mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(tui): surface live learning events
Emit learning events from memory, recall, and skill tool completions, render them as subtle italic transcript lines, and show learning stats/provenance in the TUI.
This commit is contained in:
@@ -18,8 +18,10 @@ class LedgerItem:
|
||||
summary: str
|
||||
source: str
|
||||
count: int = 0
|
||||
learned_from: str | None = None
|
||||
last_used_at: float | None = None
|
||||
learned_at: float | None = None
|
||||
via: str | None = None
|
||||
|
||||
|
||||
def build_learning_ledger(db: Any = None, *, limit: int = 80) -> dict[str, Any]:
|
||||
@@ -91,7 +93,15 @@ def _tool_usage_items(db: Any) -> list[LedgerItem]:
|
||||
|
||||
usage: dict[tuple[str, str], LedgerItem] = {}
|
||||
|
||||
def bump(item_type: str, name: str, summary: str, ts: float | None):
|
||||
def bump(
|
||||
item_type: str,
|
||||
name: str,
|
||||
summary: str,
|
||||
ts: float | None,
|
||||
*,
|
||||
learned_from: str | None = None,
|
||||
via: str | None = None,
|
||||
):
|
||||
key = (item_type, name)
|
||||
item = usage.get(key)
|
||||
if not item:
|
||||
@@ -100,19 +110,25 @@ def _tool_usage_items(db: Any) -> list[LedgerItem]:
|
||||
name=name,
|
||||
summary=summary,
|
||||
source="state.db",
|
||||
learned_from=learned_from,
|
||||
via=via,
|
||||
)
|
||||
item.count += 1
|
||||
if ts and (not item.last_used_at or ts > item.last_used_at):
|
||||
item.last_used_at = ts
|
||||
item.learned_from = learned_from or item.learned_from
|
||||
item.via = via or item.via
|
||||
|
||||
try:
|
||||
with db._lock:
|
||||
rows = db._conn.execute(
|
||||
"""
|
||||
SELECT role, content, tool_calls, tool_name, timestamp
|
||||
FROM messages
|
||||
WHERE tool_name IS NOT NULL OR tool_calls IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
SELECT m.role, m.content, m.tool_calls, m.tool_name, m.timestamp,
|
||||
m.session_id, s.title, s.source AS session_source
|
||||
FROM messages m
|
||||
LEFT JOIN sessions s ON s.id = m.session_id
|
||||
WHERE m.tool_name IS NOT NULL OR m.tool_calls IS NOT NULL
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT 5000
|
||||
"""
|
||||
).fetchall()
|
||||
@@ -123,38 +139,84 @@ def _tool_usage_items(db: Any) -> list[LedgerItem]:
|
||||
ts = _float(row["timestamp"])
|
||||
tool_name = row["tool_name"]
|
||||
content = row["content"] or ""
|
||||
learned_from = row["title"] or row["session_source"] or row["session_id"]
|
||||
if tool_name == "memory":
|
||||
target = _json(content).get("target") or "memory"
|
||||
bump(str(target), f"{target} writes", "Durable memory updates", ts)
|
||||
bump(str(target), f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via="memory")
|
||||
elif tool_name == "session_search":
|
||||
bump("recall", "session_search", "Past conversations recalled", ts)
|
||||
event = learning_event_from_tool(tool_name, {}, content)
|
||||
if event:
|
||||
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via="session_search")
|
||||
elif tool_name in {"skill_view", "skill_manage"}:
|
||||
data = _json(content)
|
||||
name = str(data.get("name") or data.get("skill") or tool_name)
|
||||
bump("skill-use", name, _skill_summary(tool_name, data), ts)
|
||||
bump("skill-use", name, _skill_summary(tool_name, data), ts, learned_from=learned_from, via=tool_name)
|
||||
|
||||
for call in _tool_calls(row["tool_calls"]):
|
||||
name, args = call
|
||||
if name == "session_search":
|
||||
query = str(args.get("query") or "").strip()
|
||||
bump(
|
||||
"recall",
|
||||
query or "session_search",
|
||||
"Past conversations recalled",
|
||||
ts,
|
||||
)
|
||||
event = learning_event_from_tool(name, args, content)
|
||||
if event:
|
||||
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via=name)
|
||||
elif name in {"skill_view", "skill_manage"}:
|
||||
skill_name = str(
|
||||
args.get("name") or args.get("skill") or args.get("query") or name
|
||||
)
|
||||
bump("skill-use", skill_name, _skill_summary(name, args), ts)
|
||||
bump("skill-use", skill_name, _skill_summary(name, args), ts, learned_from=learned_from, via=name)
|
||||
elif name == "memory":
|
||||
target = str(args.get("target") or "memory")
|
||||
bump(target, f"{target} writes", "Durable memory updates", ts)
|
||||
bump(target, f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via=name)
|
||||
|
||||
return list(usage.values())
|
||||
|
||||
|
||||
def learning_event_from_tool(
|
||||
tool_name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
result: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
args = args or {}
|
||||
data = _json(result)
|
||||
|
||||
if tool_name == "memory":
|
||||
target = str(args.get("target") or data.get("target") or "memory")
|
||||
content = str(args.get("content") or "").strip()
|
||||
return {
|
||||
"type": target if target in {"memory", "user"} else "memory",
|
||||
"verb": "remembered",
|
||||
"title": _one_line(content, max_len=120) if content else f"{target} updated",
|
||||
"summary": "Durable memory updated",
|
||||
"source": "memory",
|
||||
"via": "memory",
|
||||
}
|
||||
|
||||
if tool_name == "session_search":
|
||||
title = _recall_title(data) or str(args.get("query") or "").strip() or "past sessions"
|
||||
return {
|
||||
"type": "recall",
|
||||
"verb": "recalled",
|
||||
"title": _one_line(title, max_len=120),
|
||||
"summary": "Past conversations recalled",
|
||||
"source": "state.db",
|
||||
"via": "session_search",
|
||||
}
|
||||
|
||||
if tool_name in {"skill_view", "skill_manage"}:
|
||||
action = str(args.get("action") or data.get("action") or "").strip().lower()
|
||||
name = str(args.get("name") or args.get("query") or data.get("name") or "skill").strip()
|
||||
verb = "updated skill" if tool_name == "skill_manage" and action in {"create", "patch", "update", "install"} else "applied skill"
|
||||
return {
|
||||
"type": "skill-use",
|
||||
"verb": verb,
|
||||
"title": _one_line(name, max_len=120),
|
||||
"summary": _skill_summary(tool_name, {**args, **(data if isinstance(data, dict) else {})}),
|
||||
"source": "skills",
|
||||
"via": tool_name,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _skill_summary(tool_name: str, data: dict[str, Any]) -> str:
|
||||
action = str(data.get("action") or "").strip().lower()
|
||||
if tool_name == "skill_manage" and action:
|
||||
@@ -164,6 +226,16 @@ def _skill_summary(tool_name: str, data: dict[str, Any]) -> str:
|
||||
return "Skill reused"
|
||||
|
||||
|
||||
def _recall_title(data: Any) -> str:
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
results = data.get("results")
|
||||
if not isinstance(results, list) or not results:
|
||||
return str(data.get("query") or "").strip()
|
||||
first = results[0] if isinstance(results[0], dict) else {}
|
||||
return str(first.get("title") or first.get("preview") or data.get("query") or "").strip()
|
||||
|
||||
|
||||
def _integration_items() -> list[LedgerItem]:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
@@ -970,6 +970,17 @@ def _session_info(agent) -> dict:
|
||||
info["mcp_servers"] = get_mcp_status()
|
||||
except Exception:
|
||||
info["mcp_servers"] = []
|
||||
try:
|
||||
from hermes_cli.learning_ledger import build_learning_ledger
|
||||
|
||||
ledger = build_learning_ledger(_get_db(), limit=1)
|
||||
info["learning"] = {
|
||||
"counts": ledger.get("counts", {}),
|
||||
"inventory": ledger.get("inventory", {}),
|
||||
"total": ledger.get("total", 0),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.banner import get_update_result
|
||||
from hermes_cli.config import recommended_update_command
|
||||
@@ -1092,6 +1103,14 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
|
||||
pass
|
||||
if _tool_progress_enabled(sid) or payload.get("inline_diff"):
|
||||
_emit("tool.complete", sid, payload)
|
||||
try:
|
||||
from hermes_cli.learning_ledger import learning_event_from_tool
|
||||
|
||||
event = learning_event_from_tool(name, args, result)
|
||||
if event:
|
||||
_emit("learning.event", sid, event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _on_tool_progress(
|
||||
|
||||
@@ -231,6 +231,21 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
return
|
||||
}
|
||||
|
||||
case 'learning.event': {
|
||||
const title = String(ev.payload?.title ?? '').trim()
|
||||
const verb = String(ev.payload?.verb ?? ev.payload?.type ?? 'learned').trim()
|
||||
|
||||
if (title) {
|
||||
appendMessage({
|
||||
kind: 'learning',
|
||||
role: 'system',
|
||||
text: `${verb}: ${title}`
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'message.start':
|
||||
turnController.startMessage()
|
||||
|
||||
|
||||
@@ -89,6 +89,16 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const learningLine = (() => {
|
||||
const counts = info.learning?.counts ?? {}
|
||||
const parts = [
|
||||
counts.user || counts.memory ? `${(counts.user ?? 0) + (counts.memory ?? 0)} memories` : '',
|
||||
counts.recall ? `${counts.recall} recalls` : '',
|
||||
counts['skill-use'] ? `${counts['skill-use']} applied skills` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length ? `learned: ${parts.join(' · ')}` : ''
|
||||
})()
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
|
||||
@@ -160,6 +170,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
</Text>
|
||||
|
||||
{learningLine && (
|
||||
<Text color={t.color.cornsilk} dimColor italic>
|
||||
{learningLine} · /learned
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
||||
<Text bold color={t.color.warn}>
|
||||
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
|
||||
|
||||
@@ -24,7 +24,7 @@ const typeVerb: Record<string, string> = {
|
||||
integration: 'connected',
|
||||
memory: 'remembered',
|
||||
recall: 'recalled',
|
||||
'skill-use': 'reused skill',
|
||||
'skill-use': 'applied skill',
|
||||
user: 'remembered'
|
||||
}
|
||||
|
||||
@@ -171,11 +171,11 @@ export function LearningLedger({ gw, onClose, t }: LearningLedgerProps) {
|
||||
{detailOpen && selected ? <LedgerDetails item={selected} t={t} width={detailWidth} /> : null}
|
||||
</Box>
|
||||
|
||||
{offset + VISIBLE_ROWS < items.length && <Text color={t.color.dim}> ↓ {items.length - offset - VISIBLE_ROWS} more</Text>}
|
||||
{offset + VISIBLE_ROWS < items.length && (
|
||||
<Text color={t.color.dim}> ↓ {items.length - offset - VISIBLE_ROWS} more</Text>
|
||||
)}
|
||||
|
||||
<OverlayHint t={t}>
|
||||
↑/↓ select · Enter/Space details · 1-9,0 quick · Esc/q close
|
||||
</OverlayHint>
|
||||
<OverlayHint t={t}>↑/↓ select · Enter/Space details · 1-9,0 quick · Esc/q close</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -215,6 +215,9 @@ function LedgerDetails({ item, t, width }: LedgerDetailsProps) {
|
||||
</Text>
|
||||
{memoryLike ? <Text color={t.color.cornsilk}>{item.summary}</Text> : null}
|
||||
{item.count ? <Text color={t.color.dim}>used: {item.count}×</Text> : null}
|
||||
{item.learned_from ? <Text color={t.color.dim}>from: {item.learned_from}</Text> : null}
|
||||
{item.via ? <Text color={t.color.dim}>via: {item.via}</Text> : null}
|
||||
{item.last_used_at ? <Text color={t.color.dim}>last used: {fmtTime(item.last_used_at)}</Text> : null}
|
||||
<Text color={t.color.dim}>source: {item.source}</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -222,12 +225,14 @@ function LedgerDetails({ item, t, width }: LedgerDetailsProps) {
|
||||
|
||||
interface LearningLedgerItem {
|
||||
count?: number
|
||||
learned_from?: null | string
|
||||
last_used_at?: null | number
|
||||
learned_at?: null | number
|
||||
name: string
|
||||
source: string
|
||||
summary: string
|
||||
type: string
|
||||
via?: null | string
|
||||
}
|
||||
|
||||
interface LearningLedgerResponse {
|
||||
|
||||
@@ -100,6 +100,14 @@ export const MessageLine = memo(function MessageLine({
|
||||
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'learning') {
|
||||
return (
|
||||
<Text color={t.color.cornsilk} dimColor italic>
|
||||
{msg.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.kind === 'slash') {
|
||||
return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
}
|
||||
|
||||
@@ -375,6 +375,11 @@ export type GatewayEvent =
|
||||
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
|
||||
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
|
||||
| {
|
||||
payload?: { source?: string; summary?: string; title?: string; type?: string; verb?: string; via?: string }
|
||||
session_id?: string
|
||||
type: 'learning.event'
|
||||
}
|
||||
| { payload?: undefined; session_id?: string; type: 'message.start' }
|
||||
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
|
||||
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface ClarifyReq {
|
||||
|
||||
export interface Msg {
|
||||
info?: SessionInfo
|
||||
kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail'
|
||||
kind?: 'diff' | 'intro' | 'learning' | 'panel' | 'slash' | 'trail'
|
||||
panelData?: PanelData
|
||||
role: Role
|
||||
text: string
|
||||
@@ -148,6 +148,7 @@ export interface SessionInfo {
|
||||
reasoning_effort?: string
|
||||
service_tier?: string
|
||||
release_date?: string
|
||||
learning?: LearningSummary
|
||||
skills: Record<string, string[]>
|
||||
tools: Record<string, string[]>
|
||||
update_behind?: number | null
|
||||
@@ -156,6 +157,12 @@ export interface SessionInfo {
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface LearningSummary {
|
||||
counts?: Record<string, number>
|
||||
inventory?: { skills?: number }
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
calls: number
|
||||
context_max?: number
|
||||
|
||||
Reference in New Issue
Block a user