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