From 1448cf63aa3c3c286f07a7b2a9eea92d979ddc78 Mon Sep 17 00:00:00 2001 From: WeGoHard <79616946+WeGoHard@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:47:38 -0700 Subject: [PATCH] Add Hermes desktop and CLI support on macOS and Windows --- README.ja.md | 5 +- README.md | 5 +- README.zh-CN.md | 5 +- README.zh-TW.md | 5 +- macos/Resources/strings.macos.json | 6 + .../Sources/AgentSpeed/App/PreviewData.swift | 3 +- .../AgentSpeed/History/Model/HistoryCSV.swift | 1 + .../AgentSpeed/Localization/StringTable.swift | 3 + .../AgentSpeed/Settings/AppSettings.swift | 7 +- .../Components/TranscriptFolder.swift | 10 +- .../SettingsWindow/Panes/SourcesPane.swift | 1 + .../AgentSpeed/Theme/SourcePalette.swift | 1 + .../AgentSpeedCore/Hermes/HermesLine.swift | 57 +++ .../AgentSpeedCore/Hermes/HermesSource.swift | 132 +++++++ .../AgentSpeedCore/History/DayAggregate.swift | 14 +- .../History/HistoryAnalyzer.swift | 57 ++- .../History/HistoryDatabase+Agents.swift | 41 +++ .../History/HistoryDatabase.swift | 8 +- .../Sources/AgentSpeedCore/LiveMonitor.swift | 1 + macos/Sources/AgentSpeedCore/Models.swift | 8 +- .../Monitor/SnapshotBuilder.swift | 3 + .../Monitor/TranscriptLocations.swift | 8 + .../Monitor/TranscriptSession.swift | 39 +- .../Parsing/TranscriptParser.swift | 2 +- .../Traffic/Engine/AgentProcessRules.swift | 24 +- macos/Sources/AgentSpeedProbe/Arguments.swift | 5 +- .../AgentSpeedCoreTests/HermesTests.swift | 195 ++++++++++ .../LiveMonitorTests.swift | 8 +- .../AgentSpeedCoreTests/SessionTests.swift | 8 +- spec/EXECUTION-MODES.md | 2 +- spec/METRICS.md | 54 ++- spec/fixtures/hermes/expected.json | 348 ++++++++++++++++++ spec/fixtures/hermes/logs/agent.log | 13 + spec/fixtures/hermes/logs/agent.log.1 | 2 + spec/fixtures/hermes/logs/agent.log.backup | 1 + spec/fixtures/hermes/logs/gateway.log | 1 + spec/fixtures/hermes/logs/nested/agent.log | 1 + .../hermes/profiles/coder/logs/agent.log | 1 + spec/fixtures/traffic-cases.json | 186 +++++++++- spec/report-schema.json | 2 +- spec/research/hermes.md | 88 +++++ spec/strings.json | 12 + spec/tools/hermes_fixtures.py | 99 +++++ spec/tools/hermes_reference.py | 182 +++++++++ spec/tools/reference.py | 17 +- spec/tools/report_reference.py | 2 +- spec/tools/traffic.py | 23 +- .../AgentSpeed.Core/Hermes/HermesParser.cs | 105 ++++++ .../src/AgentSpeed.Core/Hermes/HermesPaths.cs | 120 ++++++ .../AgentSpeed.Core/History/DayAggregate.cs | 28 +- .../History/HistoryAnalyzer.cs | 15 +- .../History/HistoryStore.HermesAgents.cs | 44 +++ .../AgentSpeed.Core/History/HistoryStore.cs | 5 + windows/src/AgentSpeed.Core/IO/FileTailer.cs | 4 +- .../src/AgentSpeed.Core/IO/LineSplitter.cs | 12 +- windows/src/AgentSpeed.Core/IO/RootWatcher.cs | 1 + .../AgentSpeed.Core/IO/TranscriptDiscovery.cs | 12 + .../AgentSpeed.Core/IO/TranscriptTailer.cs | 5 +- windows/src/AgentSpeed.Core/LiveMonitor.cs | 3 + windows/src/AgentSpeed.Core/Models.cs | 9 +- .../Monitoring/MonitorCore+Hermes.cs | 27 ++ .../AgentSpeed.Core/Monitoring/MonitorCore.cs | 16 + .../Monitoring/SourceLocations.cs | 11 +- .../AgentSpeed.Core/Monitoring/TrackedFile.cs | 10 +- .../AgentSpeed.Core/Reporting/ReportBody.cs | 1 + .../Traffic/Engine/AgentProcessRules.cs | 39 +- .../Colors/HeatPalette.cs | 1 + .../Formatting/DisplayText.cs | 1 + .../History/HistoryCsv.cs | 1 + .../Settings/AppSettings.cs | 10 +- .../Settings/Orbs/OrbSources.cs | 2 +- .../Settings/SettingsSerializer.cs | 4 + .../Settings/Pages/SourcesPage.xaml | 8 + .../AgentSpeed/Settings/SettingsViewModel.cs | 12 + .../BackgroundReadTests.cs | 4 +- .../AgentSpeed.Core.Tests/EngineCaseTests.cs | 2 +- .../AgentSpeed.Core.Tests/HermesTests.cs | 340 +++++++++++++++++ .../HistoryServiceTests.cs | 6 +- .../AgentSpeed.Core.Tests/LiveMonitorTests.cs | 4 +- .../DisplayTextTests.cs | 1 + .../HarnessSourceSettingsTests.cs | 4 + .../OrbEditingTests.cs | 2 +- .../OrbMenuModelTests.cs | 2 +- .../OrbSettingsTests.cs | 4 +- .../SettingsSerializerTests.cs | 4 +- 85 files changed, 2493 insertions(+), 82 deletions(-) create mode 100644 macos/Sources/AgentSpeedCore/Hermes/HermesLine.swift create mode 100644 macos/Sources/AgentSpeedCore/Hermes/HermesSource.swift create mode 100644 macos/Sources/AgentSpeedCore/History/HistoryDatabase+Agents.swift create mode 100644 macos/Tests/AgentSpeedCoreTests/HermesTests.swift create mode 100644 spec/fixtures/hermes/expected.json create mode 100644 spec/fixtures/hermes/logs/agent.log create mode 100644 spec/fixtures/hermes/logs/agent.log.1 create mode 100644 spec/fixtures/hermes/logs/agent.log.backup create mode 100644 spec/fixtures/hermes/logs/gateway.log create mode 100644 spec/fixtures/hermes/logs/nested/agent.log create mode 100644 spec/fixtures/hermes/profiles/coder/logs/agent.log create mode 100644 spec/research/hermes.md create mode 100644 spec/tools/hermes_fixtures.py create mode 100644 spec/tools/hermes_reference.py create mode 100644 windows/src/AgentSpeed.Core/Hermes/HermesParser.cs create mode 100644 windows/src/AgentSpeed.Core/Hermes/HermesPaths.cs create mode 100644 windows/src/AgentSpeed.Core/History/HistoryStore.HermesAgents.cs create mode 100644 windows/src/AgentSpeed.Core/Monitoring/MonitorCore+Hermes.cs create mode 100644 windows/tests/AgentSpeed.Core.Tests/HermesTests.cs diff --git a/README.ja.md b/README.ja.md index ac9a6f56..2c1c7793 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,7 +4,7 @@ # Agent Speed -**AI agent が全力で走る瞬間を、目で感じる。** Codex・Claude Code・Claude Desktop・DSH(DeepSeek Harness)・OpenCode・Gemini CLI・omp・Antigravity・WorkBuddy・WorkBuddy AI・ZCode・Kimi Code CLI・Grok Build のトークン速度をリアルタイムに映し出すネイティブ計器。待機中は静かに、レッドラインでは燃え上がる。 +**AI agent が全力で走る瞬間を、目で感じる。** Codex・Claude Code・Claude Desktop・DSH(DeepSeek Harness)・OpenCode・Gemini CLI・omp・Antigravity・WorkBuddy・WorkBuddy AI・ZCode・Kimi Code CLI・Grok Build・Hermes のトークン速度をリアルタイムに映し出すネイティブ計器。待機中は静かに、レッドラインでは燃え上がる。 [English](README.md) · [繁體中文](README.zh-TW.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) @@ -88,6 +88,9 @@ Agent Speed は、agent がすでにローカルに書き出している記録 | ZCode | `~/.zcode/cli/db/db.sqlite`(または `$ZCODE_DATA_BASE_DIR/.zcode/cli/db`) | | Kimi Code CLI(TypeScript v2) | `~/.kimi-code/sessions`(または `$KIMI_CODE_HOME/sessions`) | | Grok Build | `~/.grok/sessions`(または `$GROK_HOME/sessions`) | +| Hermes(デスクトップと CLI) | `~/.hermes/logs/agent.log` と番号付きローテーションログ。名前付きプロファイルを含む(または `$HERMES_HOME`。Windows: `%LOCALAPPDATA%/hermes`) | + +Hermes はローカルログから完了した API 呼び出しの使用量を読み取り、完了後に速度を更新します。ログに保存された 0.1 秒単位の所要時間を使用し、ツール実行やメッセージ間の待機時間は含めません。使用量のない古いログは復元できません。設定で Hermes のデータまたは `logs` フォルダを選択できます。 Grok Build のターン別・モデル別バッチに保存されたトークン数、API 所要時間、実際の呼び出し回数を、既存の速度エンジンで計算します。サブエージェントの使用量は親に集約されるため一度だけ計上し、子の動作状態も表示します。個々の呼び出しのタイムラインは保存されないため、バッチ内の並列実行速度は復元できません。 diff --git a/README.md b/README.md index d176a701..20bfbebd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Agent Speed -**Feel your AI agents run.** A native racing instrument for the live token speed of Codex, Claude Code, Claude Desktop, DSH (DeepSeek Harness), OpenCode, Gemini CLI, omp, Antigravity, WorkBuddy, WorkBuddy AI, ZCode, Kimi Code CLI and Grok Build — calm when idle, on fire at the redline. +**Feel your AI agents run.** A native racing instrument for the live token speed of Codex, Claude Code, Claude Desktop, DSH (DeepSeek Harness), OpenCode, Gemini CLI, omp, Antigravity, WorkBuddy, WorkBuddy AI, ZCode, Kimi Code CLI, Grok Build and Hermes — calm when idle, on fire at the redline. [English](README.md) · [繁體中文](README.zh-TW.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) @@ -93,6 +93,9 @@ Agent Speed reads the transcripts your agents already write on your disk: | ZCode | `~/.zcode/cli/db/db.sqlite` (or `$ZCODE_DATA_BASE_DIR/.zcode/cli/db`) | | Kimi Code CLI (TypeScript v2) | `~/.kimi-code/sessions` (or `$KIMI_CODE_HOME/sessions`) | | Grok Build | `~/.grok/sessions` (or `$GROK_HOME/sessions`) | +| Hermes (desktop and CLI) | `~/.hermes/logs/agent.log` and numbered rotations, including named profiles (or `$HERMES_HOME`; Windows: `%LOCALAPPDATA%/hermes`) | + +Hermes reads completed API-call usage from its local logs. Speed updates after each call finishes; it uses the logged latency (rounded to 0.1 seconds), not time spent on tools or between messages. Older logs without usage records cannot be reconstructed. Hermes home or `logs` folders can be selected in Settings. Grok Build supplies native per-turn model batches: token counts, API duration and the actual number of model calls enter the same speed engine. Child usage is already included in its parent and is counted once; child activity remains visible. Its individual-call timeline is not stored, so intra-batch concurrency cannot be reconstructed. diff --git a/README.zh-CN.md b/README.zh-CN.md index e6e1d813..e5584226 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ # Agent Speed -**看见你的 AI agent 全速奔跑。** 为 Codex、Claude Code、Claude Desktop、DSH(DeepSeek Harness)、OpenCode、Gemini CLI、omp、Antigravity、WorkBuddy、WorkBuddy AI、ZCode、Kimi Code CLI 与 Grok Build 打造的原生实时 token 速度仪表——空闲时安静,冲到红线时全面燃烧。 +**看见你的 AI agent 全速奔跑。** 为 Codex、Claude Code、Claude Desktop、DSH(DeepSeek Harness)、OpenCode、Gemini CLI、omp、Antigravity、WorkBuddy、WorkBuddy AI、ZCode、Kimi Code CLI、Grok Build 与 Hermes 打造的原生实时 token 速度仪表——空闲时安静,冲到红线时全面燃烧。 [English](README.md) · [繁體中文](README.zh-TW.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) @@ -88,6 +88,9 @@ Agent Speed 只读取 agent 本来就写在你电脑上的记录: | ZCode | `~/.zcode/cli/db/db.sqlite`(或 `$ZCODE_DATA_BASE_DIR/.zcode/cli/db`) | | Kimi Code CLI(TypeScript v2) | `~/.kimi-code/sessions`(或 `$KIMI_CODE_HOME/sessions`) | | Grok Build | `~/.grok/sessions`(或 `$GROK_HOME/sessions`) | +| Hermes(桌面端和 CLI) | `~/.hermes/logs/agent.log` 及数字轮转日志,含命名配置(或 `$HERMES_HOME`;Windows:`%LOCALAPPDATA%/hermes`) | + +Hermes 从本地日志读取已完成 API 调用的用量,速度在每次调用结束后更新,采用日志中精确到 0.1 秒的耗时,不计入工具执行或消息之间的等待。没有用量记录的旧日志无法还原。可以在设置中选择 Hermes 数据或 `logs` 文件夹。 Grok Build 提供每回合、每模型的原生批次记录:Token、API 耗时与实际调用数交给同一套速度引擎计算。子 agent 用量已合并在父会话中,因此只计算一次,仍会显示子 agent 的活动状态;上游未保存每次调用的独立时间线,无法重建批次内的并行速度。 diff --git a/README.zh-TW.md b/README.zh-TW.md index e6c9f758..1b9ee07b 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -4,7 +4,7 @@ # Agent Speed -**看見你的 AI agent 全速奔跑。** 為 Codex、Claude Code、Claude Desktop、DSH(DeepSeek Harness)、OpenCode、Gemini CLI、omp、Antigravity、WorkBuddy、WorkBuddy AI、ZCode、Kimi Code CLI 與 Grok Build 打造的原生即時 token 速度儀表——閒置時安靜,衝到紅線時全面燃燒。 +**看見你的 AI agent 全速奔跑。** 為 Codex、Claude Code、Claude Desktop、DSH(DeepSeek Harness)、OpenCode、Gemini CLI、omp、Antigravity、WorkBuddy、WorkBuddy AI、ZCode、Kimi Code CLI、Grok Build 與 Hermes 打造的原生即時 token 速度儀表——閒置時安靜,衝到紅線時全面燃燒。 [English](README.md) · [繁體中文](README.zh-TW.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) @@ -88,6 +88,9 @@ Agent Speed 只讀取 agent 本來就寫在你電腦上的紀錄: | ZCode | `~/.zcode/cli/db/db.sqlite`(或 `$ZCODE_DATA_BASE_DIR/.zcode/cli/db`) | | Kimi Code CLI(TypeScript v2) | `~/.kimi-code/sessions`(或 `$KIMI_CODE_HOME/sessions`) | | Grok Build | `~/.grok/sessions`(或 `$GROK_HOME/sessions`) | +| Hermes(桌面版和 CLI) | `~/.hermes/logs/agent.log` 及數字輪轉日誌,含具名設定檔(或 `$HERMES_HOME`;Windows:`%LOCALAPPDATA%/hermes`) | + +Hermes 從本機日誌讀取已完成 API 呼叫的用量,速度在每次呼叫結束後更新,採用日誌中精確至 0.1 秒的耗時,不計入工具執行或訊息之間的等待。沒有用量紀錄的舊日誌無法還原。可在設定中選擇 Hermes 資料或 `logs` 資料夾。 Grok Build 提供每回合、每模型的原生批次紀錄:Token、API 耗時與實際呼叫數交給同一套速度引擎計算。子 agent 用量已合併在父工作階段中,因此只計算一次,仍會顯示子 agent 的活動狀態;上游未保存每次呼叫的獨立時間線,無法重建批次內的並行速度。 diff --git a/macos/Resources/strings.macos.json b/macos/Resources/strings.macos.json index e6812e83..d0c22679 100644 --- a/macos/Resources/strings.macos.json +++ b/macos/Resources/strings.macos.json @@ -486,6 +486,12 @@ "en": "Traffic", "ja": "通信量" }, + "mac.settings.sources.chooseHermes": { + "zh-Hant": "選擇 Hermes 資料夾", + "zh-Hans": "选择 Hermes 文件夹", + "en": "Choose Hermes Folder", + "ja": "Hermes フォルダを選択" + }, "mac.settings.sources.chooseZcode": { "zh-Hant": "選擇 ZCode 資料夾", "zh-Hans": "选择 ZCode 文件夹", diff --git a/macos/Sources/AgentSpeed/App/PreviewData.swift b/macos/Sources/AgentSpeed/App/PreviewData.swift index 2bb6e3f1..72c47efc 100644 --- a/macos/Sources/AgentSpeed/App/PreviewData.swift +++ b/macos/Sources/AgentSpeed/App/PreviewData.swift @@ -160,7 +160,7 @@ enum PreviewData { switch source.kind { case .deepseek: source.available = false case .claudeDesktop: source.enabled = false - case .codex, .claudeCode, .opencode, .gemini, .omp, .antigravity, .workbuddy, .workbuddyAi, .zcode, .kimi, .grokBuild: return source + case .codex, .claudeCode, .opencode, .gemini, .omp, .antigravity, .workbuddy, .workbuddyAi, .zcode, .kimi, .grokBuild, .hermes: return source } source.sessionCount = 0 source.live = .zero @@ -209,6 +209,7 @@ enum PreviewData { case .zcode: 3 case .kimi: 2 case .grokBuild: 1 + case .hermes: 1 } } } diff --git a/macos/Sources/AgentSpeed/History/Model/HistoryCSV.swift b/macos/Sources/AgentSpeed/History/Model/HistoryCSV.swift index 09b858e6..625b2811 100644 --- a/macos/Sources/AgentSpeed/History/Model/HistoryCSV.swift +++ b/macos/Sources/AgentSpeed/History/Model/HistoryCSV.swift @@ -36,6 +36,7 @@ enum HistoryCSV { case .zcode: "zcode" case .kimi: "kimi" case .grokBuild: "grokBuild" + case .hermes: "hermes" } } diff --git a/macos/Sources/AgentSpeed/Localization/StringTable.swift b/macos/Sources/AgentSpeed/Localization/StringTable.swift index 2ebd87b5..d00ede4c 100644 --- a/macos/Sources/AgentSpeed/Localization/StringTable.swift +++ b/macos/Sources/AgentSpeed/Localization/StringTable.swift @@ -245,6 +245,7 @@ enum StringTable { "mac.settings.sources.chooseDsh": Entry("選擇 DeepSeek Harness 的 sessions 資料夾", "选择 DeepSeek Harness 的 sessions 文件夹", "Choose the DeepSeek Harness sessions folder", "DeepSeek Harness の sessions フォルダを選択"), "mac.settings.sources.chooseGemini": Entry("選擇 Gemini CLI 的 tmp 資料夾", "选择 Gemini CLI 的 tmp 文件夹", "Choose the Gemini CLI tmp folder", "Gemini CLI の tmp フォルダを選択"), "mac.settings.sources.chooseGrokBuild": Entry("選擇 Grok Build 資料夾", "选择 Grok Build 文件夹", "Choose Grok Build Folder", "Grok Build フォルダを選択"), + "mac.settings.sources.chooseHermes": Entry("選擇 Hermes 資料夾", "选择 Hermes 文件夹", "Choose Hermes Folder", "Hermes フォルダを選択"), "mac.settings.sources.chooseKimi": Entry("選擇 Kimi Code CLI 資料夾", "选择 Kimi Code CLI 文件夹", "Choose Kimi Code CLI Folder", "Kimi Code CLI フォルダを選択"), "mac.settings.sources.chooseOmp": Entry("選擇 omp 的 sessions 資料夾", "选择 omp 的 sessions 文件夹", "Choose the omp sessions folder", "omp の sessions フォルダを選択"), "mac.settings.sources.chooseOpencode": Entry("選擇內含 opencode.db 的 OpenCode 資料夾", "选择包含 opencode.db 的 OpenCode 文件夹", "Choose the OpenCode folder that contains opencode.db", "opencode.db を含む OpenCode のフォルダを選択"), @@ -625,6 +626,7 @@ enum StringTable { "settings.sources.dshHint": Entry("DeepSeek Harness(dsh)桌面版與命令列共用這個資料夾。", "DeepSeek Harness(dsh)桌面版与命令行共用这个文件夹。", "The DeepSeek Harness (dsh) desktop app and CLI share this folder.", "DeepSeek Harness(dsh)のデスクトップアプリと CLI はこのフォルダを共有します。"), "settings.sources.geminiHint": Entry("Gemini CLI 的對話紀錄資料夾。", "Gemini CLI 的对话记录文件夹。", "The Gemini CLI conversation folder.", "Gemini CLI の会話記録フォルダです。"), "settings.sources.grokBuildHint": Entry("讀取 Grok Build 的 sessions 資料夾,包含各工作階段的更新紀錄。", "读取 Grok Build 的 sessions 文件夹,包括各会话的更新记录。", "Reads Grok Build’s sessions folder and its session update records.", "Grok Build の sessions フォルダと各セッションの更新記録を読み取ります。"), + "settings.sources.hermesHint": Entry("讀取 Hermes 的本機 API 呼叫日誌(含具名設定檔);每次呼叫完成後更新速度。可選擇 Hermes 資料或 logs 資料夾。", "读取 Hermes 的本地 API 调用日志(含命名配置);每次调用完成后更新速度。可选择 Hermes 数据或 logs 文件夹。", "Reads Hermes’s local API call logs, including named profiles. Speed updates after each call completes. Choose the Hermes home or logs folder.", "名前付きプロファイルを含む Hermes のローカル API ログを読み取り、呼び出し完了後に速度を更新します。Hermes のデータまたは logs フォルダを選択できます。"), "settings.sources.kimiHint": Entry("讀取 Kimi Code CLI 的 sessions 資料夾,包含主 agent 與子 agent 的紀錄。", "读取 Kimi Code CLI 的 sessions 文件夹,包括主 agent 和子 agent 的记录。", "Reads Kimi Code CLI’s sessions folder, including main agents and subagents.", "Kimi Code CLI の sessions フォルダから、メインエージェントとサブエージェントの記録を読み取ります。"), "settings.sources.ompHint": Entry("omp(oh-my-pi)的 sessions 資料夾,通常是 ~/.omp/agent/sessions。未選擇資料夾時,也會自動讀取各個具名設定檔。", "omp(oh-my-pi)的 sessions 文件夹,通常是 ~/.omp/agent/sessions。未选择文件夹时,也会自动读取各个命名配置文件。", "The sessions folder of omp (oh-my-pi), usually ~/.omp/agent/sessions. Unless you choose a folder, named profiles are included automatically.", "omp(oh-my-pi)の sessions フォルダです。通常は ~/.omp/agent/sessions です。フォルダを選ばない場合は、名前付きプロファイルも自動で読み込みます。"), "settings.sources.opencodeHint": Entry("OpenCode 命令列與桌面版共用這個資料庫,只會以唯讀方式讀取。", "OpenCode 命令行与桌面版共用这个数据库,只会以只读方式读取。", "The OpenCode CLI and desktop app share this database; it is only ever read.", "OpenCode の CLI とデスクトップ版はこのデータベースを共有します。読み取り専用で開きます。"), @@ -652,6 +654,7 @@ enum StringTable { "source.folderMissing": Entry("未找到資料夾", "未找到文件夹", "Folder not found", "フォルダが見つかりません"), "source.gemini": Entry("Gemini CLI", "Gemini CLI", "Gemini CLI", "Gemini CLI"), "source.grokBuild": Entry("Grok Build", "Grok Build", "Grok Build", "Grok Build"), + "source.hermes": Entry("Hermes", "Hermes", "Hermes", "Hermes"), "source.kimi": Entry("Kimi Code CLI", "Kimi Code CLI", "Kimi Code CLI", "Kimi Code CLI"), "source.notFound": Entry("未偵測到", "未检测到", "Not detected", "未検出"), "source.omp": Entry("omp", "omp", "omp", "omp"), diff --git a/macos/Sources/AgentSpeed/Settings/AppSettings.swift b/macos/Sources/AgentSpeed/Settings/AppSettings.swift index cf88e21d..624dd40a 100644 --- a/macos/Sources/AgentSpeed/Settings/AppSettings.swift +++ b/macos/Sources/AgentSpeed/Settings/AppSettings.swift @@ -77,6 +77,7 @@ final class AppSettings { var workbuddyRoot: URL? { didSet { store(workbuddyRoot?.path, .workbuddyRoot) } } /// WorkBuddy AI's data folder (or its `projects` folder), replacing `~/.workbuddy-ai`. var workbuddyAiRoot: URL? { didSet { store(workbuddyAiRoot?.path, .workbuddyAiRoot) } } + var hermesRoot: URL? { didSet { store(hermesRoot?.path, .hermesRoot) } } var zcodeRoot: URL? { didSet { store(zcodeRoot?.path, .zcodeRoot) } } var kimiRoot: URL? { didSet { store(kimiRoot?.path, .kimiRoot) } } var grokBuildRoot: URL? { didSet { store(grokBuildRoot?.path, .grokBuildRoot) } } @@ -157,6 +158,7 @@ final class AppSettings { antigravityRoot = Self.url(defaults.string(forKey: Key.antigravityRoot.rawValue)) workbuddyRoot = Self.url(defaults.string(forKey: Key.workbuddyRoot.rawValue)) workbuddyAiRoot = Self.url(defaults.string(forKey: Key.workbuddyAiRoot.rawValue)) + hermesRoot = Self.url(defaults.string(forKey: Key.hermesRoot.rawValue)) zcodeRoot = Self.url(defaults.string(forKey: Key.zcodeRoot.rawValue)) kimiRoot = Self.url(defaults.string(forKey: Key.kimiRoot.rawValue)) grokBuildRoot = Self.url(defaults.string(forKey: Key.grokBuildRoot.rawValue)) @@ -191,7 +193,8 @@ final class AppSettings { workbuddyAiRoot: workbuddyAiRoot, zcodeRoot: zcodeRoot, kimiRoot: kimiRoot, - grokBuildRoot: grokBuildRoot + grokBuildRoot: grokBuildRoot, + hermesRoot: hermesRoot ) } @@ -256,7 +259,7 @@ final class AppSettings { case effects, celebratePeaks, responsePulses case enabledSources, knownSources case codexRoot, claudeRoot, dshRoot, opencodeRoot, geminiRoot, ompRoot, antigravityRoot, workbuddyRoot, workbuddyAiRoot - case zcodeRoot, kimiRoot, grokBuildRoot + case zcodeRoot, kimiRoot, grokBuildRoot, hermesRoot case autoCheckUpdates case networkEnabled, syncPrices, currency, liveUnit, subscriptionSources, includeSubscriptions case heroLastTitle diff --git a/macos/Sources/AgentSpeed/SettingsWindow/Components/TranscriptFolder.swift b/macos/Sources/AgentSpeed/SettingsWindow/Components/TranscriptFolder.swift index 22cf2ad9..3d1d3e65 100644 --- a/macos/Sources/AgentSpeed/SettingsWindow/Components/TranscriptFolder.swift +++ b/macos/Sources/AgentSpeed/SettingsWindow/Components/TranscriptFolder.swift @@ -18,6 +18,7 @@ enum TranscriptFolder: CaseIterable { case zcode case kimi case grokBuild + case hermes /// Every default location, in reading order. var defaultURLs: [URL] { @@ -33,6 +34,7 @@ enum TranscriptFolder: CaseIterable { case .zcode: [LiveMonitor.defaultZcodeRoot] case .kimi: [LiveMonitor.defaultKimiRoot] case .grokBuild: [LiveMonitor.defaultGrokBuildRoot] + case .hermes: [LiveMonitor.defaultHermesRoot] case .workbuddyAi: [LiveMonitor.defaultWorkBuddyAiDataFolder] } } @@ -52,6 +54,7 @@ enum TranscriptFolder: CaseIterable { case .zcode: "mac.settings.sources.chooseZcode" case .kimi: "mac.settings.sources.chooseKimi" case .grokBuild: "mac.settings.sources.chooseGrokBuild" + case .hermes: "mac.settings.sources.chooseHermes" case .workbuddyAi: "mac.settings.sources.chooseWorkbuddyAi" } } @@ -70,6 +73,7 @@ enum TranscriptFolder: CaseIterable { case .zcode: "settings.sources.zcodeHint" case .kimi: "settings.sources.kimiHint" case .grokBuild: "settings.sources.grokBuildHint" + case .hermes: "settings.sources.hermesHint" case .workbuddyAi: "settings.sources.workbuddyAiHint" } } @@ -88,6 +92,7 @@ enum TranscriptFolder: CaseIterable { case .zcode: settings.zcodeRoot case .kimi: settings.kimiRoot case .grokBuild: settings.grokBuildRoot + case .hermes: settings.hermesRoot case .workbuddyAi: settings.workbuddyAiRoot } } @@ -114,7 +119,7 @@ enum TranscriptFolder: CaseIterable { case .antigravity: let found = defaultURLs.filter(isReadable) return found.isEmpty ? defaultURLs : found - case .codex, .claude, .dsh, .opencode, .gemini, .workbuddy, .workbuddyAi, .zcode, .kimi, .grokBuild: + case .codex, .claude, .dsh, .opencode, .gemini, .workbuddy, .workbuddyAi, .zcode, .kimi, .grokBuild, .hermes: return defaultURLs } } @@ -142,6 +147,7 @@ enum TranscriptFolder: CaseIterable { case .zcode: settings.zcodeRoot = value case .kimi: settings.kimiRoot = value case .grokBuild: settings.grokBuildRoot = value + case .hermes: settings.hermesRoot = value case .workbuddyAi: settings.workbuddyAiRoot = value } } @@ -152,7 +158,7 @@ enum TranscriptFolder: CaseIterable { switch self { case .workbuddy, .workbuddyAi: Self.isSameFolder(LiveMonitor.workbuddyProjectsFolder(url), LiveMonitor.workbuddyProjectsFolder(defaultURL)) - case .codex, .claude, .dsh, .opencode, .gemini, .omp, .antigravity, .zcode, .kimi, .grokBuild: + case .codex, .claude, .dsh, .opencode, .gemini, .omp, .antigravity, .zcode, .kimi, .grokBuild, .hermes: Self.isSameFolder(url, defaultURL) } } diff --git a/macos/Sources/AgentSpeed/SettingsWindow/Panes/SourcesPane.swift b/macos/Sources/AgentSpeed/SettingsWindow/Panes/SourcesPane.swift index e28be2d3..038bdeb3 100644 --- a/macos/Sources/AgentSpeed/SettingsWindow/Panes/SourcesPane.swift +++ b/macos/Sources/AgentSpeed/SettingsWindow/Panes/SourcesPane.swift @@ -67,6 +67,7 @@ extension SourceKind { case .zcode: .zcode case .kimi: .kimi case .grokBuild: .grokBuild + case .hermes: .hermes } } } diff --git a/macos/Sources/AgentSpeed/Theme/SourcePalette.swift b/macos/Sources/AgentSpeed/Theme/SourcePalette.swift index a357e0a4..e24da5d6 100644 --- a/macos/Sources/AgentSpeed/Theme/SourcePalette.swift +++ b/macos/Sources/AgentSpeed/Theme/SourcePalette.swift @@ -17,6 +17,7 @@ extension SourceKind { case .zcode: RGBColor(hex: 0x466BFF) case .kimi: RGBColor(hex: 0xE16F8A) case .grokBuild: RGBColor(hex: 0x8290A6) + case .hermes: RGBColor(hex: 0xD69B43) } } diff --git a/macos/Sources/AgentSpeedCore/Hermes/HermesLine.swift b/macos/Sources/AgentSpeedCore/Hermes/HermesLine.swift new file mode 100644 index 00000000..e67bb628 --- /dev/null +++ b/macos/Sources/AgentSpeedCore/Hermes/HermesLine.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Hermes's provider-confirmed per-call usage log, shared by its desktop app, CLI and gateway. +/// Timestamps are Python logging's local wall clock; latency is rounded to tenths of a second. +final class HermesLine { + private let formatter: DateFormatter + private static let expression = try! NSRegularExpression(pattern: + #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) INFO(?:[ \t]*\[([^\]\r\n]+)\])?[ \t]+(?:run_agent|agent\.conversation_loop|agent\.turn_usage): API call #(\d+): model=(\S+) provider=(\S+) in=(\d+) out=(\d+) total=\d+ latency=(\d+(?:\.\d+)?)s(?: (.*))?$"#) + private static let cacheExpression = try! NSRegularExpression(pattern: #"(?:^| )cache=(\d+)/\d+ \([^)]*\)(?: |$)"#) + private static let writeExpression = try! NSRegularExpression(pattern: #"(?:^| )write=(\d+)(?: |$)"#) + + init(timeZone: TimeZone = .current) { + formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss,SSS" + formatter.isLenient = false + } + + func record(_ bytes: UnsafeRawBufferPointer, logDirectory: String) -> ResponseRecord? { + guard bytes.count <= 64 * 1024 else { return nil } + let text = String(decoding: bytes, as: UTF8.self) + guard let match = Self.expression.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) else { return nil } + func field(_ index: Int) -> String? { + guard let range = Range(match.range(at: index), in: text) else { return nil } + return String(text[range]) + } + guard let timestamp = field(1), let date = formatter.date(from: timestamp), formatter.string(from: date) == timestamp, + let call = field(3), let model = field(4), let provider = field(5), + let inputText = field(6), let outputText = field(7), + let durationText = field(8), let seconds = Double(durationText), seconds.isFinite else { return nil } + let end = Clock.milliseconds(date) + guard DayLabeler.hasDay(end) else { return nil } + let milliseconds = seconds * 1000 + let start = milliseconds > 0 && milliseconds <= Double(ResponseTiming.maxIntervalMs) ? end - Int64(milliseconds.rounded()) : nil + func count(_ digits: String) -> Int { + TokenCount.clamp(Int64(digits) ?? TokenCount.ceiling) + } + let suffix = field(9) ?? "" + func suffixCount(_ expression: NSRegularExpression) -> Int { + guard let match = expression.firstMatch(in: suffix, range: NSRange(suffix.startIndex..., in: suffix)), + let range = Range(match.range(at: 1), in: suffix) else { return 0 } + return count(String(suffix[range])) + } + let normalizedInput = count(inputText) + let session = field(2) ?? logDirectory + return ResponseRecord(key: "hermes:\(session):\(end):\(call)", source: .hermes, + agentId: logDirectory + "#" + (field(2) ?? "untagged"), model: model == "unknown" ? nil : model, + startMs: start, endMs: end, + usage: TokenUsage(input: normalizedInput, + cachedInput: min(normalizedInput, suffixCount(Self.cacheExpression)), + cacheWriteInput: min(normalizedInput, suffixCount(Self.writeExpression)), + output: count(outputText)), + provider: provider == "unknown" ? nil : provider) + } +} diff --git a/macos/Sources/AgentSpeedCore/Hermes/HermesSource.swift b/macos/Sources/AgentSpeedCore/Hermes/HermesSource.swift new file mode 100644 index 00000000..9147330e --- /dev/null +++ b/macos/Sources/AgentSpeedCore/Hermes/HermesSource.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Incremental, read-only log tailing. Multiple sessions may share a log, and rotations retain response identity. +final class HermesSource { + struct Update { + var upserts: [(ResponseUpsert, String)] = [] + var agents: [String: AgentState] = [:] + var isEmpty: Bool { upserts.isEmpty && agents.isEmpty } + } + struct LogFile { + var path: String + var directory: String + var stat: FileStat + /// Inode identity survives rotations; retaining old entries preserves history after logs expire. + var historyKey: String { "hermes-log:\(directory):\(stat.identity.device):\(stat.identity.inode)" } + } + let rootPath: String + private let fileID: FileID + private let parser: HermesLine + private var tailers: [String: FileTailer] = [:] + private var stats: [String: FileStat] = [:] + private var states: [String: AgentState] = [:] + private var sinceMs: Int64 = 0 + private(set) var exists = false + private(set) var unreadableFiles = 0 + private(set) var partialHistory = false + private(set) var bytesRead: Int64 = 0 + var isReady: Bool { exists } + var watchedPaths: [String] { Self.logDirectories(root: rootPath).filter(TranscriptDiscovery.isAvailable(root:)) } + + init(root: String, fileID: FileID, timeZone: TimeZone = .current) { + rootPath = root + self.fileID = fileID + parser = HermesLine(timeZone: timeZone) + } + + static func logDirectories(root: String) -> [String] { + // The configured root may be an alias, as with other sources; discovered child folders may not be symlinks. + let root = URL(fileURLWithPath: root, isDirectory: true).standardizedFileURL.resolvingSymlinksInPath() + guard FileStat.lstat(root.path)?.isDirectory == true else { return [] } + if root.lastPathComponent == "logs" { return [root.path] } + var paths: [String] = [] + let logs = root.appendingPathComponent("logs") + if FileStat.lstat(logs.path)?.isDirectory == true { paths.append(logs.path) } + let profiles = root.appendingPathComponent("profiles") + guard FileStat.lstat(profiles.path)?.isDirectory == true else { return paths } + for name in ((try? FileManager.default.contentsOfDirectory(atPath: profiles.path)) ?? []).sorted() { + let profile = profiles.appendingPathComponent(name) + guard FileStat.lstat(profile.path)?.isDirectory == true else { continue } + let logs = profile.appendingPathComponent("logs") + if FileStat.lstat(logs.path)?.isDirectory == true { paths.append(logs.path) } + } + return paths + } + + static func files(root: String) -> [LogFile] { + var files: [LogFile] = [] + for directory in logDirectories(root: root) { + let names = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] + for name in names where isLogName(name) { + let path = (directory as NSString).appendingPathComponent(name) + guard let stat = FileStat.lstat(path), stat.isRegularFile else { continue } + files.append(LogFile(path: path, directory: directory, stat: stat)) + } + } + return files.sorted { lhs, rhs in + if lhs.stat.modifiedNs != rhs.stat.modifiedNs { return lhs.stat.modifiedNs < rhs.stat.modifiedNs } + return lhs.path < rhs.path + } + } + + static func isLogName(_ name: String) -> Bool { + if name == "agent.log" { return true } + guard name.hasPrefix("agent.log.") else { return false } + let suffix = name.dropFirst("agent.log.".count) + return !suffix.isEmpty && suffix.utf8.allSatisfy { (48...57).contains($0) } + } + + @discardableResult + func refreshAvailability() -> Bool { + let available = !watchedPaths.isEmpty + defer { exists = available } + return exists != available + } + + func seed(todayStartMs: Int64, deduper: inout Deduper) -> Update { + sinceMs = todayStartMs - TranscriptSession.startupLookbackMs + return refresh(deduper: &deduper) + } + + func refresh(deduper: inout Deduper) -> Update { + refreshAvailability() + var update = Update() + let files = Self.files(root: rootPath) + let paths = Set(files.map(\.path)) + tailers = tailers.filter { paths.contains($0.key) } + stats = stats.filter { paths.contains($0.key) } + unreadableFiles = 0 + var records: [ResponseRecord] = [] + for file in files { + guard stats[file.path] != file.stat else { continue } + let start: FileTailer.Start = file.stat.size > FileTailer.largeFileBytes ? .tail(FileTailer.tailWindowBytes) : .beginning + if tailers[file.path] == nil && file.stat.size > FileTailer.largeFileBytes { partialHistory = true } + var tailer = tailers[file.path] ?? FileTailer(path: file.path, start: start, maxLineBytes: 64 * 1024) + let result = tailer.read { bytes in + if let record = parser.record(bytes, logDirectory: file.directory), record.endMs >= sinceMs { records.append(record) } + } + if result.failed { unreadableFiles += 1 } else { stats[file.path] = result.stat } + if tailer.oversizedLines > 0 { partialHistory = true } + bytesRead += Int64(result.bytesRead) + tailers[file.path] = tailer + } + for record in records.sorted(by: { $0.endMs != $1.endMs ? $0.endMs < $1.endMs : $0.key < $1.key }) { + guard deduper.claim(record.key, for: fileID) == .new else { continue } + var state = states[record.agentId] ?? AgentState(source: .hermes) + if record.endMs >= (state.lastLineMs ?? 0) { + state.model = record.model + state.project = "Hermes" + state.noteLine(at: record.endMs) + state.setPhase(.idle, at: record.endMs) + states[record.agentId] = state + update.agents[record.agentId] = state + } + update.upserts.append((ResponseUpsert(record: record, isCompletion: true), "Hermes")) + } + return update + } + + func activeSessionCount(todayStartMs: Int64) -> Int { + states.values.filter { ($0.lastLineMs ?? 0) >= todayStartMs }.count + } +} diff --git a/macos/Sources/AgentSpeedCore/History/DayAggregate.swift b/macos/Sources/AgentSpeedCore/History/DayAggregate.swift index fa5ee51b..e978750b 100644 --- a/macos/Sources/AgentSpeedCore/History/DayAggregate.swift +++ b/macos/Sources/AgentSpeedCore/History/DayAggregate.swift @@ -52,6 +52,8 @@ struct DayAggregate: Equatable, Sendable { var measuredMs: Int64 = 0 var unfinalized = 0 var agents = 0 + /// Hermes shares log files between sessions and rotates a session across files. Only opaque IDs are persisted. + var agentHashes: Set = [] var models: [String: ModelTally] = [:] var modes: [ExecutionModeUsageKey: ExecutionModeUsage] = [:] /// Nil when the contribution was stored before costs existed (METRICS 8). @@ -95,7 +97,8 @@ struct DayAggregate: Equatable, Sendable { measuredOutput = measuredOutput.saturatingAdd(other.measuredOutput) measuredMs += other.measuredMs unfinalized += other.unfinalized - agents += other.agents + agents += other.agents - agentHashes.intersection(other.agentHashes).count + agentHashes.formUnion(other.agentHashes) for (key, usage) in other.modes { if modes[key] == nil { modes[key] = usage } else { modes[key]?.add(usage) } } @@ -115,9 +118,14 @@ struct DayAggregate: Equatable, Sendable { var days: [UsageKey: DayAggregate] = [:] for record in records where DayLabeler.hasDay(record.endMs) { let day = labeler.label(forMs: record.endMs) - days[UsageKey(day: day, source: source), default: DayAggregate()].add(record, cost: pricing.cost(of: record, day: day)) + let key = UsageKey(day: day, source: source) + days[key, default: DayAggregate()].add(record, cost: pricing.cost(of: record, day: day)) + if source == .hermes { days[key]?.agentHashes.insert(KeyHash.fnv1a(record.agentId)) } + } + for key in days.keys { + let agents = source == .hermes ? (days[key]?.agentHashes.count ?? 0) : 1 + days[key]?.agents = agents } - for key in days.keys { days[key]?.agents = 1 } return days } diff --git a/macos/Sources/AgentSpeedCore/History/HistoryAnalyzer.swift b/macos/Sources/AgentSpeedCore/History/HistoryAnalyzer.swift index 85af68aa..87ec0eeb 100644 --- a/macos/Sources/AgentSpeedCore/History/HistoryAnalyzer.swift +++ b/macos/Sources/AgentSpeedCore/History/HistoryAnalyzer.swift @@ -105,6 +105,11 @@ final class HistoryAnalyzer { var previous: HistoryDatabase.Entry? } + private struct PlannedHermesLog { + var file: HermesSource.LogFile + var previous: HistoryDatabase.Entry? + } + private struct Plan { var files: [PlannedFile] = [] var discovered = 0 @@ -112,8 +117,9 @@ final class HistoryAnalyzer { var opencodeIdentity: String? var zcode: [PlannedZcodeSession] = [] var zcodeIdentity: String? + var hermes: [PlannedHermesLog] = [] - var bytes: Int64 { files.reduce(0) { $0 + $1.stat.size } } + var bytes: Int64 { files.reduce(0) { $0 + $1.stat.size } + hermes.reduce(0) { $0 + $1.file.stat.size } } } /// The work a run would do: discovered files whose identity or analysis version changed, and changed OpenCode @@ -148,6 +154,16 @@ final class HistoryAnalyzer { if configuration.readsZcode { try planZcode(configuration: configuration, options: options, zoneChanged: zoneChanged, repricing: repricing, into: &plan) } + if configuration.readsHermes { + for file in HermesSource.files(root: configuration.resolvedHermesRoot) { + plan.discovered += 1 + let previous = entries[file.historyKey] + if let previous, !zoneChanged, previous.version == options.analysisVersion, + previous.size == file.stat.size, previous.modified == file.stat.modifiedNs, + !(repricing && previous.hasUnpriced) { continue } + plan.hermes.append(PlannedHermesLog(file: file, previous: previous)) + } + } return plan } @@ -240,18 +256,18 @@ final class HistoryAnalyzer { let plan = try plan(configuration: configuration, options: options, zoneChanged: zoneChanged, repricing: repricing) report.filesDiscovered = plan.discovered let sessions = plan.opencode?.sessions ?? [] - var state = Progress(filesTotal: plan.files.count + sessions.count + plan.zcode.count, bytesTotal: plan.bytes) + var state = Progress(filesTotal: plan.files.count + sessions.count + plan.zcode.count + plan.hermes.count, bytesTotal: plan.bytes) progress(state) func elapsedMs() -> Double { Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000 } - if plan.files.isEmpty && sessions.isEmpty && plan.zcode.isEmpty { + if plan.files.isEmpty && sessions.isEmpty && plan.zcode.isEmpty && plan.hermes.isEmpty { try finish(plan: plan, zone: zone, priceVersion: priceVersion) report.durationMs = elapsedMs() return report } var deduper = Deduper.hashed() - let replaced = Set(plan.files.compactMap(\.previous?.id) + sessions.compactMap(\.previous?.id) + plan.zcode.compactMap(\.previous?.id)) + let replaced = Set(plan.files.compactMap(\.previous?.id) + sessions.compactMap(\.previous?.id) + plan.zcode.compactMap(\.previous?.id) + plan.hermes.compactMap(\.previous?.id)) try database.loadKeys(excluding: replaced, into: &deduper) var labeler = DayLabeler(timeZone: options.timeZone) var pending: [(path: String, entry: HistoryDatabase.Entry, keys: [UInt64])] = [] @@ -319,6 +335,39 @@ final class HistoryAnalyzer { } } + if !report.cancelled { + let parser = HermesLine() + for planned in plan.hermes { + if isCancelled() { report.cancelled = true; break } + let file = planned.file + var tailer = FileTailer(path: file.path, maxLineBytes: 64 * 1024) + var records: [String: ResponseRecord] = [:] + let read = tailer.read { bytes in + if let record = parser.record(bytes, logDirectory: file.directory) { records[record.key] = record } + } + state.filesDone += 1 + state.bytesDone += file.stat.size + // A rotation between planning and opening must never replace the old inode's history with new data. + guard !read.failed, let actual = read.stat, actual.identity == file.stat.identity else { + report.filesUnreadable += 1 + if let id = planned.previous?.id { deduper.insertSettled(try database.keys(ofFile: id)) } + progress(state) + continue + } + let accepted = records.values.filter { deduper.claim($0.key, for: TranscriptSession.hermesFileID) != .otherFile } + let keys = deduper.finishFile() + let entry = HistoryDatabase.Entry(id: planned.previous?.id, family: "hermes", size: actual.size, + modified: actual.modifiedNs, version: options.analysisVersion, + days: DayAggregate.contributions(of: accepted, source: .hermes, labeler: &labeler, pricing: pricing)) + entries[file.historyKey] = entry + pending.append((file.historyKey, entry, keys)) + report.filesAnalyzed += 1 + report.bytesAnalyzed += file.stat.size + try flush(force: false) + progress(state) + } + } + var unreadSessions = 0 if let opencode = plan.opencode, !report.cancelled { let fileID = TranscriptSession.opencodeFileID diff --git a/macos/Sources/AgentSpeedCore/History/HistoryDatabase+Agents.swift b/macos/Sources/AgentSpeedCore/History/HistoryDatabase+Agents.swift new file mode 100644 index 00000000..a1cca2cb --- /dev/null +++ b/macos/Sources/AgentSpeedCore/History/HistoryDatabase+Agents.swift @@ -0,0 +1,41 @@ +import Foundation + +extension HistoryDatabase { + /// Additive, like execution modes: older builds can continue using the same history store. + static let agentTableSQL = """ + CREATE TABLE IF NOT EXISTS contribution_agents ( + file_id INTEGER NOT NULL, + day TEXT NOT NULL, + source TEXT NOT NULL, + agent_hash INTEGER NOT NULL, + PRIMARY KEY (file_id, day, source, agent_hash) + ) WITHOUT ROWID; + """ + + func loadAgents(into entries: inout [String: Entry], paths: [Int64: String]) throws { + let query = try connection.prepare("SELECT file_id, day, source, agent_hash FROM contribution_agents") + try query.forEachRow { row in + guard let path = paths[row.int64(0)], let day = row.string(1), row.string(2) == SourceKind.hermes.rawValue else { return } + let key = UsageKey(day: day, source: .hermes) + entries[path]?.days[key]?.agentHashes.insert(UInt64(bitPattern: row.int64(3))) + } + } + + func deleteAgents(fileId: Int64) throws { + let query = try connection.prepare("DELETE FROM contribution_agents WHERE file_id = ?1") + query.bind(1, fileId) + try query.run() + } + + func saveAgents(_ hashes: Set, fileId: Int64, day: String, source: SourceKind) throws { + guard source == .hermes, !hashes.isEmpty else { return } + let query = try connection.prepare("INSERT INTO contribution_agents(file_id, day, source, agent_hash) VALUES (?1, ?2, ?3, ?4)") + for hash in hashes.sorted() { + query.bind(1, fileId) + query.bind(2, day) + query.bind(3, source.rawValue) + query.bind(4, Int64(bitPattern: hash)) + try query.run() + } + } +} diff --git a/macos/Sources/AgentSpeedCore/History/HistoryDatabase.swift b/macos/Sources/AgentSpeedCore/History/HistoryDatabase.swift index a2787c10..058b9203 100644 --- a/macos/Sources/AgentSpeedCore/History/HistoryDatabase.swift +++ b/macos/Sources/AgentSpeedCore/History/HistoryDatabase.swift @@ -55,7 +55,10 @@ final class HistoryDatabase { try connection.execute(Self.metaTableSQL) } try connection.execute(Self.trafficTableSQL) - if migrating || version == 0 { try connection.execute(Self.modeTableSQL) } + if migrating || version == 0 { + try connection.execute(Self.modeTableSQL) + try connection.execute(Self.agentTableSQL) + } } static func inMemory() throws -> HistoryDatabase { @@ -240,6 +243,7 @@ final class HistoryDatabase { cost: Self.cost(row, 8)) } try loadModes(into: &entries, paths: paths) + try loadAgents(into: &entries, paths: paths) return entries } @@ -314,7 +318,9 @@ final class HistoryDatabase { clearModels.bind(1, id) try clearModels.run() try deleteModes(fileId: id) + try deleteAgents(fileId: id) for (key, day) in entry.days { + try saveAgents(day.agentHashes, fileId: id, day: key.day, source: key.source) try saveModes(day.modes.values.sorted { $0.id < $1.id }, fileId: id, day: key.day, source: key.source) insertDay.bind(1, id) insertDay.bind(2, key.day) diff --git a/macos/Sources/AgentSpeedCore/LiveMonitor.swift b/macos/Sources/AgentSpeedCore/LiveMonitor.swift index b9525700..a115143c 100644 --- a/macos/Sources/AgentSpeedCore/LiveMonitor.swift +++ b/macos/Sources/AgentSpeedCore/LiveMonitor.swift @@ -63,6 +63,7 @@ public final class LiveMonitor { TranscriptLocations.defaultClaudeRoot } + public nonisolated static var defaultHermesRoot: URL { TranscriptLocations.defaultHermesRoot } public nonisolated static var defaultZcodeRoot: URL { TranscriptLocations.defaultZcodeRoot } public nonisolated static var defaultKimiRoot: URL { TranscriptLocations.defaultKimiRoot } public nonisolated static var defaultGrokBuildRoot: URL { TranscriptLocations.defaultGrokBuildRoot } diff --git a/macos/Sources/AgentSpeedCore/Models.swift b/macos/Sources/AgentSpeedCore/Models.swift index 55631e7f..9c62beff 100644 --- a/macos/Sources/AgentSpeedCore/Models.swift +++ b/macos/Sources/AgentSpeedCore/Models.swift @@ -18,6 +18,8 @@ public enum SourceKind: String, CaseIterable, Codable, Sendable, Identifiable { case zcode case kimi case grokBuild + /// Nous Research Hermes desktop and CLI. + case hermes public var id: String { rawValue } } @@ -371,6 +373,8 @@ public struct MonitorConfiguration: Equatable, Sendable { public var zcodeRoot: URL? public var kimiRoot: URL? public var grokBuildRoot: URL? + /// Hermes home or logs folder; nil uses `$HERMES_HOME` or `~/.hermes`. + public var hermesRoot: URL? /// Tests: replaces the automatic roots of a family that are otherwise derived from the environment and the home /// folder (omp `sessions` roots, Antigravity store folders, WorkBuddy data folders). var automaticRoots: [TranscriptFamily: [URL]] = [:] @@ -388,7 +392,8 @@ public struct MonitorConfiguration: Equatable, Sendable { workbuddyAiRoot: URL? = nil, zcodeRoot: URL? = nil, kimiRoot: URL? = nil, - grokBuildRoot: URL? = nil + grokBuildRoot: URL? = nil, + hermesRoot: URL? = nil ) { self.enabledSources = enabledSources self.codexRoot = codexRoot @@ -403,5 +408,6 @@ public struct MonitorConfiguration: Equatable, Sendable { self.zcodeRoot = zcodeRoot self.kimiRoot = kimiRoot self.grokBuildRoot = grokBuildRoot + self.hermesRoot = hermesRoot } } diff --git a/macos/Sources/AgentSpeedCore/Monitor/SnapshotBuilder.swift b/macos/Sources/AgentSpeedCore/Monitor/SnapshotBuilder.swift index 66d0bd28..068cc9c4 100644 --- a/macos/Sources/AgentSpeedCore/Monitor/SnapshotBuilder.swift +++ b/macos/Sources/AgentSpeedCore/Monitor/SnapshotBuilder.swift @@ -31,6 +31,9 @@ struct SnapshotContext: Sendable { } else if kind == .opencode { let location = session.opencodeLocation sources[kind] = SourceInfo(enabled: enabled, rootPath: location.path, available: location.available, sessionCount: counts[kind] ?? 0) + } else if kind == .hermes { + let location = session.hermesLocation + sources[kind] = SourceInfo(enabled: enabled, rootPath: location.path, available: location.available, sessionCount: counts[kind] ?? 0) } else if kind == .zcode { let location = session.zcodeLocation sources[kind] = SourceInfo(enabled: enabled, rootPath: location.path, available: location.available, sessionCount: counts[kind] ?? 0) diff --git a/macos/Sources/AgentSpeedCore/Monitor/TranscriptLocations.swift b/macos/Sources/AgentSpeedCore/Monitor/TranscriptLocations.swift index a6c3eb64..2f0a83c2 100644 --- a/macos/Sources/AgentSpeedCore/Monitor/TranscriptLocations.swift +++ b/macos/Sources/AgentSpeedCore/Monitor/TranscriptLocations.swift @@ -125,6 +125,11 @@ enum TranscriptLocations { (TranscriptDiscovery.normalizedRoot(root) as NSString).deletingLastPathComponent } + static var defaultHermesRoot: URL { + environment("HERMES_HOME").map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? home.appendingPathComponent(".hermes", isDirectory: true) + } + static var defaultZcodeRoot: URL { let base = environment("ZCODE_DATA_BASE_DIR").map { URL(fileURLWithPath: $0, isDirectory: true) } ?? home return base.appendingPathComponent(".zcode/cli/db", isDirectory: true) @@ -247,6 +252,8 @@ extension MonitorConfiguration { var readsClaude: Bool { reads(.claude) } var readsDsh: Bool { reads(.dsh) } var readsOpencode: Bool { enabledSources.contains(.opencode) } + var readsHermes: Bool { enabledSources.contains(.hermes) } + var resolvedHermesRoot: String { TranscriptDiscovery.normalizedRoot((hermesRoot ?? TranscriptLocations.defaultHermesRoot).path) } var readsZcode: Bool { enabledSources.contains(.zcode) } var resolvedZcodeRoot: String { TranscriptDiscovery.normalizedRoot((zcodeRoot ?? TranscriptLocations.defaultZcodeRoot).path) } @@ -256,5 +263,6 @@ extension MonitorConfiguration { || TranscriptFamily.allCases.contains { resolvedRoots(for: $0) != other.resolvedRoots(for: $0) } || resolvedOpencodeRoot != other.resolvedOpencodeRoot || resolvedZcodeRoot != other.resolvedZcodeRoot + || resolvedHermesRoot != other.resolvedHermesRoot } } diff --git a/macos/Sources/AgentSpeedCore/Monitor/TranscriptSession.swift b/macos/Sources/AgentSpeedCore/Monitor/TranscriptSession.swift index 1406cef0..d9698db0 100644 --- a/macos/Sources/AgentSpeedCore/Monitor/TranscriptSession.swift +++ b/macos/Sources/AgentSpeedCore/Monitor/TranscriptSession.swift @@ -74,6 +74,7 @@ final class TranscriptSession { /// Deduplication owner of every OpenCode row; transcript files count up from 0. static let opencodeFileID: FileID = -1 static let zcodeFileID: FileID = -2 + static let hermesFileID: FileID = -3 let enabledSources: Set let limits: ReadingLimits @@ -93,6 +94,8 @@ final class TranscriptSession { private(set) var partialHistory = false /// The OpenCode database, when that source is enabled (METRICS 4.5, 5.2). let opencode: OpencodeSource? + let hermes: HermesSource? + private let hermesRoot: String let zcode: ZcodeSource? private let zcodeRoot: String private var zcodeNeedsSeed = false @@ -126,6 +129,8 @@ final class TranscriptSession { } opencodeRoot = configuration.resolvedOpencodeRoot opencode = configuration.readsOpencode ? OpencodeSource(root: opencodeRoot, fileID: Self.opencodeFileID) : nil + hermesRoot = configuration.resolvedHermesRoot + hermes = configuration.readsHermes ? HermesSource(root: hermesRoot, fileID: Self.hermesFileID) : nil zcodeRoot = configuration.resolvedZcodeRoot zcode = configuration.readsZcode ? ZcodeSource(root: zcodeRoot, fileID: Self.zcodeFileID) : nil refreshAvailability() @@ -133,7 +138,7 @@ final class TranscriptSession { // MARK: - Roots - var hasAvailableRoot: Bool { roots.contains { $0.enabled && $0.detected } || opencode?.isReady == true || zcode?.isReady == true } + var hasAvailableRoot: Bool { roots.contains { $0.enabled && $0.detected } || opencode?.isReady == true || zcode?.isReady == true || hermes?.isReady == true } var watchedPaths: [String] { var paths = roots.filter { $0.enabled && $0.available }.map(\.canonicalPath) @@ -141,9 +146,11 @@ final class TranscriptSession { paths.append(directory) } if let directory = zcode?.watchedDirectory, !paths.contains(directory) { paths.append(directory) } + for path in hermes?.watchedPaths ?? [] where !paths.contains(path) { paths.append(path) } return paths } + var hermesLocation: (path: String, available: Bool) { (hermesRoot, hermes?.exists ?? false) } var zcodeLocation: (path: String, available: Bool) { (zcodeRoot, zcode?.exists ?? false) } /// Root path and availability of the OpenCode source (the folder that contains `opencode.db`). @@ -154,7 +161,7 @@ final class TranscriptSession { /// Re-checks which roots exist. Returns true when availability changed. @discardableResult func refreshAvailability() -> Bool { - var changed = false + var changed = hermes?.refreshAvailability() ?? false if let opencode, opencode.refreshAvailability() { changed = true opencodeNeedsSeed = true @@ -266,6 +273,14 @@ final class TranscriptSession { zcodeNeedsSeed = false apply(zcode.seed(todayStartMs: todayStartMs, deduper: &deduper), live: false) } + if let hermes { + summary.filesDiscovered += HermesSource.files(root: hermesRoot).count + let before = hermes.bytesRead + apply(hermes.seed(todayStartMs: todayStartMs, deduper: &deduper), live: false) + summary.filesRead += HermesSource.files(root: hermesRoot).count + summary.bytesRead += hermes.bytesRead - before + partialHistory = partialHistory || hermes.partialHistory + } summary.partialHistory = partialHistory summary.durationMs = Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000 return summary @@ -334,8 +349,11 @@ final class TranscriptSession { @discardableResult func handleChanges(atCanonicalPaths paths: Set) -> Bool { var changed = false + if let hermes, paths.contains(where: { HermesSource.isLogName(($0 as NSString).lastPathComponent) }) { + changed = apply(hermes.refresh(deduper: &deduper), live: true) + } if let opencode, paths.contains(where: opencode.matches(canonicalPath:)) { - changed = apply(opencode.refresh(deduper: &deduper), live: true) + changed = apply(opencode.refresh(deduper: &deduper), live: true) || changed } if let zcode, paths.contains(where: zcode.matches(canonicalPath:)) { changed = apply(zcode.refresh(deduper: &deduper), live: true) || changed @@ -380,8 +398,9 @@ final class TranscriptSession { @discardableResult func pollRecentFiles(nowMs: Int64) -> Bool { var changed = false + if let hermes { changed = apply(hermes.refresh(deduper: &deduper), live: true) } if let update = opencode?.refreshIfChanged(deduper: &deduper) { - changed = apply(update, live: true) + changed = apply(update, live: true) || changed } if let update = zcode?.refreshIfChanged(deduper: &deduper) { changed = apply(update, live: true) || changed } let hot = files.values.filter { nowMs - $0.lastStat.modifiedMs <= Self.hotWindowMs } @@ -403,6 +422,7 @@ final class TranscriptSession { @discardableResult func rediscover(todayStartMs: Int64) -> Bool { var changed = refreshAvailability() + if let hermes { changed = apply(hermes.refresh(deduper: &deduper), live: true) || changed } var seen: Set = [] var fresh: [(DiscoveredFile, Root)] = [] let activeSince = todayStartMs - Self.startupLookbackMs @@ -593,7 +613,7 @@ final class TranscriptSession { files.values.reduce(removedMalformedLines) { $0 + $1.parser.state.malformedLines } + (opencode?.malformedRows ?? 0) } - var unreadableFileCount: Int { unreadablePaths.count + (opencode?.status == .unreadable ? 1 : 0) + (zcode?.status == .unreadable ? 1 : 0) } + var unreadableFileCount: Int { unreadablePaths.count + (opencode?.status == .unreadable ? 1 : 0) + (zcode?.status == .unreadable ? 1 : 0) + (hermes?.unreadableFiles ?? 0) } /// Antigravity databases whose connection is open. var openDatabaseCount: Int { @@ -624,6 +644,7 @@ final class TranscriptSession { counts[.opencode] = opencode.activeSessionCount(todayStartMs: todayStartMs) } if let zcode { counts[.zcode] = zcode.activeSessionCount(todayStartMs: todayStartMs) } + if let hermes { counts[.hermes] = hermes.activeSessionCount(todayStartMs: todayStartMs) } return counts } @@ -698,6 +719,14 @@ final class TranscriptSession { return !update.isEmpty } + @discardableResult + private func apply(_ update: HermesSource.Update, live: Bool) -> Bool { + for (upsert, project) in update.upserts { deliver(upsert, project: project, live: live) } + for (id, state) in update.agents { engine.setAgent(EngineAgent(id: id, state: state)) } + partialHistory = partialHistory || (hermes?.partialHistory ?? false) + return !update.isEmpty + } + // MARK: - Tracking private func track(_ file: DiscoveredFile, family: TranscriptFamily, root: String, start: FileTailer.Start) -> TrackedFile { diff --git a/macos/Sources/AgentSpeedCore/Parsing/TranscriptParser.swift b/macos/Sources/AgentSpeedCore/Parsing/TranscriptParser.swift index ac84fbd3..dd5b57d8 100644 --- a/macos/Sources/AgentSpeedCore/Parsing/TranscriptParser.swift +++ b/macos/Sources/AgentSpeedCore/Parsing/TranscriptParser.swift @@ -32,7 +32,7 @@ enum TranscriptFamily: String, CaseIterable, Sendable { case .workbuddyAi: self = .workbuddyAi case .kimi: self = .kimi case .grokBuild: self = .grokBuild - case .opencode, .zcode: return nil + case .opencode, .zcode, .hermes: return nil } } diff --git a/macos/Sources/AgentSpeedCore/Traffic/Engine/AgentProcessRules.swift b/macos/Sources/AgentSpeedCore/Traffic/Engine/AgentProcessRules.swift index c42985cf..bd187fd5 100644 --- a/macos/Sources/AgentSpeedCore/Traffic/Engine/AgentProcessRules.swift +++ b/macos/Sources/AgentSpeedCore/Traffic/Engine/AgentProcessRules.swift @@ -38,11 +38,11 @@ struct AgentProcessRules: Sendable { private static let workbuddyExecutables: [String: SourceKind] = ["WorkBuddy": .workbuddy, "WorkBuddyAI": .workbuddyAi] private static let workbuddyCLI = "/app.asar.unpacked/cli/" - /// The rules read a process's arguments only for script hosts, `claude` executables (the Chrome bridge check) and - /// WorkBuddy's executables; everything else is decided by the path. + /// Arguments are needed for script hosts, Python (Hermes entrypoints), `claude` (the Chrome bridge check), and + /// WorkBuddy executables; everything else is decided by the path. func needsArguments(executable: String, bundleIdentifier: () -> String?) -> Bool { let name = platform.name(executable) - return Self.scriptHosts.contains(name.lowercased()) || name == "claude" + return Self.scriptHosts.contains(name.lowercased()) || Self.isPython(name) || name == "claude" || platform.isUnder(executable, ["~/.local/share/claude/versions"]) || workbuddyEdition(executable: executable, bundleIdentifier: bundleIdentifier) != nil } @@ -116,6 +116,8 @@ struct AgentProcessRules: Sendable { if name == "zcode" || name == "zcode-cli" || script.contains("/@zcode/cli/") { return .zcode } if name == "kimi" || name == "kimi-code" || script.contains("/@moonshot-ai/kimi-code/") { return .kimi } if name == "grok" || name == "grok-build" { return .grokBuild } + if name == "hermes" { return .hermes } + if Self.isPython(name), isHermesPython(arguments() ?? []) { return .hermes } // Only the embedded CodeBuddy Code CLI is the agent; the app, its helpers and its daemon are not. if let edition = workbuddyEdition(executable: executable, bundleIdentifier: bundleIdentifier), (arguments() ?? []).dropFirst().contains(where: { platform.normalize($0).contains(Self.workbuddyCLI) }) { @@ -124,6 +126,22 @@ struct AgentProcessRules: Sendable { return nil } + private static func isPython(_ name: String) -> Bool { + let name = name.lowercased() + if name == "python" || name == "pythonw" || name == "python3" { return true } + guard name.hasPrefix("python3.") else { return false } + let minor = name.dropFirst("python3.".count) + return !minor.isEmpty && minor.utf8.allSatisfy { (48...57).contains($0) } + } + + private func isHermesPython(_ arguments: [String]) -> Bool { + let args = Array(arguments.dropFirst().drop { ["-u", "-B", "-E", "-I", "-s", "-S"].contains($0) }) + if args.count >= 2, args[0] == "-m", ["hermes_cli", "hermes_cli.main", "hermes_cli.__main__"].contains(args[1]) { return true } + guard let entry = args.first, !entry.hasPrefix("-") else { return false } + let path = platform.normalize(entry) + return ["/bin/hermes", "/scripts/hermes", "/hermes-agent/cli.py", "/hermes-agent/run_agent.py", "/hermes-agent/hermes_cli/main.py"].contains { path.hasSuffix($0) } + } + private static func containsAny(_ text: String, _ needles: [String]) -> Bool { needles.contains { text.contains($0) } } diff --git a/macos/Sources/AgentSpeedProbe/Arguments.swift b/macos/Sources/AgentSpeedProbe/Arguments.swift index 6d83be91..fd116659 100644 --- a/macos/Sources/AgentSpeedProbe/Arguments.swift +++ b/macos/Sources/AgentSpeedProbe/Arguments.swift @@ -32,7 +32,7 @@ struct ProbeArguments { | --traffic SECONDS [--traffic-interval 0|1|5] [--no-live] [--history-store PATH]) [--prices PATH] [--codex PATH] [--claude PATH] [--dsh PATH] [--opencode PATH] [--gemini PATH] [--omp PATH] [--antigravity PATH] [--workbuddy PATH] [--workbuddy-ai PATH] - [--zcode PATH] [--kimi PATH] [--grok-build PATH] + [--zcode PATH] [--kimi PATH] [--grok-build PATH] [--hermes PATH] AgentSpeedProbe --sync-prices DIR | --sync-rates DIR CURRENCY """ @@ -105,6 +105,9 @@ struct ProbeArguments { // WorkBuddy AI's data folder or its `projects` folder. guard let path = iterator.next() else { return nil } configuration.workbuddyAiRoot = URL(fileURLWithPath: path, isDirectory: true) + case "--hermes": + guard let path = iterator.next() else { return nil } + configuration.hermesRoot = URL(fileURLWithPath: path) case "--zcode": guard let path = iterator.next() else { return nil } configuration.zcodeRoot = URL(fileURLWithPath: path) diff --git a/macos/Tests/AgentSpeedCoreTests/HermesTests.swift b/macos/Tests/AgentSpeedCoreTests/HermesTests.swift new file mode 100644 index 00000000..eae1a158 --- /dev/null +++ b/macos/Tests/AgentSpeedCoreTests/HermesTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing +@testable import AgentSpeedCore + +@Suite("Hermes") +struct HermesTests { + private static let dayStart: Int64 = 1_792_454_400_000 + private static let end: Int64 = 1_792_497_604_000 + private static let utc = TimeZone(secondsFromGMT: 0)! + + @Test func sharedFixturesAndLocalTimestamps() throws { + let expected = try Fixtures.json("hermes/expected.json") + let parser = HermesLine(timeZone: Self.utc) + var seen: Set = [] + for file in try #require(expected["files"] as? [[String: Any]]) { + let path = Fixtures.root.appendingPathComponent(try #require(file.string("path"))) + let directory = path.deletingLastPathComponent().path + var records: [ResponseRecord] = [] + forEachLine(in: try Data(contentsOf: path)) { bytes in + guard let record = parser.record(bytes, logDirectory: directory), seen.insert(record.key).inserted else { return } + records.append(record) + } + let wanted = try #require(file["responses"] as? [[String: Any]]).map { row in + var row = row + row["key"] = row.string("key")?.replacingOccurrences(of: "@LOG_ROOT@", with: directory) + return row + } + try HarnessFixtureTests.compare(records, wanted) + } + for row in try #require(expected["lineCases"] as? [[String: Any]]) { + var line = try #require(row.string("line")) + if let length = row.int64("padToBytes") { line += String(repeating: " ", count: max(0, Int(length) - line.utf8.count)) } + let record = Data(line.utf8).withUnsafeBytes { parser.record($0, logDirectory: "fixture-root") } + if let wanted = row["response"] as? [String: Any] { + try HarnessFixtureTests.compare([try #require(record)], [wanted]) + } else { + #expect(record == nil) + } + } + for row in try #require(expected["timeZones"] as? [[String: Any]]) { + let zoneName = try #require(row.string("zone")) + let zone = try #require(TimeZone(identifier: zoneName)) + let parser = HermesLine(timeZone: zone) + let line = try #require(row.string("line")) + let record = Data(line.utf8).withUnsafeBytes { parser.record($0, logDirectory: "/logs") } + #expect(record?.endMs == row.int64("endMs")) + } + } + + @Test func countsAreBoundedAndUnknownModelsRemainUnknown() throws { + let parser = HermesLine(timeZone: Self.utc) + let huge = String(repeating: "9", count: 40) + let line = "2026-10-20 12:00:04,000 INFO[s] agent.turn_usage: API call #1: model=unknown provider=unknown in=\(huge) out=\(huge) total=\(huge) latency=0.0s cache=\(huge)/10 (100%) write=\(huge)" + let record = try #require(Data(line.utf8).withUnsafeBytes { parser.record($0, logDirectory: "/logs") }) + #expect(record.model == nil && record.provider == nil && record.startMs == nil) + #expect(record.usage.input == Int(TokenCount.ceiling) && record.usage.output == Int(TokenCount.ceiling)) + #expect(record.usage.cachedInput == record.usage.input && record.usage.cacheWriteInput == record.usage.input) + } + + @Test func sourceDiscoversProfilesAndSeparatesSessions() throws { + let root = Fixtures.root.appendingPathComponent("hermes") + let source = HermesSource(root: root.path, fileID: -3, timeZone: Self.utc) + #expect(source.refreshAvailability()) + var deduper = Deduper() + let update = source.seed(todayStartMs: Self.dayStart, deduper: &deduper) + let expected = try Fixtures.json("hermes/expected.json") + let totals = try #require(expected["totals"] as? [String: Any]) + #expect(update.upserts.count == totals.int("requests")) + #expect(update.agents.count >= 6) + #expect(source.activeSessionCount(todayStartMs: Self.dayStart) == update.agents.count) + #expect(source.refresh(deduper: &deduper).isEmpty) + #expect(HermesSource.files(root: root.path).count == 3) + #expect(HermesSource.files(root: root.appendingPathComponent("logs").path).count == 2) + #expect(SourceSet(.hermes).rawValue == 1 << 13) + } + + @Test func discoveryDoesNotFollowChildSymlinks() throws { + let directory = try TempDirectory(), outside = try TempDirectory() + try outside.write("logs/agent.log", Self.line(session: "outside", call: 1, end: Self.end, output: 200)) + try FileManager.default.createSymbolicLink(at: directory.file("logs"), withDestinationURL: outside.file("logs")) + try FileManager.default.createDirectory(at: directory.file("profiles/linked-logs"), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: directory.file("profiles/linked-logs/logs"), withDestinationURL: outside.file("logs")) + try FileManager.default.createSymbolicLink(at: directory.file("profiles/linked-profile"), withDestinationURL: outside.url) + #expect(HermesSource.files(root: directory.path).isEmpty) + try FileManager.default.removeItem(at: directory.file("profiles")) + try FileManager.default.createSymbolicLink(at: directory.file("profiles"), withDestinationURL: outside.url) + #expect(HermesSource.files(root: directory.path).isEmpty) + } + + @Test func completeLinesAndRotationProduceExactlyOneCompletion() throws { + let directory = try TempDirectory() + let configuration = MonitorConfiguration(enabledSources: [.hermes], hermesRoot: directory.url) + let session = TranscriptSession(configuration: configuration, clock: { Self.end + 4_000 }) + #expect(!session.hasAvailableRoot) + let first = Self.line(session: "a", call: 1, end: Self.end, output: 200) + try directory.write("logs/agent.log", first) + #expect(session.rediscover(todayStartMs: Self.dayStart) && session.hasAvailableRoot) + #expect(session.responses.count == 1) + _ = session.advance(nowMs: Self.end, todayStartMs: Self.dayStart, emitsEvents: false) + let second = Self.line(session: "b", call: 1, end: Self.end + 4_000, output: 80) + try directory.append("logs/agent.log", String(second.dropLast())) + #expect(!session.pollRecentFiles(nowMs: Self.end + 4_000)) + try directory.append("logs/agent.log", "\n") + #expect(session.pollRecentFiles(nowMs: Self.end + 4_000)) + let output = session.advance(nowMs: Self.end + 4_000, todayStartMs: Self.dayStart, emitsEvents: true) + #expect(output.output.today.requests == 2 && output.output.today.output == 280) + #expect(output.events.filter { if case .responseCompleted = $0 { true } else { false } }.count == 1) + try FileManager.default.moveItem(at: directory.file("logs/agent.log"), to: directory.file("logs/agent.log.1")) + try directory.write("logs/agent.log", second + Self.line(session: "a", call: 1, end: Self.end + 8_000, output: 40)) + #expect(session.pollRecentFiles(nowMs: Self.end + 8_000)) + #expect(session.responses.count == 3) + let context = SnapshotContext(session: session, todayStartMs: Self.dayStart) + #expect(context.sources[.hermes]?.available == true && context.sources[.hermes]?.sessionCount == 2) + var moved = configuration + moved.hermesRoot = directory.file("other") + #expect(moved.requiresRescan(comparedTo: configuration)) + } + + @Test func historyAndLiveAgreeAndExpiredRotationsKeepHistory() throws { + let directory = try TempDirectory() + try directory.write("logs/agent.log", Self.line(session: "a", call: 1, end: Self.end, output: 200) + + Self.line(session: "b", call: 1, end: Self.end, output: 80)) + let configuration = MonitorConfiguration(enabledSources: [.hermes], hermesRoot: directory.url) + let session = TranscriptSession(configuration: configuration) + _ = session.seed(todayStartMs: Self.dayStart) + let live = session.advance(nowMs: Self.end + 500, todayStartMs: Self.dayStart, emitsEvents: false).output + let store = directory.file("history.sqlite").path + let first = try HistoryScan.run(configuration: configuration, timeZone: Self.utc, storePath: store) + let day = try #require(first.days.first) + #expect(day.totals == live.today && day.measuredMs == 8_000 && day.agents == 2) + #expect(day.totals.cachedInput == 1000 && day.totals.cacheWriteInput == 200) + let unchanged = try HistoryScan.run(configuration: configuration, timeZone: Self.utc, storePath: store) + #expect(unchanged.filesAnalyzed == 0 && unchanged.days == first.days) + try FileManager.default.moveItem(at: directory.file("logs/agent.log"), to: directory.file("logs/agent.log.1")) + try directory.write("logs/agent.log", Self.line(session: "a", call: 1, end: Self.end + 8_000, output: 40)) + let rotated = try HistoryScan.run(configuration: configuration, timeZone: Self.utc, storePath: store) + #expect(rotated.days.first?.totals.requests == 3 && rotated.days.first?.totals.output == 320 && rotated.days.first?.agents == 2) + try FileManager.default.removeItem(at: directory.file("logs/agent.log.1")) + let expired = try HistoryScan.run(configuration: configuration, timeZone: Self.utc, storePath: store) + #expect(expired.days == rotated.days) + var reports = ReportLedger() + for response in session.responses { reports.count(response, modelId: "known") } + #expect(reports.counters.total.output == 280 && reports.counters.byModel[ReportModelKey(modelId: "known", source: .hermes)]?.output == 280) + } + + @Test func historyDefersRotationBetweenPlanningAndReading() throws { + let directory = try TempDirectory() + try directory.write("logs/agent.log", Self.line(session: "a", call: 1, end: Self.end, output: 200)) + let configuration = MonitorConfiguration(enabledSources: [.hermes], hermesRoot: directory.url) + let database = try HistoryDatabase.inMemory() + let analyzer = try HistoryAnalyzer(database: database) + let options = HistoryAnalyzer.Options(timeZone: Self.utc) + try analyzer.run(configuration: configuration, options: options) + try directory.append("logs/agent.log", Self.line(session: "a", call: 2, end: Self.end + 4_000, output: 80)) + var rotated = false + var rotationError: (any Error)? + let raced = try analyzer.run(configuration: configuration, options: options, progress: { _ in + guard !rotated else { return } + rotated = true + do { + try FileManager.default.moveItem(at: directory.file("logs/agent.log"), to: directory.file("logs/agent.log.1")) + try directory.write("logs/agent.log", Self.line(session: "b", call: 1, end: Self.end + 8_000, output: 40)) + } catch { rotationError = error } + }) + if let rotationError { throw rotationError } + #expect(raced.filesUnreadable == 1 && analyzer.days(sources: [.hermes]).first?.totals.output == 200) + try analyzer.run(configuration: configuration, options: options) + let day = try #require(analyzer.days(sources: [.hermes]).first) + #expect(day.totals.requests == 3 && day.totals.output == 320 && day.agents == 2) + } + + @Test func trafficMatchesHermesEntrypointsWithoutMatchingDevelopmentScripts() { + let rules = AgentProcessRules(platform: TrafficPlatform(kind: .macos, home: "/Users/dev")) + func source(_ executable: String, _ args: [String] = []) -> SourceKind? { + rules.source(executable: executable, arguments: { args }, ancestorExecutables: []) + } + #expect(source("/usr/local/bin/hermes") == .hermes) + #expect(rules.needsArguments(executable: "/usr/bin/python3.13", bundleIdentifier: { nil })) + #expect(source("/usr/bin/python3", ["python3", "-u", "-m", "hermes_cli.main", "gateway", "ensure"]) == .hermes) + #expect(source("/usr/bin/python3", ["python3", "/Users/dev/.hermes/hermes-agent/cli.py"]) == .hermes) + #expect(source("/usr/bin/python3", ["python3", "/venv/bin/hermes"]) == .hermes) + #expect(source("/usr/bin/python3", ["python3", "/projects/hermes-agent/test.py"]) == nil) + #expect(source("/usr/bin/python3", ["python3", "other.py", "-m", "hermes_cli.main"]) == nil) + #expect(source("/Applications/Hermes.app/Contents/MacOS/Hermes") == nil) + } + + private static func line(session: String, call: Int, end: Int64, output: Int) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss,SSS" + let date = formatter.string(from: Date(timeIntervalSince1970: Double(end) / 1000)) + return "\(date) INFO [\(session)] agent.conversation_loop: API call #\(call): model=claude-sonnet-4.6 provider=anthropic in=1200 out=\(output) total=\(1200 + output) latency=4.0s cache=500/1200 (42%) write=100\n" + } +} diff --git a/macos/Tests/AgentSpeedCoreTests/LiveMonitorTests.swift b/macos/Tests/AgentSpeedCoreTests/LiveMonitorTests.swift index 0e35742a..d4c3358a 100644 --- a/macos/Tests/AgentSpeedCoreTests/LiveMonitorTests.swift +++ b/macos/Tests/AgentSpeedCoreTests/LiveMonitorTests.swift @@ -25,7 +25,13 @@ struct LiveMonitorTests { opencodeRoot: directory.file("missing-opencode"), geminiRoot: directory.file("missing-gemini"), ompRoot: directory.file("missing-omp"), - antigravityRoot: directory.file("missing-antigravity") + antigravityRoot: directory.file("missing-antigravity"), + workbuddyRoot: directory.file("missing-workbuddy"), + workbuddyAiRoot: directory.file("missing-workbuddy-ai"), + zcodeRoot: directory.file("missing-zcode"), + kimiRoot: directory.file("missing-kimi"), + grokBuildRoot: directory.file("missing-grok"), + hermesRoot: directory.file("missing-hermes") )) monitor.start() defer { monitor.stop() } diff --git a/macos/Tests/AgentSpeedCoreTests/SessionTests.swift b/macos/Tests/AgentSpeedCoreTests/SessionTests.swift index ed586d34..eb0702c2 100644 --- a/macos/Tests/AgentSpeedCoreTests/SessionTests.swift +++ b/macos/Tests/AgentSpeedCoreTests/SessionTests.swift @@ -20,7 +20,13 @@ struct SessionTests { opencodeRoot: directory.file("opencode"), geminiRoot: directory.file("gemini"), ompRoot: directory.file("missing-omp"), - antigravityRoot: directory.file("missing-antigravity") + antigravityRoot: directory.file("missing-antigravity"), + workbuddyRoot: directory.file("missing-workbuddy"), + workbuddyAiRoot: directory.file("missing-workbuddy-ai"), + zcodeRoot: directory.file("missing-zcode"), + kimiRoot: directory.file("missing-kimi"), + grokBuildRoot: directory.file("missing-grok"), + hermesRoot: directory.file("missing-hermes") ) return (directory, configuration) } diff --git a/spec/EXECUTION-MODES.md b/spec/EXECUTION-MODES.md index b2965cd1..a26e65e5 100644 --- a/spec/EXECUTION-MODES.md +++ b/spec/EXECUTION-MODES.md @@ -55,7 +55,7 @@ Mode changes affect their native scope and time, not earlier responses. Upserts routing facts. A synthetic compatibility `service_tier:"standard"` is not served evidence. - Antigravity: Planning/Fast is a workflow dimension only when its actual native field is known. Turbo permissions, animation speed and effort are excluded. Unknown protobuf tier semantics remain unclassified. -- Gemini CLI, DSH, Grok Build: expose the common unknown state until an explicit supported native field is present; +- Gemini CLI, DSH, Grok Build, Hermes: expose the common unknown state until an explicit supported native field is present; a provider API's capability alone is not evidence that its harness selected it. ## Local aggregates and history diff --git a/spec/METRICS.md b/spec/METRICS.md index 78b70f85..b9c315cf 100644 --- a/spec/METRICS.md +++ b/spec/METRICS.md @@ -8,8 +8,8 @@ shared fixtures in `spec/fixtures/`. | Term | Meaning | | --- | --- | -| Source | `codex`, `claudeCode`, `claudeDesktop`, `deepseek` (display name **DSH**, DeepSeek Harness), `opencode`, `gemini` (Gemini CLI), `omp` (oh-my-pi), `antigravity` (Google Antigravity), `workbuddy` (Tencent WorkBuddy, China edition), `workbuddyAi` (WorkBuddy AI, international edition), `zcode` (ZCode), `kimi` (Kimi Code CLI), `grokBuild` (Grok Build). This is the append-only *source order*. | -| Agent | One transcript file. Agent id = absolute file path. Sub-agents have their own files and are separate agents. (OpenCode: one per session row, 4.5; Antigravity: one per conversation database, 4.8.) | +| Source | `codex`, `claudeCode`, `claudeDesktop`, `deepseek` (display name **DSH**, DeepSeek Harness), `opencode`, `gemini` (Gemini CLI), `omp` (oh-my-pi), `antigravity` (Google Antigravity), `workbuddy` (Tencent WorkBuddy, China edition), `workbuddyAi` (WorkBuddy AI, international edition), `zcode` (ZCode), `kimi` (Kimi Code CLI), `grokBuild` (Grok Build), `hermes` (Hermes). This is the append-only *source order*. | +| Agent | One transcript file. Agent id = absolute file path. Sub-agents have their own files and are separate agents. (OpenCode: one per session row, 4.5; Antigravity: one per conversation database, 4.8; Hermes: one per logged session, 4.13.) | | Response | One model API call that produced a usage record. | | Phase | `generating` (a request is in flight), `tools` (model finished, waiting for tool results), `idle` | @@ -32,6 +32,7 @@ responses (4.12); the speed engine still processes its normalized tokens and dur | ZCode | `~/.zcode/cli/db/db.sqlite`; `$ZCODE_DATA_BASE_DIR` replaces the home base, with `.zcode/cli/db` underneath | user setting (data directory, database folder, or `db.sqlite`) | | Kimi Code CLI (TypeScript v2) | `$KIMI_CODE_HOME/sessions`, else `~/.kimi-code/sessions` | user setting (data directory or its `sessions` folder) | | Grok Build | `$GROK_HOME/sessions`, else `~/.grok/sessions` | user setting (data directory or its `sessions` folder) | +| Hermes (desktop and CLI) | `$HERMES_HOME`, else `~/.hermes` on macOS or `%LOCALAPPDATA%/hermes` on Windows; `logs/agent.log` and numbered rotations, also `profiles//logs` | user setting (Hermes home or `logs` folder) | `~` is `HOME` on macOS and `%USERPROFILE%` on Windows. Claude Code and Claude Desktop share one root; each file is assigned to a source by its `entrypoint` field (section 4.1). Ordinary Claude Desktop chat conversations are not stored @@ -1131,6 +1132,49 @@ existing price book on the native model batch, so tier selection and rounding ca Evidence and executable checks: `spec/research/harnesses.md`, `spec/tools/harness_reference.py`, and `spec/fixtures/harness-expected.json`. +### 4.13 Hermes + +Hermes Desktop and Hermes Agent CLI write the same local Python logs. Read only `logs/agent.log` and +`agent.log.` rotations, also under immediate named `profiles//logs` directories. +A selected `logs` directory is read directly. Discovery never follows symlinks; unrelated debug, gateway, GUI, +error and tool logs do not contribute usage. See `spec/research/hermes.md` for upstream evidence. +On Windows, an existing legacy `~/.hermes` home is used when the current default directory does not exist. + +The canonical record is an INFO line from `agent.turn_usage`, `agent.conversation_loop`, or legacy `run_agent`, with the format: + +```text +2026-10-20 12:00:04,000 INFO [session-id] agent.turn_usage: API call #1: model=MODEL provider=PROVIDER in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 +``` + +The session tag is optional. Parse the timestamp as local wall time: Hermes does not persist a UTC offset. +A log copied from another timezone, or an ambiguous daylight-saving transition, cannot recover its original offset. +The logger's millisecond timestamp is `endMs`. A positive `latency` of at most 900 seconds gives +`startMs = endMs - latency * 1000`; zero, missing or invalid duration must never manufacture measured speed. +Latency is rounded by Hermes to 0.1 seconds. All common interval validation and speed formulas still apply. + +`input = in` already includes cache reads and writes; `output = out` already includes reasoning. The optional +`cache=read/prompt` numerator supplies `cachedInput`; optional `write=N` supplies `cacheWriteInput`. Do not add +cache counts a second time. `reasoning` and `cacheWrite1hInput` are zero because this log does not split them out. +Use the logged model and provider for the existing price lookup; `unknown` provider is null. Ignore `total`, +request ids and upstream-routing text. A line with `in=? out=? ... usage=unavailable` is not a known usage record. +No message content, prompt text, tool output or database session totals are parsed as tokens. + +`key = "hermes:" + sessionId + ":" + endMs + ":" + callIndex`; a missing session tag substitutes the normalized +log directory for the session id. This deduplicates rotation overlap and copied session logs while allowing the +call counter to restart at a different time. Agent identity combines the log directory with the session id, +so interleaved sessions remain separate. Untagged records share one anonymous agent per log directory. +The log has no reliable parent links or project metadata, so neither is inferred. + +Only completed calls are measured. INFO logs do not reliably include request starts, tool boundaries or streaming +usage, so Hermes does not claim an in-flight generating phase or a pre-completion speed estimate. Logs from older +Hermes versions without numeric API-call usage cannot reconstruct past tokens. Specialized providers that fold +several internal calls into one reported call expose only that logged aggregate, not their internal concurrency. +History keys physical log segments by file identity rather than the reused rotation filename, retaining per-day +totals after old segments disappear. Hermes contributions persist only hashes of agent identities to count a +session once per day across segments while separating multiple sessions in one file. Existing sources keep their +per-file counting rules. Shared synthetic cases and an independent reader are in `spec/fixtures/hermes/` and +`spec/tools/hermes_reference.py`. + ## 5. Reading files - Startup reading order (it decides which file owns a duplicated key): files sorted by modification time ascending, @@ -1714,7 +1758,7 @@ date is needed. The app keeps a **process table** of the current user's processes (on Windows: of the app's session), refreshed on the ticks 10.10 gives: pid, parent pid, start time, -executable path and, only for script hosts (`node`, `bun`, `deno`, with or without `.exe`) and WorkBuddy's +executable path and, only for script hosts (`node`, `bun`, `deno`, Python/Python 3, with or without `.exe`) and WorkBuddy's executables (below), the argument list; on macOS, for WorkBuddy, also the bundle id of the app whose `Contents/MacOS` holds the executable (`CFBundleIdentifier` of its `Contents/Info.plist`, read once per app path). macOS: `proc_listallpids`, `proc_pidinfo(PROC_PIDTBSDINFO)`, `proc_pidpath`, `sysctl(KERN_PROCARGS2)`. Windows: one @@ -1743,6 +1787,10 @@ desktop app that runs its agent on its own executable, so its row needs the argu | `omp` | `name == omp`, or a script host whose arguments contain `/@oh-my-pi/` or `/oh-my-pi/`. | | `antigravity` | `name == agy`, or `name` starts with `language_server_` (the Antigravity language server, started by the CLI, the desktop app and the IDE with `--app_data_dir`). | | `workbuddy`, `workbuddyAi` | The embedded CodeBuddy Code CLI, which makes the model calls itself: an executable of the edition — macOS: in the app with bundle id `com.tencent.workbuddy.mac` (WorkBuddy) or `com.workbuddy.workbuddy-ai` (WorkBuddy AI), whose main executable is named `Electron`; Windows: `name` `WorkBuddy` or `WorkBuddyAI` — whose arguments after the first contain a path with `/app.asar.unpacked/cli/` (the CLI entry `…/app.asar.unpacked/cli/bin/codebuddy`, run with `ELECTRON_RUN_AS_NODE`). Observed in the 5.8 code: pooled `--prewarm` processes that later serve one conversation each (`--serve --session-id `; the command line does not change when one is taken from the pool), cold `--serve` starts, and the fallback `--acp` with first argument `wb-acp:`. Not verified with a running app; the Windows install folder is unknown, so only the name is used. | +| `hermes` | `name == hermes`; Python running `-m hermes_cli`, `hermes_cli.main` or `hermes_cli.__main__`, a `bin/hermes` / `Scripts/hermes` entry, or `hermes-agent/cli.py`, `run_agent.py` or `hermes_cli/main.py`. Python flags such as `-u` may precede the entry. Ordinary scripts merely mentioning Hermes in their arguments are excluded. The desktop gateway launches `python -m hermes_cli.main gateway run`; its tool descendants follow the existing tree rules. The Electron desktop shell itself is not an agent. | + +Hermes's isolated `python -I -c` system-service bootstraps are not attributed; their local API logs still provide +token usage and speed. Arbitrary Python command strings are not classified by substring matching. The desktop apps' own processes are **not** agent processes, nor are their Electron or Tauri helpers: macOS `/Applications/{Claude,ChatGPT,Codex,OpenCode,Antigravity}.app/Contents/MacOS/`; Windows executables in the diff --git a/spec/fixtures/hermes/expected.json b/spec/fixtures/hermes/expected.json new file mode 100644 index 00000000..ea6cc590 --- /dev/null +++ b/spec/fixtures/hermes/expected.json @@ -0,0 +1,348 @@ +{ + "version": 1, + "timeZone": "UTC", + "files": [ + { + "path": "hermes/logs/agent.log.1", + "responses": [ + { + "key": "hermes:sess-a:1792497604000:1", + "sessionId": "sess-a", + "startMs": 1792497600000, + "endMs": 1792497604000, + "input": 1200, + "cachedInput": 500, + "cacheWriteInput": 100, + "cacheWrite1hInput": 0, + "output": 200, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + }, + { + "key": "hermes:sess-b:1792497604500:1", + "sessionId": "sess-b", + "startMs": 1792497602500, + "endMs": 1792497604500, + "input": 800, + "cachedInput": 100, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 80, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + ] + }, + { + "path": "hermes/logs/agent.log", + "responses": [ + { + "key": "hermes:sess-a:1792497605000:2", + "sessionId": "sess-a", + "startMs": null, + "endMs": 1792497605000, + "input": 20, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 5, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + }, + { + "key": "hermes:sess-a:1792497609000:1", + "sessionId": "sess-a", + "startMs": 1792497606000, + "endMs": 1792497609000, + "input": 600, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 60, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + }, + { + "key": "hermes:sess-c:1792497611000:3", + "sessionId": "sess-c", + "startMs": 1792497609500, + "endMs": 1792497611000, + "input": 1000, + "cachedInput": 700, + "cacheWriteInput": 30, + "cacheWrite1hInput": 0, + "output": 90, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + }, + { + "key": "hermes:@LOG_ROOT@:1792497612500:1", + "sessionId": null, + "startMs": 1792497611500, + "endMs": 1792497612500, + "input": 400, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 40, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + }, + { + "key": "hermes:sess-long:1792497613000:1", + "sessionId": "sess-long", + "startMs": null, + "endMs": 1792497613000, + "input": 500, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 30, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + ] + }, + { + "path": "hermes/profiles/coder/logs/agent.log", + "responses": [ + { + "key": "hermes:sess-profile:1792497614500:1", + "sessionId": "sess-profile", + "startMs": 1792497614000, + "endMs": 1792497614500, + "input": 300, + "cachedInput": 0, + "cacheWriteInput": 100, + "cacheWrite1hInput": 0, + "output": 20, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + ] + } + ], + "discover": [ + "hermes/logs/agent.log.1", + "hermes/logs/agent.log", + "hermes/profiles/coder/logs/agent.log" + ], + "timeZones": [ + { + "zone": "UTC", + "line": "2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host", + "endMs": 1792497604000 + }, + { + "zone": "America/Los_Angeles", + "line": "2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host", + "endMs": 1792522804000 + }, + { + "zone": "America/Los_Angeles", + "line": "2026-03-08 02:30:00,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host", + "endMs": null + } + ], + "lineCases": [ + { + "name": "positive counters beyond Int64 saturate", + "line": "2026-10-20 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=18446744073709551616 out=18446744073709551616 total=36893488147419103232 latency=1.0s", + "response": { + "key": "hermes:edge:1792497604000:1", + "sessionId": "edge", + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 1000000000000, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 1000000000000, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "cache counters beyond Int64 clamp to input", + "line": "2026-10-20 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s cache=18446744073709551616/100 (100%) write=18446744073709551616", + "response": { + "key": "hermes:edge:1792497604000:1", + "sessionId": "edge", + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 100, + "cachedInput": 100, + "cacheWriteInput": 100, + "cacheWrite1hInput": 0, + "output": 10, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "unknown is a real session tag", + "line": "2026-10-20 12:00:04,000 INFO [unknown] agent.conversation_loop: API call #1: model=unknown provider=unknown in=100 out=10 total=110 latency=1.0s", + "response": { + "key": "hermes:unknown:1792497604000:1", + "sessionId": "unknown", + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 100, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 10, + "reasoning": 0, + "model": null, + "provider": null, + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "missing session uses log directory", + "line": "2026-10-20 12:00:04,000 INFO agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s", + "response": { + "key": "hermes:fixture-root:1792497604000:1", + "sessionId": null, + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 100, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 10, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "extra header spaces", + "line": "2026-10-20 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s", + "response": { + "key": "hermes:edge:1792497604000:1", + "sessionId": "edge", + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 100, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 10, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "unrelated suffix content is not retained", + "line": "2026-10-20 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s prompt=PRIVATE_SYNTHETIC_PROMPT tool_output=PRIVATE_SYNTHETIC_OUTPUT", + "response": { + "key": "hermes:edge:1792497604000:1", + "sessionId": "edge", + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 100, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 10, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "64 KiB metadata line accepted", + "line": "2026-10-20 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s", + "padToBytes": 65536, + "response": { + "key": "hermes:edge:1792497604000:1", + "sessionId": "edge", + "startMs": 1792497603000, + "endMs": 1792497604000, + "input": 100, + "cachedInput": 0, + "cacheWriteInput": 0, + "cacheWrite1hInput": 0, + "output": 10, + "reasoning": 0, + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "final": true, + "fast": false, + "requestCount": 1 + } + }, + { + "name": "over 64 KiB ignored", + "line": "2026-10-20 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s", + "padToBytes": 65537, + "response": null + }, + { + "name": "invalid calendar day ignored", + "line": "2026-02-30 12:00:04,000 INFO [edge] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=100 out=10 total=110 latency=1.0s", + "response": null + } + ], + "totals": { + "requests": 8, + "input": 4820, + "output": 525, + "cachedInput": 1300, + "cacheWriteInput": 230, + "measuredDurationMs": 12000 + } +} diff --git a/spec/fixtures/hermes/logs/agent.log b/spec/fixtures/hermes/logs/agent.log new file mode 100644 index 00000000..4c07da97 --- /dev/null +++ b/spec/fixtures/hermes/logs/agent.log @@ -0,0 +1,13 @@ +2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host +2026-10-20 12:00:05,000 INFO [sess-a] agent.conversation_loop: API call #2: model=anthropic/claude-sonnet-4.6 provider=anthropic in=20 out=5 total=25 latency=0.0s +2026-10-20 12:00:09,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=600 out=60 total=660 latency=3.0s +2026-10-20 12:00:11,000 INFO [sess-c] run_agent: API call #3: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1000 out=90 total=1090 latency=1.5s cache=700/1000 (70%) write=30 +2026-10-20 12:00:12,500 INFO agent.turn_usage: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=400 out=40 total=440 latency=1.0s +2026-10-20 12:00:13,000 INFO [sess-long] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=500 out=30 total=530 latency=901.0s +2026-10-20 12:00:14,000 INFO [sess-a] agent.conversation_loop: API call #4: model=test provider=test in=? out=? total=? latency=0.1s usage=unavailable +2026-10-20 12:00:15,000 INFO [wrong-logger] unrelated.logger: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=999 out=999 total=1998 latency=1.0s +2026-02-30 12:00:15,000 INFO [invalid-date] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=999 out=999 total=1998 latency=1.0s +2026-10-20 12:00:15,000 INFO [negative] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=-1 out=999 total=998 latency=1.0s +2026-10-20 12:00:15,000 INFO [bad-latency] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=999 out=999 total=1998 latency=NaNs +API call #1: model=example provider=example in=999 out=999 total=1998 latency=1.0s +2026-10-20 12:00:16,000 INFO [sess-a] agent.conversation_loop: Turn ended: reason=text_response model=example api_calls=2/20 diff --git a/spec/fixtures/hermes/logs/agent.log.1 b/spec/fixtures/hermes/logs/agent.log.1 new file mode 100644 index 00000000..9a37dc59 --- /dev/null +++ b/spec/fixtures/hermes/logs/agent.log.1 @@ -0,0 +1,2 @@ +2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host +2026-10-20 12:00:04,500 INFO [sess-b] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=800 out=80 total=880 latency=2.0s cache=100/800 (12%) diff --git a/spec/fixtures/hermes/logs/agent.log.backup b/spec/fixtures/hermes/logs/agent.log.backup new file mode 100644 index 00000000..0d1c9074 --- /dev/null +++ b/spec/fixtures/hermes/logs/agent.log.backup @@ -0,0 +1 @@ +2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host diff --git a/spec/fixtures/hermes/logs/gateway.log b/spec/fixtures/hermes/logs/gateway.log new file mode 100644 index 00000000..0d1c9074 --- /dev/null +++ b/spec/fixtures/hermes/logs/gateway.log @@ -0,0 +1 @@ +2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host diff --git a/spec/fixtures/hermes/logs/nested/agent.log b/spec/fixtures/hermes/logs/nested/agent.log new file mode 100644 index 00000000..0d1c9074 --- /dev/null +++ b/spec/fixtures/hermes/logs/nested/agent.log @@ -0,0 +1 @@ +2026-10-20 12:00:04,000 INFO [sess-a] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host diff --git a/spec/fixtures/hermes/profiles/coder/logs/agent.log b/spec/fixtures/hermes/profiles/coder/logs/agent.log new file mode 100644 index 00000000..e8d42448 --- /dev/null +++ b/spec/fixtures/hermes/profiles/coder/logs/agent.log @@ -0,0 +1 @@ +2026-10-20 12:00:14,500 INFO [sess-profile] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=300 out=20 total=320 latency=0.5s write=100 diff --git a/spec/fixtures/traffic-cases.json b/spec/fixtures/traffic-cases.json index b3515a71..51777319 100644 --- a/spec/fixtures/traffic-cases.json +++ b/spec/fixtures/traffic-cases.json @@ -2834,6 +2834,190 @@ } ] } + }, + { + "platform": "macos", + "home": "/Users/dev", + "name": "classify-hermes-macos", + "samples": [ + { + "t": 1792490400000, + "processes": [ + { + "pid": 501, + "ppid": 1, + "start": 1000, + "exe": "/Users/dev/.local/bin/hermes", + "args": [] + }, + { + "pid": 502, + "ppid": 1, + "start": 1001, + "exe": "/usr/bin/python3.12", + "args": [ + "/usr/bin/python3.12", + "-u", + "-m", + "hermes_cli.main", + "gateway", + "run" + ] + }, + { + "pid": 503, + "ppid": 1, + "start": 1002, + "exe": "/usr/bin/python3.12", + "args": [ + "/usr/bin/python3.12", + "/Users/dev/.hermes/hermes-agent/venv/bin/hermes", + "chat" + ] + }, + { + "pid": 504, + "ppid": 1, + "start": 1003, + "exe": "/usr/bin/python3.12", + "args": [ + "/usr/bin/python3.12", + "-c", + "print(\"hermes_cli\")" + ] + }, + { + "pid": 505, + "ppid": 1, + "start": 1004, + "exe": "/usr/bin/python3.12", + "args": [ + "/usr/bin/python3.12", + "/projects/tool.py", + "-m", + "hermes_cli.main" + ] + }, + { + "pid": 506, + "ppid": 1, + "start": 1005, + "exe": "/Applications/Hermes.app/Contents/MacOS/Hermes", + "args": [] + }, + { + "pid": 507, + "ppid": 502, + "start": 1006, + "exe": "/bin/sh", + "args": [] + } + ] + } + ], + "expected": { + "classes": { + "501": "hermes/agent", + "502": "hermes/agent", + "503": "hermes/agent", + "504": null, + "505": null, + "506": null, + "507": "hermes/tool" + } + } + }, + { + "platform": "windows", + "home": "C:/Users/dev", + "name": "classify-hermes-windows", + "samples": [ + { + "t": 1792490400000, + "processes": [ + { + "pid": 501, + "ppid": 1, + "start": 1000, + "exe": "C:/Users/dev/.local/bin/hermes", + "args": [] + }, + { + "pid": 502, + "ppid": 1, + "start": 1001, + "exe": "C:/Python/python.exe", + "args": [ + "C:/Python/python.exe", + "-u", + "-m", + "hermes_cli.main", + "gateway", + "run" + ] + }, + { + "pid": 503, + "ppid": 1, + "start": 1002, + "exe": "C:/Python/python.exe", + "args": [ + "C:/Python/python.exe", + "C:/Users/dev/.hermes/hermes-agent/venv/bin/hermes", + "chat" + ] + }, + { + "pid": 504, + "ppid": 1, + "start": 1003, + "exe": "C:/Python/python.exe", + "args": [ + "C:/Python/python.exe", + "-c", + "print(\"hermes_cli\")" + ] + }, + { + "pid": 505, + "ppid": 1, + "start": 1004, + "exe": "C:/Python/python.exe", + "args": [ + "C:/Python/python.exe", + "/projects/tool.py", + "-m", + "hermes_cli.main" + ] + }, + { + "pid": 506, + "ppid": 1, + "start": 1005, + "exe": "C:/Apps/Hermes/Hermes.exe", + "args": [] + }, + { + "pid": 507, + "ppid": 502, + "start": 1006, + "exe": "C:/Windows/System32/cmd.exe", + "args": [] + } + ] + } + ], + "expected": { + "classes": { + "501": "hermes/agent", + "502": "hermes/agent", + "503": "hermes/agent", + "504": null, + "505": null, + "506": null, + "507": "hermes/tool" + } + } } ] -} \ No newline at end of file +} diff --git a/spec/report-schema.json b/spec/report-schema.json index ad04b339..a7b882fe 100644 --- a/spec/report-schema.json +++ b/spec/report-schema.json @@ -59,7 +59,7 @@ "maximum": 1000000000000 }, "source": { - "description": "An Agent Speed source id. Known ids: codex, claudeCode, claudeDesktop, deepseek, opencode, gemini, omp, antigravity, workbuddy, workbuddyAi, zcode, kimi, grokBuild. New ids are added over time; receivers accept ids they do not know.", + "description": "An Agent Speed source id. Known ids: codex, claudeCode, claudeDesktop, deepseek, opencode, gemini, omp, antigravity, workbuddy, workbuddyAi, zcode, kimi, grokBuild, hermes. New ids are added over time; receivers accept ids they do not know.", "type": "string", "pattern": "^[a-z][A-Za-z0-9]{0,31}$" }, diff --git a/spec/research/hermes.md b/spec/research/hermes.md new file mode 100644 index 00000000..bb6fde73 --- /dev/null +++ b/spec/research/hermes.md @@ -0,0 +1,88 @@ +# Hermes Agent and Hermes Desktop data source + +Verified against the official NousResearch/hermes-agent source, including the local +Desktop build's source revision `5938f04aacee521c07e6e896f68b9e33f33a3697`. +No user conversations or credentials were read to build the fixtures. + +## Native log contract + +Hermes Desktop uses the Python agent's profile store and log routing. Each completed +model call with provider usage writes one INFO record to `logs/agent.log`: + +```text +2026-10-20 12:00:04,000 INFO [session-id] agent.conversation_loop: API call #1: model=anthropic/claude-sonnet-4.6 provider=anthropic in=1200 out=200 total=1400 latency=4.0s cache=500/1200 (42%) write=100 id=response-id +``` + +Authoritative implementation: + +- [Per-response accounting and log emission](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/agent/turn_usage.py) +- [Log formatting, session tags, profile routing and rotation](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/hermes_logging.py) +- [Canonical token buckets](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/agent/usage_pricing.py) +- [Upstream synthetic log tests](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/tests/agent/test_turn_usage_log_line.py) +- [Upstream log consumer](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/evals/postmortem/forensics/logcalls.py) + +`in` is the complete prompt count: uncached input plus cache reads plus cache writes. +`cache` and `write` are subsets, not additional input. `out` is the full output +counter. Separate reasoning counts and cache-write lifetimes are absent. `total` +does not provide a second independent usage sample. `write`, `id` and `upstream` +were added as optional suffixes; readers must not require them. The older +`run_agent` logger and the extracted `agent.turn_usage` logger are also accepted. + +The log timestamp is Python's local wall-clock `asctime`, with milliseconds but no +UTC offset. `latency` is measured API wall time rounded to 0.1 seconds. Derive the +start from the completion timestamp minus this duration; a rounded `0.0s` or an +interval beyond the engine's maximum keeps usage but has unknown timing. Never +derive model speed from the gap between messages or calls. No reliable per-call +start event exists at the default INFO level, so this integration reports completed +calls and does not claim live streaming estimates. + +## Location and discovery + +[Hermes home resolution](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/hermes_constants.py) +uses `HERMES_HOME` when supplied, then `~/.hermes` on macOS/Linux and +`%LOCALAPPDATA%/hermes` on current Windows builds. Older Windows installations may +use `~/.hermes`. Named profiles use `/profiles/`, each with its own +`logs` directory. The ordinary rotation names are `agent.log.1`, `.2`, `.3` (defaults: +5 MB each and three backups). Only `agent.log` and numeric rotations are consumed; +`gateway.log` and `gui.log` can repeat records and are excluded. + +Desktop asks `hermes gateway ensure` to start or attach to the local backend. The +[backend launcher](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/hermes_cli/gateway_runtime_start.py) +starts the long-lived process as `python -m hermes_cli.main [--profile default] +gateway run --quiet`, carrying the profile's `HERMES_HOME`. Traffic recognition can +therefore match this exact Python module or the `hermes` command without treating +arbitrary Python scripts, or the Electron UI process, as model backends. + +Deduplicate with session identity, completion timestamp and call number. Call +numbers alone can restart when an agent process resumes; file names change under +rotation. Missing session tags fall back to the containing log directory. Group +agent activity by log directory and session, keeping interleaved conversations +separate. + +Parsers limit individual metadata lines to 64 KiB, cap counters at the shared +token ceiling, and ignore malformed dates, missing usage, unrelated logger names +and incomplete trailing lines. Synthetic shared fixtures cover the current and +older logger spellings, rotated duplicates, concurrent sessions, timezone parsing, +counter bounds and missing timing. + +## Limits + +- Network attribution recognizes the direct CLI/gateway entry points. Isolated + `python -I -c` service bootstraps are not attributed; their persisted API logs + still contribute token usage and speed normally. +- Default INFO logging must remain enabled. Deleted rotations cannot be recovered; + only retained logs can be backfilled into usage history. +- Calls whose provider omits usage log `in=? out=? total=?` and cannot be counted as + measured token usage. Errors and unrelated logs are ignored. +- A log completion timestamp follows response accounting and can lag the exact + provider completion slightly. Duration resolution is 0.1 seconds. +- Historical timezone changes and the repeated hour at a daylight-saving fallback + cannot be reconstructed exactly from offset-free timestamps. Use the machine's + local timezone; fixtures inject UTC explicitly. +- MoA can fold advisor usage into one outer call. The log has no per-advisor call + timing or request count; it cannot reconstruct that fan-out. +- Project paths, parent/child relationships and per-call execution modes are not + present in the selected metadata. No conversation content is needed or retained. +- [The canonical session database](https://github.com/NousResearch/hermes-agent/blob/5938f04aacee521c07e6e896f68b9e33f33a3697/website/docs/developer-guide/session-storage.md) + contains cumulative usage and message timestamps but no per-call durations. It + is deliberately not used to invent response timing or added to log totals. diff --git a/spec/strings.json b/spec/strings.json index ee439587..eee9d0d7 100644 --- a/spec/strings.json +++ b/spec/strings.json @@ -4596,6 +4596,18 @@ "en": "Kimi Code CLI", "ja": "Kimi Code CLI" }, + "source.hermes": { + "zh-Hant": "Hermes", + "zh-Hans": "Hermes", + "en": "Hermes", + "ja": "Hermes" + }, + "settings.sources.hermesHint": { + "zh-Hant": "讀取 Hermes 的本機 API 呼叫日誌(含具名設定檔);每次呼叫完成後更新速度。可選擇 Hermes 資料或 logs 資料夾。", + "zh-Hans": "读取 Hermes 的本地 API 调用日志(含命名配置);每次调用完成后更新速度。可选择 Hermes 数据或 logs 文件夹。", + "en": "Reads Hermes’s local API call logs, including named profiles. Speed updates after each call completes. Choose the Hermes home or logs folder.", + "ja": "名前付きプロファイルを含む Hermes のローカル API ログを読み取り、呼び出し完了後に速度を更新します。Hermes のデータまたは logs フォルダを選択できます。" + }, "source.grokBuild": { "zh-Hant": "Grok Build", "zh-Hans": "Grok Build", diff --git a/spec/tools/hermes_fixtures.py b/spec/tools/hermes_fixtures.py new file mode 100644 index 00000000..422c197d --- /dev/null +++ b/spec/tools/hermes_fixtures.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Synthetic Hermes logs and hand-specified expectations; never reads a real installation.""" +import json +import sys +from pathlib import Path + +BASE = 1792497600000 # 2026-10-20T12:00:00Z; the test parser must explicitly use UTC. + + +def line(clock, session, call, inp, out, latency, suffix="", logger="agent.conversation_loop"): + tag = " [%s]" % session if session else "" + return ("2026-10-20 12:00:%s INFO%s %s: API call #%d: model=anthropic/claude-sonnet-4.6 " + "provider=anthropic in=%s out=%s total=%d latency=%ss%s" % + (clock, tag, logger, call, inp, out, inp + out, latency, suffix)) + + +def response(session, call, end, duration, inp, out, read=0, write=0): + return dict(key="hermes:%s:%d:%d" % (session or "@LOG_ROOT@", BASE + end, call), sessionId=session, + startMs=BASE + end - duration if duration is not None else None, endMs=BASE + end, + input=inp, cachedInput=read, cacheWriteInput=write, cacheWrite1hInput=0, + output=out, reasoning=0, model="anthropic/claude-sonnet-4.6", provider="anthropic", + final=True, fast=False, requestCount=1) + + +def line_cases(): + overflow = 18446744073709551616 + cap = 10 ** 12 + unknown = response("unknown", 1, 4000, 1000, 100, 10) + unknown["model"] = unknown["provider"] = None + missing = response(None, 1, 4000, 1000, 100, 10) + missing["key"] = missing["key"].replace("@LOG_ROOT@", "fixture-root") + simple = line("04,000", "edge", 1, 100, 10, "1.0") + return [ + dict(name="positive counters beyond Int64 saturate", line=line("04,000", "edge", 1, overflow, overflow, "1.0"), + response=response("edge", 1, 4000, 1000, cap, cap)), + dict(name="cache counters beyond Int64 clamp to input", line=line("04,000", "edge", 1, 100, 10, "1.0", + " cache=%d/100 (100%%) write=%d" % (overflow, overflow)), response=response("edge", 1, 4000, 1000, 100, 10, 100, 100)), + dict(name="unknown is a real session tag", line=line("04,000", "unknown", 1, 100, 10, "1.0") + .replace("model=anthropic/claude-sonnet-4.6 provider=anthropic", "model=unknown provider=unknown"), response=unknown), + dict(name="missing session uses log directory", line=line("04,000", None, 1, 100, 10, "1.0"), response=missing), + dict(name="extra header spaces", line=simple.replace("INFO [edge] ", "INFO [edge] "), + response=response("edge", 1, 4000, 1000, 100, 10)), + dict(name="unrelated suffix content is not retained", line=simple + " prompt=PRIVATE_SYNTHETIC_PROMPT tool_output=PRIVATE_SYNTHETIC_OUTPUT", + response=response("edge", 1, 4000, 1000, 100, 10)), + dict(name="64 KiB metadata line accepted", line=simple, padToBytes=65536, + response=response("edge", 1, 4000, 1000, 100, 10)), + dict(name="over 64 KiB ignored", line=simple, padToBytes=65537, response=None), + dict(name="invalid calendar day ignored", line=simple.replace("2026-10-20", "2026-02-30"), response=None), + ] + + +def generate(root): + root = Path(root) + first = line("04,000", "sess-a", 1, 1200, 200, "4.0", " cache=500/1200 (42%) write=100 id=fixture-response upstream=Example Host") + files = [ + ("hermes/logs/agent.log.1", [first, line("04,500", "sess-b", 1, 800, 80, "2.0", " cache=100/800 (12%)")], + [response("sess-a", 1, 4000, 4000, 1200, 200, 500, 100), response("sess-b", 1, 4500, 2000, 800, 80, 100)]), + ("hermes/logs/agent.log", [ + first, # A rotated/copied record is still one request. + line("05,000", "sess-a", 2, 20, 5, "0.0"), + line("09,000", "sess-a", 1, 600, 60, "3.0"), # A resumed process can reset its counter. + line("11,000", "sess-c", 3, 1000, 90, "1.5", " cache=700/1000 (70%) write=30", "run_agent"), + line("12,500", None, 1, 400, 40, "1.0", logger="agent.turn_usage"), + line("13,000", "sess-long", 1, 500, 30, "901.0"), + "2026-10-20 12:00:14,000 INFO [sess-a] agent.conversation_loop: API call #4: model=test provider=test in=? out=? total=? latency=0.1s usage=unavailable", + line("15,000", "wrong-logger", 1, 999, 999, "1.0", logger="unrelated.logger"), + line("15,000", "invalid-date", 1, 999, 999, "1.0").replace("2026-10-20", "2026-02-30"), + line("15,000", "negative", 1, -1, 999, "1.0"), + line("15,000", "bad-latency", 1, 999, 999, "NaN"), + "API call #1: model=example provider=example in=999 out=999 total=1998 latency=1.0s", + "2026-10-20 12:00:16,000 INFO [sess-a] agent.conversation_loop: Turn ended: reason=text_response model=example api_calls=2/20", + ], [response("sess-a", 2, 5000, None, 20, 5), response("sess-a", 1, 9000, 3000, 600, 60), + response("sess-c", 3, 11000, 1500, 1000, 90, 700, 30), response(None, 1, 12500, 1000, 400, 40), + response("sess-long", 1, 13000, None, 500, 30)]), + ("hermes/profiles/coder/logs/agent.log", [line("14,500", "sess-profile", 1, 300, 20, "0.5", " write=100")], + [response("sess-profile", 1, 14500, 500, 300, 20, write=100)]), + ] + for path, lines, _ in files: + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + for ignored in ("hermes/logs/gateway.log", "hermes/logs/agent.log.backup", "hermes/logs/nested/agent.log"): + target = root / ignored + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(first + "\n", encoding="utf-8") + expected = dict(version=1, timeZone="UTC", files=[dict(path=p, responses=r) for p, _, r in files], + discover=[p for p, _, _ in files], + timeZones=[dict(zone="UTC", line=first, endMs=BASE + 4000), + dict(zone="America/Los_Angeles", line=first, endMs=BASE + 7 * 3600 * 1000 + 4000), + dict(zone="America/Los_Angeles", line=first.replace("2026-10-20 12:00:04", "2026-03-08 02:30:00"), endMs=None)], + lineCases=line_cases(), + totals=dict(requests=8, input=4820, output=525, cachedInput=1300, + cacheWriteInput=230, measuredDurationMs=12000)) + (root / "hermes/expected.json").write_text(json.dumps(expected, indent=2) + "\n", encoding="utf-8") + return expected + + +if __name__ == "__main__": + generate(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent / "fixtures") diff --git a/spec/tools/hermes_reference.py b/spec/tools/hermes_reference.py new file mode 100644 index 00000000..65503c35 --- /dev/null +++ b/spec/tools/hermes_reference.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Independent normalization of Hermes's native per-call agent.log records. + +Only the fixed metadata prefix and token counters are retained. Hermes timestamps +have no UTC offset: real logs use local time; shared fixtures explicitly use UTC. +""" +from __future__ import annotations + +import json +import math +import re +import sys +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from pathlib import Path +from zoneinfo import ZoneInfo + +CAP = 10 ** 12 +MAX_LINE = 64 * 1024 +MAX_DURATION_MS = 900_000 +NAME = re.compile(r"agent\.log(?:\.[0-9]+)?\Z") +LINE = re.compile( + r"^(?P