"""Terminal renderer for the learning timeline (learned skills + memories). The desktop app (``apps/desktop/src/app/starmap``) paints a GPU radial constellation; a terminal can't, so this is a *rendition* of the same data as a timeline bar chart — date rows, proportional skill/memory bars colored by the day's dominant category, and a cumulative trajectory sparkline — plus per-slice bucket metadata the TUI walks as a tree. The age gradient and complementary memory ink are ported from the desktop source, not guessed. Grids are emitted as style runs — ``[text, style, alpha, hex?]`` — so each consumer maps the semantic style + brightness onto its own palette; the optional 4th element overrides the base color (category heatmap). Pure, stdlib-only. """ from __future__ import annotations import math from datetime import datetime, timezone from typing import Any, Iterable, Optional # time-axis.ts LEAD_IN: the oldest node sits just off recency 0. LEAD_IN = 0.06 # constants.ts AGE_GRADIENT — old quiet, recent bright. AGE_OLD_INK = 0.42 AGE_MID_INK = 0.74 AGE_NEW_INK = 0.95 AGE_MID = 0.52 # Style keys consumers map to base colors (brightness = the run alpha). STYLE_BG = "bg" STYLE_SKILL = "skill" STYLE_MEMORY = "memory" STYLE_LABEL = "label" STYLE_DIM = "dim" # Legend glyphs mirror NODE_SHAPE (skill = circle, memory = diamond). SKILL_GLYPH = "●" MEMORY_GLYPH = "◆" _LABEL_KEYS = tuple("123456789abc") Run = list # [text, style, alpha, hex?] Row = list # list[Run] Grid = list # list[Row] def _to_ts(value: Any) -> Optional[float]: try: return None if value is None else float(value) except (TypeError, ValueError): return None def _clamp(v: float, lo: float, hi: float) -> float: return lo if v < lo else hi if v > hi else v def _smoothstep(p: float) -> float: p = _clamp(p, 0.0, 1.0) return p * p * (3 - 2 * p) def recency_ink(rec: float) -> float: """Port of geometry.ts ``recencyInk`` — smoothstep age → ink alpha.""" t = _clamp(rec, 0.0, 1.0) if t <= AGE_MID: return AGE_OLD_INK + (AGE_MID_INK - AGE_OLD_INK) * _smoothstep(t / AGE_MID) return AGE_MID_INK + (AGE_NEW_INK - AGE_MID_INK) * _smoothstep((t - AGE_MID) / (1 - AGE_MID)) def format_date(ts: Optional[float]) -> str: if not ts: return "unknown" try: return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%-d %b %Y") except (ValueError, OSError, OverflowError): return "unknown" def compute_recency(nodes: list[dict[str, Any]]) -> dict[str, Any]: """Port of time-axis.ts ``computeRecency`` (id → recency ratio, timed flag).""" known = [t for t in (_to_ts(n.get("timestamp")) for n in nodes) if t is not None] min_ts = min(known) if known else None max_ts = max(known) if known else None timed = min_ts is not None and max_ts is not None and max_ts > min_ts ordered = sorted( nodes, key=lambda n: ( _to_ts(n.get("timestamp")) if _to_ts(n.get("timestamp")) is not None else math.inf, str(n.get("id", "")), ), ) last = max(len(ordered) - 1, 1) ord_ratio = {str(n.get("id", "")): (i / last if len(ordered) > 1 else 0.0) for i, n in enumerate(ordered)} rec: dict[str, float] = {} for n in nodes: nid = str(n.get("id", "")) ts = _to_ts(n.get("timestamp")) if timed and ts is not None and min_ts is not None and max_ts is not None: ratio = (ts - min_ts) / (max_ts - min_ts) else: ratio = ord_ratio.get(nid, 0.0) rec[nid] = LEAD_IN + (1 - LEAD_IN) * _clamp(ratio, 0.0, 1.0) return {"rec": rec, "timed": timed, "minTs": min_ts, "maxTs": max_ts} def _date_at(rec: dict[str, Any], reveal: float) -> Optional[float]: if not rec.get("timed"): return None lo, hi = rec.get("minTs"), rec.get("maxTs") if lo is None or hi is None: return None return round(lo + _clamp(reveal, 0, 1) * (hi - lo)) # ── Color: ported from color.ts so memory ink + age fade match the desktop ── def hex_to_rgb(s: str) -> tuple[int, int, int]: s = s.strip().lstrip("#") if len(s) == 3: s = "".join(c * 2 for c in s) try: return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) except (ValueError, IndexError): return 255, 215, 0 def rgb_to_hex(c: tuple) -> str: return "#{:02X}{:02X}{:02X}".format(*(int(_clamp(v, 0, 255)) for v in c)) def mix_rgb(a: tuple, b: tuple, t: float) -> tuple[int, int, int]: p = _clamp(t, 0.0, 1.0) return tuple(round(a[i] + (b[i] - a[i]) * p) for i in range(3)) # type: ignore[return-value] def _rgb_to_hsl(c: tuple) -> tuple[float, float, float]: r, g, b = (x / 255 for x in c) mx, mn = max(r, g, b), min(r, g, b) light = (mx + mn) / 2 d = mx - mn if not d: return 0.0, 0.0, light s = d / (2 - mx - mn) if light > 0.5 else d / (mx + mn) if mx == r: h = (g - b) / d + (6 if g < b else 0) elif mx == g: h = (b - r) / d + 2 else: h = (r - g) / d + 4 return h * 60, s, light def _hsl_to_rgb(h: float, s: float, light: float) -> tuple[int, int, int]: hue = ((h % 360) + 360) % 360 c = (1 - abs(2 * light - 1)) * s x = c * (1 - abs(((hue / 60) % 2) - 1)) m = light - c / 2 if hue < 60: r, g, b = c, x, 0.0 elif hue < 120: r, g, b = x, c, 0.0 elif hue < 180: r, g, b = 0.0, c, x elif hue < 240: r, g, b = 0.0, x, c elif hue < 300: r, g, b = x, 0.0, c else: r, g, b = c, 0.0, x return round((r + m) * 255), round((g + m) * 255), round((b + m) * 255) def _complementary_ink(c: tuple) -> tuple[int, int, int]: h, s, light = _rgb_to_hsl(c) return _hsl_to_rgb(h + 165, max(s, 0.5), _clamp(light, 0.5, 0.7)) def derive_palette(primary_hex: str, *, dark: bool = True) -> dict[str, str]: """Port of color.ts ``computePalette`` (the bits a terminal needs).""" primary = hex_to_rgb(primary_hex) base = (255, 255, 255) if dark else (0, 0, 0) bg = (8, 8, 12) if dark else (250, 250, 250) return { "primary": primary_hex, # Memories are drillable → primary "clickable" ink; skills are dead-ends # → muted complement. "memory": rgb_to_hex(mix_rgb(primary, base, 0.12 if dark else 0.18)), "skill": rgb_to_hex(mix_rgb(_complementary_ink(primary), bg, 0.45)), "label": rgb_to_hex(mix_rgb(base, bg, 0.35)), "dim": rgb_to_hex(mix_rgb(base, bg, 0.7)), "bg": rgb_to_hex(bg), } def _node_score(node: dict[str, Any], rec: float) -> float: """Pick which visible objects deserve map markers + label rows.""" if node.get("kind") == "memory": return 3.5 + rec use = float(node.get("useCount", 0) or 0) return rec * 2 + math.sqrt(max(0.0, use)) + (2.0 if node.get("pinned") else 0.0) def _node_label(node: dict[str, Any]) -> str: text = str(node.get("label") or node.get("id") or "unknown").strip() return text if len(text) <= 26 else text[:23].rstrip() + "…" def _node_meta(node: dict[str, Any]) -> str: if node.get("kind") == "memory": source = "profile memory" if node.get("memorySource") == "profile" else "memory" return f"{source} · {format_date(_to_ts(node.get('timestamp')))}" bits = [str(node.get("category") or "skill"), format_date(_to_ts(node.get("timestamp")))] count = int(node.get("useCount", 0) or 0) if count: bits.append(f"x{count}") if node.get("pinned"): bits.append("pinned") return " · ".join(bits) # ── Timeline chart frame ───────────────────────────────────────────────────── class _ChartBucket: __slots__ = ("label", "ts", "skills", "memories", "nodes", "rec") def __init__(self, label: str, ts: float): self.label = label self.ts = ts self.skills = 0 self.memories = 0 self.nodes: list[dict[str, Any]] = [] self.rec = 1.0 @property def total(self) -> int: return self.skills + self.memories def _period_key(ts: float, granularity: str) -> tuple[int, ...]: dt = datetime.fromtimestamp(ts, tz=timezone.utc) if granularity == "day": return (dt.year, dt.month, dt.day) if granularity == "month": return (dt.year, dt.month) return (dt.year,) def _period_label(ts: float, granularity: str) -> str: dt = datetime.fromtimestamp(ts, tz=timezone.utc) if granularity == "day": return dt.strftime("%-d %b") if granularity == "month": return dt.strftime("%b %Y") return dt.strftime("%Y") def _build_chart_buckets(nodes: list[dict[str, Any]], rec: dict[str, Any], max_rows: int) -> list[_ChartBucket]: """Timeline rows: finest date granularity that fits, oldest → newest.""" if not nodes: return [] if not rec["timed"]: ordered = sorted(nodes, key=lambda n: rec["rec"].get(str(n.get("id", "")), 0.0)) n_bins = min(max_rows, max(1, len(ordered))) buckets = [_ChartBucket(f"#{i + 1}", float(i)) for i in range(n_bins)] for node in ordered: idx = int(_clamp(math.floor(rec["rec"].get(str(node.get("id", "")), 0.0) * n_bins), 0, n_bins - 1)) b = buckets[idx] b.nodes.append(node) if node.get("kind") == "memory": b.memories += 1 else: b.skills += 1 return buckets chosen: Optional[list[_ChartBucket]] = None for granularity in ("day", "month", "year"): groups: dict[tuple[int, ...], _ChartBucket] = {} for node in nodes: ts = _to_ts(node.get("timestamp")) if ts is None: continue key = _period_key(ts, granularity) bucket = groups.get(key) if bucket is None: bucket = _ChartBucket(_period_label(ts, granularity), ts) groups[key] = bucket bucket.nodes.append(node) if node.get("kind") == "memory": bucket.memories += 1 else: bucket.skills += 1 # For short spans, keep the useful day-by-day graph even when the caller # asked for fewer rows; terminal scrollback is better than collapsing a # month of activity into one unreadable bar. if len(groups) <= max_rows or (granularity == "day" and len(groups) <= 32): chosen = [groups[key] for key in sorted(groups)] break if chosen is None: # If even yearly buckets overflow, fall back to even time bins. min_ts, max_ts = rec.get("minTs"), rec.get("maxTs") n_bins = max(1, max_rows) chosen = [] for i in range(n_bins): ts = min_ts + (i / max(1, n_bins - 1)) * (max_ts - min_ts) if min_ts and max_ts else float(i) chosen.append(_ChartBucket(format_date(ts), ts)) for node in nodes: r = rec["rec"].get(str(node.get("id", "")), 0.0) idx = int(_clamp(math.floor(r * n_bins), 0, n_bins - 1)) b = chosen[idx] b.nodes.append(node) if node.get("kind") == "memory": b.memories += 1 else: b.skills += 1 min_ts, max_ts = rec.get("minTs"), rec.get("maxTs") span = (max_ts - min_ts) if min_ts is not None and max_ts is not None and max_ts > min_ts else 0 for bucket in chosen: bucket.rec = LEAD_IN + (1 - LEAD_IN) * ((bucket.ts - min_ts) / span) if span else 1.0 return chosen def _bucket_label_node(bucket: _ChartBucket) -> Optional[dict[str, Any]]: if not bucket.nodes: return None return max(bucket.nodes, key=lambda node: _node_score(node, _to_ts(node.get("timestamp")) or bucket.ts)) def _bucket_nodes(bucket: _ChartBucket, memory_lookup: Optional[dict[str, dict[str, Any]]] = None) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] # Chronological within the slice so the TUI tree reads oldest → newest. ordered = sorted(bucket.nodes, key=lambda n: _to_ts(n.get("timestamp")) or bucket.ts) for node in ordered: style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL raw_label = str(node.get("label") or node.get("id") or "unknown").strip() memory = (memory_lookup or {}).get(str(node.get("id", ""))) out.append( { "id": str(node.get("id", "")), "glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH, "label": _node_label(node), "fullLabel": raw_label, "meta": _node_meta(node), "body": str(memory.get("body", "")) if memory else "", "style": style, } ) return out def _bucket_rows(buckets: list[_ChartBucket], payload: dict[str, Any]) -> list[dict[str, Any]]: cmap = category_color_map(payload) memory_lookup = { f"memory:{card.get('source')}:{idx}": card for idx, card in enumerate(payload.get("memory", []) or []) if isinstance(card, dict) } rows: list[dict[str, Any]] = [] for idx, bucket in enumerate(buckets): cat = _bucket_category(bucket) rows.append( { "index": idx, "label": bucket.label, "date": format_date(bucket.ts), "skills": bucket.skills, "memories": bucket.memories, "total": bucket.total, "category": cat, "color": cmap.get(cat) if cat else None, "nodes": _bucket_nodes(bucket, memory_lookup), } ) return rows def _category_counts(payload: dict[str, Any]) -> list[tuple[str, int]]: clusters = [ (str(c.get("category")), int(c.get("count", 0))) for c in payload.get("clusters", []) or [] if c.get("category") and c.get("category") != "memory" ] if clusters: return clusters counts: dict[str, int] = {} for node in payload.get("nodes", []): if node.get("kind") == "memory": continue cat = str(node.get("category") or "skill") counts[cat] = counts.get(cat, 0) + 1 return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) def category_color_map(payload: dict[str, Any]) -> dict[str, str]: """Deterministic, evenly-spread hue per skill category (theme-independent).""" clusters = _category_counts(payload) n = max(1, len(clusters)) # Golden-angle hue spacing so adjacent categories never collide in color. return {cat: rgb_to_hex(_hsl_to_rgb((i * 137.508) % 360, 0.55, 0.62)) for i, (cat, _c) in enumerate(clusters)} def category_legend(payload: dict[str, Any], limit: int = 4) -> list[dict[str, Any]]: cmap = category_color_map(payload) cats = _category_counts(payload) shown = cats[:limit] hidden = max(0, len(cats) - len(shown)) return [ {"glyph": "●", "color": cmap.get(cat, ""), "label": f"{cat} ({count})"} for cat, count in shown ] + ([{"glyph": "·", "color": "", "label": f"+{hidden}"}] if hidden else []) def _bucket_category(bucket: _ChartBucket) -> Optional[str]: counts: dict[str, int] = {} for node in bucket.nodes: if node.get("kind") == "memory": continue cat = str(node.get("category") or "skill") counts[cat] = counts.get(cat, 0) + 1 return max(counts, key=lambda k: counts[k]) if counts else None def _trajectory_row(buckets: list[_ChartBucket], width: int, reveal: float) -> Row: """Cumulative learning curve as a compact star-path sparkline.""" if not buckets: return [] total = sum(b.total for b in buckets) or 1 visible = int(_clamp(math.ceil(reveal * len(buckets)), 0, len(buckets))) acc = 0 points: list[int] = [] for b in buckets[:visible]: acc += b.total points.append(round((acc / total) * (width - 1))) cells = [" "] * width last = 0 for p in points: for x in range(min(last, p), max(last, p) + 1): if 0 <= x < width and cells[x] == " ": cells[x] = "·" if 0 <= p < width: cells[p] = "✦" last = p return [["trajectory ", STYLE_LABEL, 0.55], ["".join(cells), STYLE_SKILL, 0.48]] def render_graph(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, reveal: float = 1.0) -> dict[str, Any]: """Render one timeline frame at ``reveal`` (0→1). Date rows with proportional skill/memory bars colored by the day's dominant category, numbered markers tied to label rows, and a cumulative trajectory sparkline underneath. """ reveal = _clamp(reveal, 0.0, 1.0) cols = max(44, cols) rows = max(14, rows) nodes = list(payload.get("nodes", [])) if not nodes: placeholder = [["no learning yet — keep using Hermes and it maps out here", STYLE_DIM, 0.7]] return {"grid": [placeholder], "date": "", "reveal": reveal, "visible": 0} rec = compute_recency(nodes) cmap = category_color_map(payload) buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3)) n_buckets = len(buckets) visible_bucket_count = int(_clamp(math.ceil(reveal * n_buckets), 0, n_buckets)) max_total = max((b.total for b in buckets), default=1) or 1 label_w = min(9, max(len(b.label) for b in buckets)) bar_w = max(14, cols - label_w - 16) grid: Grid = [] labels: list[dict[str, Any]] = [] visible = 0 for i, bucket in enumerate(buckets): if i >= visible_bucket_count: grid.append([]) continue visible += bucket.total ink = recency_ink(bucket.rec) bar_len = max(1, round((bucket.total / max_total) * bar_w)) if bucket.total else 0 skill_len = round((bucket.skills / bucket.total) * bar_len) if bucket.total else 0 if bucket.skills and skill_len == 0: skill_len = 1 memory_len = bar_len - skill_len if bucket.memories and memory_len == 0 and bar_len > 1: memory_len = 1 skill_len = bar_len - 1 node = _bucket_label_node(bucket) marker = "" if node and len(labels) < 6: marker = _LABEL_KEYS[len(labels)] style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL labels.append( { "key": marker, "glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH, "label": _node_label(node), "meta": _node_meta(node), "style": style, "alpha": round(ink, 3), } ) cat = _bucket_category(bucket) cat_hex = cmap.get(cat) if cat else None row: Row = [[f"{bucket.label:>{label_w}} ", STYLE_LABEL, ink], ["│ ", STYLE_DIM, 0.55]] if marker: row.append([marker, STYLE_LABEL, 0.95]) elif bucket.total: head_hex = cat_hex if bucket.skills else None row.append(["✦" if bucket.skills else "◆", STYLE_SKILL if bucket.skills else STYLE_MEMORY, ink, head_hex]) if skill_len: # Bar colored by the day's dominant category — a learning heatmap. row.append(["━" * skill_len, STYLE_SKILL, ink, cat_hex]) if memory_len: if memory_len == 1: mem_trail = "◆" else: mem_trail = "◆" + ("━" * (memory_len - 2)) + "◆" row.append([mem_trail, STYLE_MEMORY, max(0.65, ink)]) if bar_len < bar_w: # Empty space keeps counts aligned; starmap texture lives in the # trajectory row below, where it reads as signal rather than noise. row.append([" " * (bar_w - bar_len), STYLE_BG, 1.0]) row.append([" ", STYLE_BG, 1.0]) row.append([str(bucket.skills), STYLE_SKILL, max(0.72, ink)]) if bucket.memories: row.append(["+", STYLE_DIM, 0.6]) row.append([str(bucket.memories), STYLE_MEMORY, max(0.72, ink)]) if i == visible_bucket_count - 1: row.append([" ◀ now", STYLE_LABEL, 0.9]) elif bucket.total == max_total and max_total > 1: row.append([" ☄ peak", STYLE_LABEL, 0.75]) grid.append(row) # Cumulative learning trajectory underneath the rows. grid.append([[(" " * (label_w + 2)), STYLE_BG, 1.0], *_trajectory_row(buckets, max(12, cols - label_w - 13), reveal)]) return { "grid": grid, "date": format_date(_date_at(rec, reveal)), "reveal": reveal, "visible": visible, "labels": labels, } # ── Trimmings ────────────────────────────────────────────────────────────── def build_legend(payload: dict[str, Any]) -> list[dict[str, Any]]: nodes = payload.get("nodes", []) skills = sum(1 for n in nodes if n.get("kind") != "memory") memories = sum(1 for n in nodes if n.get("kind") == "memory") return [ {"glyph": SKILL_GLYPH, "style": STYLE_SKILL, "label": f"skills ({skills})"}, {"glyph": MEMORY_GLYPH, "style": STYLE_MEMORY, "label": f"memories ({memories})"}, ] def axis_labels(payload: dict[str, Any]) -> dict[str, str]: rec = compute_recency(list(payload.get("nodes", []))) if not rec["timed"]: return {"start": "oldest", "end": "now"} return {"start": format_date(rec.get("minTs")), "end": format_date(rec.get("maxTs"))} def _peak_day(payload: dict[str, Any]) -> Optional[str]: counts: dict[tuple[int, ...], int] = {} reps: dict[tuple[int, ...], float] = {} for node in payload.get("nodes", []): ts = _to_ts(node.get("timestamp")) if ts is None: continue key = _period_key(ts, "day") counts[key] = counts.get(key, 0) + 1 reps[key] = ts if not counts: return None best = max(counts, key=lambda k: counts[k]) return f"busiest day {_period_label(reps[best], 'day')} · {counts[best]} learned" def build_summary(payload: dict[str, Any]) -> list[str]: stats = payload.get("stats", {}) or {} lines: list[str] = [] learned = stats.get("learned_skills", stats.get("nodes", 0)) mem = stats.get("memory_nodes", 0) edges = stats.get("related_edges", 0) lines.append(f"{learned} learned skills · {mem} memories · {edges} skill links") extra = [] if stats.get("memory_skill_edges"): extra.append(f"{stats['memory_skill_edges']} memory↔skill links") peak = _peak_day(payload) if peak: extra.append(peak) if extra: lines.append(" · ".join(extra)) return lines def _merge_runs(cells: Iterable[Run]) -> Row: out: Row = [] for run in cells: text, style, alpha = run[0], run[1], (run[2] if len(run) > 2 else 1.0) hex_override = run[3] if len(run) > 3 else None prev_hex = out[-1][3] if out and len(out[-1]) > 3 else None if out and out[-1][1] == style and abs(out[-1][2] - alpha) < 1e-6 and prev_hex == hex_override: out[-1][0] += text else: merged: Run = [text, style, alpha] if hex_override: merged.append(hex_override) out.append(merged) return out def render_frames(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, frames: int = 48) -> dict[str, Any]: """Pre-render a full play-through (reveal 0→1) plus static legend/summary.""" frames = max(2, min(frames, 240)) nodes = list(payload.get("nodes", [])) rec = compute_recency(nodes) # Mirror render_graph's bucketing so the interactive row list lines up with # what the user sees. buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3)) if nodes else [] out_frames = [] for i in range(frames): reveal = i / (frames - 1) frame = render_graph(payload, cols=cols, rows=rows, reveal=reveal) out_frames.append( { "reveal": frame["reveal"], "date": frame["date"], "visible": frame["visible"], "grid": frame["grid"], "labels": frame.get("labels", []), } ) return { "frames": out_frames, "legend": build_legend(payload), "categories": category_legend(payload), "buckets": _bucket_rows(buckets, payload), "summary": build_summary(payload), "axis": axis_labels(payload), "count": len(payload.get("nodes", [])), "cols": cols, "rows": rows, }