Merge pull request #2332 into stage-362

feat: show cron output usage metadata (Michaelyklam)
This commit is contained in:
Hermes Agent
2026-05-15 22:55:36 +00:00
5 changed files with 132 additions and 2 deletions
+2
View File
@@ -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/<session_id>/`; 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
+64 -1
View File
@@ -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.
+30 -1
View File
@@ -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 `<div class="detail-run-item" id="${rid}">
<div class="detail-run-head" onclick="_loadRunContent('${esc(jobId)}','${esc(run.filename)}','${rid}')">
<span><span style="opacity:.7">${esc(ts)}</span> <span style="opacity:.4;font-size:11px">${esc(sizeStr)}</span></span>
<span><span style="opacity:.7">${esc(ts)}</span> <span style="opacity:.4;font-size:11px">${esc(sizeStr)}</span>${usageStrip ? ` <span class="cron-run-usage-strip">${esc(usageStrip)}</span>` : ''}</span>
<span class="detail-run-actions">
<button type="button" class="detail-expand-toggle" onclick="event.stopPropagation();toggleCronRunExpanded('${esc(jobId)}','${esc(run.filename)}','${rid}')" title="${esc(runToggleLabel)}" aria-label="${esc(runToggleLabel)}">${esc(runExpanded ? '▴' : '▾')}</button>
<span style="opacity:.6"></span>
@@ -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;
+2
View File
@@ -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);}
+34
View File
@@ -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