diff --git a/hermes_cli/learning_ledger.py b/hermes_cli/learning_ledger.py
index bdeedba4a9b..8ed4771d259 100644
--- a/hermes_cli/learning_ledger.py
+++ b/hermes_cli/learning_ledger.py
@@ -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
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 02c62de020e..9d13bafc3ba 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -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(
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
index 267bf8c1660..a9000b4dafc 100644
--- a/ui-tui/src/app/createGatewayEventHandler.ts
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -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()
diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx
index 5922e71ba71..d66cbdb6223 100644
--- a/ui-tui/src/components/branding.tsx
+++ b/ui-tui/src/components/branding.tsx
@@ -89,6 +89,16 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
)
}
+ 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 (
@@ -160,6 +170,12 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
/help for commands
+ {learningLine && (
+
+ {learningLine} · /learned
+
+ )}
+
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
diff --git a/ui-tui/src/components/learningLedger.tsx b/ui-tui/src/components/learningLedger.tsx
index f6aaff029c3..fb3550a9423 100644
--- a/ui-tui/src/components/learningLedger.tsx
+++ b/ui-tui/src/components/learningLedger.tsx
@@ -24,7 +24,7 @@ const typeVerb: Record = {
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 ? : null}
- {offset + VISIBLE_ROWS < items.length && ↓ {items.length - offset - VISIBLE_ROWS} more}
+ {offset + VISIBLE_ROWS < items.length && (
+ ↓ {items.length - offset - VISIBLE_ROWS} more
+ )}
-
- ↑/↓ select · Enter/Space details · 1-9,0 quick · Esc/q close
-
+ ↑/↓ select · Enter/Space details · 1-9,0 quick · Esc/q close
)
}
@@ -215,6 +215,9 @@ function LedgerDetails({ item, t, width }: LedgerDetailsProps) {
{memoryLike ? {item.summary} : null}
{item.count ? used: {item.count}× : null}
+ {item.learned_from ? from: {item.learned_from} : null}
+ {item.via ? via: {item.via} : null}
+ {item.last_used_at ? last used: {fmtTime(item.last_used_at)} : null}
source: {item.source}
)
@@ -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 {
diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx
index 99312b0618f..8de27804772 100644
--- a/ui-tui/src/components/messageLine.tsx
+++ b/ui-tui/src/components/messageLine.tsx
@@ -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 (
+
+ {msg.text}
+
+ )
+ }
+
if (msg.kind === 'slash') {
return {msg.text}
}
diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts
index dbaecd4d3d5..d17196f1d24 100644
--- a/ui-tui/src/gatewayTypes.ts
+++ b/ui-tui/src/gatewayTypes.ts
@@ -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' }
diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts
index 6aea78e3e4d..b8dffe053c6 100644
--- a/ui-tui/src/types.ts
+++ b/ui-tui/src/types.ts
@@ -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
tools: Record
update_behind?: number | null
@@ -156,6 +157,12 @@ export interface SessionInfo {
version?: string
}
+export interface LearningSummary {
+ counts?: Record
+ inventory?: { skills?: number }
+ total?: number
+}
+
export interface Usage {
calls: number
context_max?: number