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:
Brooklyn Nicholson
2026-04-27 13:31:52 -05:00
parent 97bf2568a4
commit b9697d001d
8 changed files with 170 additions and 23 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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()

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>
}

View File

@@ -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' }

View File

@@ -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