From 8186577c7b230d55f23ddb9b7973b860bfa13727 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 15 May 2026 14:53:45 -0700 Subject: [PATCH] feat: show cron output usage metadata --- CHANGELOG.md | 2 ++ api/routes.py | 65 +++++++++++++++++++++++++++++++++++++++++- static/panels.js | 31 +++++++++++++++++++- static/style.css | 2 ++ tests/test_sprint10.py | 34 ++++++++++++++++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8107e1ac..c6d68092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **PR #2332** by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model, token, cost, and duration frontmatter from cron output files and returns it from `/api/crons/history` and `/api/crons/run`; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata. + - **PR #2319** by @Michaelyklam — Chat file uploads now land in a session-scoped attachment inbox instead of cluttering the active workspace root. By default uploads are stored under `~/.hermes/webui/attachments//`; operators can override the root with `HERMES_WEBUI_ATTACHMENT_DIR`, and the agent still receives the absolute uploaded file path for context. Archive extraction stays workspace-scoped (it's an explicit workspace operation). README updated to document the new default location. **Stage-361 maintainer fix applied inline**: Opus advisor caught that `_build_native_multimodal_message` at `api/streaming.py:787` required uploads to be under `workspace_root`, which would have silently dropped every image upload for vision-capable models once the inbox moved outside the workspace. The fix adds `_attachment_root()` (from `api/upload.py`) as a second allowed location, with 3 regression tests covering the new code path AND verifying the original workspace + cross-root rejection paths still work. ### Fixed diff --git a/api/routes.py b/api/routes.py index d64ba1f7..3047b37e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6816,10 +6816,14 @@ def _handle_cron_history(handler, parsed): for f in page: try: st = f.stat() + usage = _cron_output_usage_metadata( + f.read_text(encoding="utf-8", errors="replace") + ) runs.append({ "filename": f.name, "size": st.st_size, "modified": st.st_mtime, + "usage": usage, }) except OSError: logger.debug("Failed to stat cron output file %s", f) @@ -6851,12 +6855,71 @@ def _handle_cron_run_detail(handler, parsed): try: content = fpath.read_text(encoding="utf-8", errors="replace") snippet = _cron_output_snippet(content) + usage = _cron_output_usage_metadata(content) return j(handler, {"job_id": job_id, "filename": filename, - "content": content, "snippet": snippet}) + "content": content, "snippet": snippet, + "usage": usage}) except Exception as e: return j(handler, {"error": str(e)}, status=500) +def _cron_output_usage_metadata(text: str) -> dict: + """Extract optional token/cost metadata from a cron output markdown file.""" + import re as _re + + head = text.split("## Response", 1)[0].split("# Response", 1)[0] + usage: dict = {} + + def _intish(value: str): + cleaned = _re.sub(r"[^0-9]", "", value or "") + return int(cleaned) if cleaned else None + + def _floatish(value: str): + match = _re.search(r"[-+]?\d+(?:\.\d+)?", (value or "").replace(",", "")) + return float(match.group(0)) if match else None + + for raw_line in head.splitlines(): + line = raw_line.strip() + model_match = _re.match(r"\*\*(?:Model|Model Used):\*\*\s*(.+)$", line, _re.I) + if model_match: + usage["model"] = model_match.group(1).strip() + continue + provider_match = _re.match(r"\*\*Provider:\*\*\s*(.+)$", line, _re.I) + if provider_match: + usage["provider"] = provider_match.group(1).strip() + continue + cost_match = _re.match(r"\*\*(?:Estimated cost|Cost):\*\*\s*(.+)$", line, _re.I) + if cost_match: + cost = _floatish(cost_match.group(1)) + if cost is not None: + usage["estimated_cost_usd"] = cost + continue + duration_match = _re.match(r"\*\*(?:Duration|Elapsed):\*\*\s*(.+)$", line, _re.I) + if duration_match: + seconds = _floatish(duration_match.group(1)) + if seconds is not None: + usage["duration_seconds"] = seconds + continue + tokens_match = _re.match(r"\*\*Tokens:\*\*\s*(.+)$", line, _re.I) + if tokens_match: + value = tokens_match.group(1) + input_match = _re.search(r"([0-9][0-9,]*)\s*(?:input|in)\b", value, _re.I) + output_match = _re.search(r"([0-9][0-9,]*)\s*(?:output|out)\b", value, _re.I) + total_match = _re.search(r"([0-9][0-9,]*)\s*(?:total\s*)?tokens?\b", value, _re.I) + if input_match: + usage["input_tokens"] = _intish(input_match.group(1)) + if output_match: + usage["output_tokens"] = _intish(output_match.group(1)) + if total_match and "total_tokens" not in usage: + usage["total_tokens"] = _intish(total_match.group(1)) + + if "total_tokens" not in usage: + total = sum(int(usage.get(k) or 0) for k in ("input_tokens", "output_tokens")) + if total: + usage["total_tokens"] = total + return usage + + def _cron_output_snippet(text: str, limit: int = 600) -> str: """Extract the response body from a cron output .md file for preview. diff --git a/static/panels.js b/static/panels.js index 441c7ede..0f9ccedb 100644 --- a/static/panels.js +++ b/static/panels.js @@ -623,11 +623,12 @@ async function _loadCronDetailRuns(jobId){ const sizeStr = run.size > 1024 ? (run.size/1024).toFixed(1)+' KB' : run.size+' B'; const dateStr = new Date(run.modified * 1000).toLocaleString(); const rid = `cron-det-run-${jobId}-${i}`; + const usageStrip = _formatCronRunUsageStrip(run.usage); const runExpanded = _cronExpansionGet(_cronRunExpandKey(jobId, run.filename)); const runToggleLabel = runExpanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output'); return `
- ${esc(ts)} ${esc(sizeStr)} + ${esc(ts)} ${esc(sizeStr)}${usageStrip ? ` ${esc(usageStrip)}` : ''} @@ -662,6 +663,13 @@ async function _loadRunContent(jobId, filename, runId){ } else { body.textContent = data.snippet || data.content; } + const usageStrip = _formatCronRunUsageStrip(data.usage); + if (usageStrip) { + const usage = document.createElement('div'); + usage.className = 'cron-run-usage-strip cron-run-usage-footer'; + usage.textContent = usageStrip; + body.appendChild(usage); + } // Show "View full output" button if content was truncated if (data.content && data.snippet && data.content.length > data.snippet.length) { const btn = document.createElement('button'); @@ -1011,6 +1019,27 @@ function _cronOutputSnippet(content) { return body.slice(0, 600) || '(empty)'; } +function _formatCronRunUsageStrip(usage) { + if (!usage || typeof usage !== 'object') return ''; + const parts = []; + const fmt = n => { + const value = Number(n || 0); + if (!Number.isFinite(value) || value <= 0) return ''; + if (value >= 1000000) return (value / 1000000).toFixed(value >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M'; + if (value >= 1000) return (value / 1000).toFixed(value >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k'; + return String(Math.round(value)); + }; + const input = fmt(usage.input_tokens); + const output = fmt(usage.output_tokens); + const total = fmt(usage.total_tokens); + if (input || output) parts.push(`${input || '0'} in · ${output || '0'} out`); + else if (total) parts.push(`${total} tokens`); + const cost = Number(usage.estimated_cost_usd); + if (Number.isFinite(cost) && cost > 0) parts.push(`$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`); + if (usage.model) parts.push(String(usage.model)); + return parts.join(' · '); +} + // ── Cron run watch ──────────────────────────────────────────────────────────── let _cronWatchInterval = null; let _cronWatchStart = null; diff --git a/static/style.css b/static/style.css index f21f68aa..9b778c8c 100644 --- a/static/style.css +++ b/static/style.css @@ -3459,6 +3459,8 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .detail-run-body{display:none;margin-top:6px;font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.5;max-height:260px;overflow-y:auto;background:var(--sidebar);border:1px solid var(--border);border-radius:6px;padding:8px 10px;} .detail-run-item.open .detail-run-body{display:block;} .detail-run-body.expanded{max-height:none;overflow-y:visible;} +.cron-run-usage-strip{display:inline-flex;align-items:center;gap:4px;margin-left:8px;color:var(--text-secondary);font-size:11px;opacity:.72;white-space:nowrap;} +.cron-run-usage-footer{display:flex;margin:8px 0 0 0;padding-top:8px;border-top:1px solid var(--border-subtle);} .cron-item.active,.ws-row.active,.profile-card.active{background:var(--accent-bg);} .cron-item.active .cron-name,.ws-row.active .ws-row-name,.profile-card.active .profile-card-name{color:var(--accent-text);} diff --git a/tests/test_sprint10.py b/tests/test_sprint10.py index fbce0a4b..bbbfdc8e 100644 --- a/tests/test_sprint10.py +++ b/tests/test_sprint10.py @@ -116,6 +116,40 @@ def test_cron_output_snippet_helper(cleanup_test_sessions): assert "_cronOutputSnippet" in src +def test_cron_output_usage_metadata_parses_optional_fields(cleanup_test_sessions): + from api.routes import _cron_output_usage_metadata + + content = "\n".join([ + "# Cron Job: Nightly", + "**Model:** openai-codex/gpt-5.5", + "**Tokens:** 12,345 in / 678 out", + "**Estimated cost:** $0.0123 (estimated)", + "**Duration:** 42.5s", + "", + "## Response", + "Done", + ]) + + usage = _cron_output_usage_metadata(content) + + assert usage["model"] == "openai-codex/gpt-5.5" + assert usage["input_tokens"] == 12345 + assert usage["output_tokens"] == 678 + assert usage["total_tokens"] == 13023 + assert usage["estimated_cost_usd"] == 0.0123 + assert usage["duration_seconds"] == 42.5 + + +def test_cron_output_usage_strip_render_hook(cleanup_test_sessions): + src, _ = get_text("/static/panels.js") + css, _ = get_text("/static/style.css") + + assert "_formatCronRunUsageStrip(run.usage)" in src + assert "_formatCronRunUsageStrip(data.usage)" in src + assert "cron-run-usage-strip" in src + assert ".cron-run-usage-strip" in css + + def test_cron_output_window_preserves_response_after_large_prompt(cleanup_test_sessions): """Large skill dumps before ## Response must not hide the useful output.""" from api.routes import _cron_output_content_window