diff --git a/agent/learning_graph.py b/agent/learning_graph.py new file mode 100644 index 00000000000..6dc518b2aba --- /dev/null +++ b/agent/learning_graph.py @@ -0,0 +1,320 @@ +"""Assemble the "learning made visible" graph for desktop. + +This graph is intentionally scoped to what a user actually learns over time: +- non-base, learned/profile skills (agent-created or used), +- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes. + +Skill links come from declared ``related_skills``. Memory-to-skill links are +derived from lexical overlap so the graph can answer "which learned skills are +connected to the things I remember?". + +Run as a module to print edge-density stats against real data: + + python -m agent.learning_graph +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from hermes_constants import get_hermes_home + + +@dataclass +class SkillNode: + name: str + category: str + source: str = "profile" + timestamp: Optional[int] = None + use_count: int = 0 + state: str = "active" + created_by: Optional[str] = None + pinned: bool = False + related: list[str] = field(default_factory=list) + + +def _frontmatter(text: str) -> dict[str, Any]: + try: + from agent.skill_utils import parse_frontmatter + + fm, _ = parse_frontmatter(text) + return fm or {} + except Exception: + return {} + + +def _related(fm: dict[str, Any]) -> list[str]: + raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills") + if isinstance(raw, list): + return [str(r).strip() for r in raw if str(r).strip()] + if isinstance(raw, str): + return [r.strip() for r in raw.strip("[]").split(",") if r.strip()] + return [] + + +def _category(fm: dict[str, Any], skill_md: Path) -> str: + cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category") + if cat: + return str(cat) + # …/skills///SKILL.md + parts = skill_md.parts + return parts[-3] if len(parts) >= 3 else "general" + + +def _iter_skill_files(roots: list[tuple[str, Path]]): + for source, root in roots: + if root.exists(): + for path in root.rglob("SKILL.md"): + yield source, path + + +def _load_usage() -> dict[str, dict[str, Any]]: + try: + from tools.skill_usage import load_usage + + return load_usage() + except Exception: + path = get_hermes_home() / "skills" / ".usage.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _to_int_ts(value: Any) -> Optional[int]: + try: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + s = str(value).strip() + if not s: + return None + try: + return int(float(s)) + except ValueError: + parsed = datetime.fromisoformat(s.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp()) + except Exception: + return None + + +def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]: + for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"): + ts = _to_int_ts(rec.get(key)) + if ts is not None: + return ts + return None + + +def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]: + usage = _load_usage() + nodes: dict[str, SkillNode] = {} + + for source, skill_md in _iter_skill_files(skill_roots): + if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts): + continue + try: + fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000]) + except OSError: + continue + name = str(fm.get("name") or skill_md.parent.name).strip() + if not name or name in nodes: + continue + rec = usage.get(name, {}) + last_activity = _usage_timestamp(rec) + file_ts = _to_int_ts(skill_md.stat().st_mtime) + nodes[name] = SkillNode( + name=name, + category=_category(fm, skill_md), + source=source, + timestamp=last_activity or file_ts, + use_count=int(rec.get("use_count", 0) or 0), + state=str(rec.get("state", "active") or "active"), + created_by=rec.get("created_by"), + pinned=bool(rec.get("pinned", False)), + related=_related(fm), + ) + return nodes + + +def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]: + """Undirected related_skills edges where BOTH endpoints exist (deduped).""" + seen: set[tuple[str, str]] = set() + edges: list[tuple[str, str]] = [] + for node in nodes.values(): + for target in node.related: + if target in nodes and target != node.name: + a, b = sorted((node.name, target)) + key = (a, b) + if key not in seen: + seen.add(key) + edges.append(key) + return edges + + +def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]: + linked: set[str] = set() + for a, b in edges: + linked.add(a) + linked.add(b) + cats: dict[str, int] = {} + for n in nodes.values(): + cats[n.category] = cats.get(n.category, 0) + 1 + n = len(nodes) or 1 + return { + "nodes": len(nodes), + "related_edges": len(edges), + "edges_per_node": round(len(edges) / n, 3), + "linked_nodes": len(linked), + "isolated_pct": round(100 * (n - len(linked)) / n, 1), + "categories": len(cats), + "agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"), + "used": sum(1 for x in nodes.values() if x.use_count > 0), + "top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8], + } + + +def _memory_cards() -> list[dict[str, Any]]: + """Freeform memory as readable cards. + + ``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each + chunk becomes one card. Every chunk is surfaced — the graph shows everything. + """ + base = get_hermes_home() / "memories" + cards: list[dict[str, Any]] = [] + for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")): + path = base / fname + try: + text = path.read_text(encoding="utf-8").strip() + file_ts = _to_int_ts(path.stat().st_mtime) + except OSError: + continue + for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")): + if not chunk: + continue + first = chunk.splitlines()[0].strip().lstrip("# ").strip() + cards.append( + { + "source": source, + "timestamp": file_ts + chunk_idx if file_ts is not None else None, + "title": (first[:80] + "…") if len(first) > 80 else first, + "body": chunk[:1200], + } + ) + return cards + + +def _tokenize(text: str) -> set[str]: + return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3} + + +def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]: + edges: list[tuple[str, str]] = [] + skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills] + for idx, card in enumerate(memory_cards): + mem_id = f"memory:{card['source']}:{idx}" + text = f"{card.get('title', '')}\n{card.get('body', '')}".lower() + text_tokens = _tokenize(text) + scored: list[tuple[int, str]] = [] + for skill, tokens, skill_name_lower in skill_meta: + score = 0 + if skill_name_lower in text: + score += 6 + score += len(tokens & text_tokens) + if score > 0: + scored.append((score, skill.name)) + scored.sort(key=lambda x: (-x[0], x[1])) + for _, skill_name in scored[:4]: + edges.append((mem_id, skill_name)) + return edges + + +def _skill_roots() -> list[tuple[str, Path]]: + repo = Path(__file__).resolve().parent.parent + home_skills = get_hermes_home() / "skills" + return [("base", repo / "skills"), ("profile", home_skills)] + + +def build_learning_graph() -> dict[str, Any]: + """Full payload for the desktop learning panel. + + Focus on what is profile-learned and actionable: + - skills that are NOT base-installed and show real learning signal + (agent-created or used), + - memory chunks as first-class graph nodes connected to those learned skills. + """ + all_skills = build_skill_nodes(_skill_roots()) + learned_skills = { + name: node + for name, node in all_skills.items() + if node.source != "base" and (node.created_by == "agent" or node.use_count > 0) + } + skill_edges = build_edges(learned_skills) + memory_cards = _memory_cards() + memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values())) + + edges = skill_edges + memory_edges + clusters: dict[str, int] = {} + for node in learned_skills.values(): + clusters[node.category] = clusters.get(node.category, 0) + 1 + if memory_cards: + clusters["memory"] = len(memory_cards) + + graph_nodes = [ + { + "id": n.name, + "label": n.name, + "kind": "skill", + "timestamp": n.timestamp, + "category": n.category, + "useCount": n.use_count, + "state": n.state, + "createdBy": n.created_by, + "pinned": n.pinned, + } + for n in learned_skills.values() + ] + for i, card in enumerate(memory_cards): + graph_nodes.append( + { + "id": f"memory:{card['source']}:{i}", + "label": card["title"], + "kind": "memory", + "memorySource": card["source"], + "timestamp": card.get("timestamp"), + "category": "memory", + "useCount": 0, + "state": "active", + "createdBy": "memory", + "pinned": False, + } + ) + + return { + "nodes": graph_nodes, + "edges": [{"source": a, "target": b} for a, b in edges], + "clusters": [ + {"category": c, "count": n} + for c, n in sorted(clusters.items(), key=lambda kv: -kv[1]) + ], + "memory": memory_cards, + "stats": { + **density_stats(learned_skills, skill_edges), + "memory_nodes": len(memory_cards), + "memory_skill_edges": len(memory_edges), + "learned_skills": len(learned_skills), + }, + } + + +if __name__ == "__main__": + nodes = build_skill_nodes(_skill_roots()) + print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2)) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4bf4eaade96..6fd35c0fbc4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,8 +81,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "dnd-core": "^14.0.1", "dompurify": "^3.4.11", + "fflate": "^0.8.3", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", @@ -118,6 +120,7 @@ "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", + "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", "@types/node": "^24.13.2", "@types/react": "^19.2.14", diff --git a/apps/desktop/scripts/.gitignore b/apps/desktop/scripts/.gitignore new file mode 100644 index 00000000000..646f02ffc79 --- /dev/null +++ b/apps/desktop/scripts/.gitignore @@ -0,0 +1 @@ +share-codes.txt diff --git a/apps/desktop/scripts/gen-share-codes.ts b/apps/desktop/scripts/gen-share-codes.ts new file mode 100644 index 00000000000..8de54582461 --- /dev/null +++ b/apps/desktop/scripts/gen-share-codes.ts @@ -0,0 +1,171 @@ +// Throwaway generator: deterministic fake star-map graphs → real share codes +// (runs the actual encoder, so every string round-trips). Run with `npx tsx`. +import { writeFileSync } from 'node:fs' + +import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes' + +import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code' + +const DAY = 86_400 +const END = Math.floor(Date.UTC(2026, 5, 29) / 1000) + +// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs. +const rng = (seed: number) => () => { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296 +} + +const pick = (arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]! + +const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const +const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const +const CREATED = [null, 'agent', 'agent', 'user'] as const + +const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({ + category: pick(CATEGORIES, r()), + createdBy: pick(CREATED, r()), + id, + kind: 'skill', + label, + pinned: r() > 0.85, + state: pick(STATES, r()), + timestamp: ts, + useCount: Math.floor(r() ** 3 * 120) +}) + +const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({ + category: 'memory', + createdBy: 'memory', + id: `memory:${source}:${i}`, + kind: 'memory', + label, + memorySource: source, + pinned: false, + state: 'active', + timestamp: ts, + useCount: 0 +}) + +const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title }) + +// ── 1. Tiny + quirky ────────────────────────────────────────────────────────── +function tiny(): StarmapGraph { + const r = rng(7) + const nodes: StarmapNode[] = [ + skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r), + skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r), + skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r), + memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY), + memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY) + ] + const edges: StarmapEdge[] = [ + { source: 'memory:memory:1', target: 'git-blame-zen' }, + { source: 'rubber-duck', target: 'git-blame-zen' } + ] + const memory = [ + card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY), + card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY) + ] + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 2. Mid-size, mixed signal ──────────────────────────────────────────────── +function mid(): StarmapGraph { + const r = rng(42) + const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking'] + const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r)) + const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode'] + + memTitles.forEach((title, i) => { + const ts = END - Math.floor(r() * 120) * DAY + nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts)) + }) + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 9; i += 1) { + edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` }) + } + + const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY)) + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 3. Dense web, partly undated (ordinal fallback) ────────────────────────── +function web(): StarmapGraph { + const r = rng(1337) + const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) => + // Half the skills carry no timestamp → exercises the ordinal recency path. + skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r) + ) + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 44; i += 1) { + edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` }) + } + + return { clusters: [], edges, memory: [], nodes, stats: {} } +} + +// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ─────────────── +function beast(): StarmapGraph { + const r = rng(2024) + const start = END - 730 * DAY + const span = END - start + const nodes: StarmapNode[] = [] + const memory: StarmapMemoryCard[] = [] + + // Bursts → an interesting waveform instead of a flat smear. + const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span) + + for (let i = 0; i < 240; i += 1) { + const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end + nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r)) + } + + for (let i = 0; i < 150; i += 1) { + const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12) + const source = r() > 0.5 ? 'memory' : 'profile' + nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts)) + memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts)) + } + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 380; i += 1) { + const a = Math.floor(r() * 240) + const b = Math.floor(r() * 240) + + if (a !== b) { + edges.push({ source: `b${a}`, target: `b${b}` }) + } + } + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +const graphs: [string, StarmapGraph][] = [ + ['tiny + quirky', tiny()], + ['mid · mixed signal', mid()], + ['dense web · half undated', web()], + ['the beast · ~2 years', beast()] +] + +const lines: string[] = [] + +for (const [name, g] of graphs) { + const code = encodeShareCode(g) + const back = decodeShareCode(code) // round-trip assert — throws if invalid + // v2 is viz-only: nodes + edge topology survive; memory prose is dropped. + const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length + console.log(`${ok ? 'ok ' : 'BAD'} ${name} — ${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`) + lines.push(`# ${name} — ${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '') +} + +writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n')) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index bfda963204c..ec1c79566dc 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -36,6 +36,7 @@ import { RefreshCw, Settings, Settings2, + Starmap, Sun, Terminal, Users, @@ -68,7 +69,8 @@ import { PROFILES_ROUTE, sessionRoute, SETTINGS_ROUTE, - SKILLS_ROUTE + SKILLS_ROUTE, + STARMAP_ROUTE } from '../routes' import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' @@ -383,7 +385,14 @@ export function CommandPalette() { run: go(CRON_ROUTE) }, { action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) }, - { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) } + { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }, + { + icon: Starmap, + id: 'nav-starmap', + keywords: ['star map', 'memory', 'memories', 'skills', 'graph', 'learning', 'constellation'], + label: t.starmap.title, + run: go(STARMAP_ROUTE) + } ] }, ...branchGroup, diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ed3ea225aeb..07d57ac49cc 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -134,6 +134,7 @@ const AgentsView = lazy(async () => ({ default: (await import('./agents')).Agent const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) @@ -192,6 +193,7 @@ export function DesktopController() { openCommandCenterSection, profilesOpen, settingsOpen, + starmapOpen, toggleCommandCenter } = useOverlayRouting() @@ -994,6 +996,12 @@ export function DesktopController() { )} + + {starmapOpen && ( + + + + )} ) diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 2b655fccc8d..66ab264e474 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -8,6 +8,7 @@ export const ARTIFACTS_ROUTE = '/artifacts' export const CRON_ROUTE = '/cron' export const PROFILES_ROUTE = '/profiles' export const AGENTS_ROUTE = '/agents' +export const STARMAP_ROUTE = '/starmap' export type AppView = | 'agents' @@ -19,6 +20,7 @@ export type AppView = | 'profiles' | 'settings' | 'skills' + | 'starmap' export type AppRouteId = | 'agents' @@ -30,6 +32,7 @@ export type AppRouteId = | 'profiles' | 'settings' | 'skills' + | 'starmap' export interface AppRoute { id: AppRouteId @@ -46,7 +49,8 @@ export const APP_ROUTES = [ { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, { id: 'cron', path: CRON_ROUTE, view: 'cron' }, { id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' }, - { id: 'agents', path: AGENTS_ROUTE, view: 'agents' } + { id: 'agents', path: AGENTS_ROUTE, view: 'agents' }, + { id: 'starmap', path: STARMAP_ROUTE, view: 'starmap' } ] as const satisfies readonly AppRoute[] const APP_VIEW_BY_PATH = new Map(APP_ROUTES.map(route => [route.path, route.view])) @@ -55,7 +59,14 @@ const RESERVED_PATHS: ReadonlySet = new Set(APP_ROUTES.map(route => rout // Views that render as a full-screen modal card (OverlayView) over the shell. // While one is open the app's titlebar control clusters must hide so they don't // bleed over the overlay (they sit at a higher z-index than the overlay card). -export const OVERLAY_VIEWS: ReadonlySet = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings']) +export const OVERLAY_VIEWS: ReadonlySet = new Set([ + 'agents', + 'command-center', + 'cron', + 'profiles', + 'settings', + 'starmap' +]) export function isOverlayView(view: AppView): boolean { return OVERLAY_VIEWS.has(view) diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts index d4b0d2130f5..01873b08dd6 100644 --- a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { type CommandCenterSection } from '@/app/command-center' -import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes' +import { + AGENTS_ROUTE, + appViewForPath, + COMMAND_CENTER_ROUTE, + isOverlayView, + NEW_CHAT_ROUTE, + STARMAP_ROUTE +} from '@/app/routes' const SECTIONS = ['sessions', 'system', 'usage'] as const @@ -14,6 +21,7 @@ export function useOverlayRouting() { const settingsOpen = currentView === 'settings' const commandCenterOpen = currentView === 'command-center' const agentsOpen = currentView === 'agents' + const starmapOpen = currentView === 'starmap' const cronOpen = currentView === 'cron' const profilesOpen = currentView === 'profiles' const chatOpen = currentView === 'chat' @@ -53,6 +61,7 @@ export function useOverlayRouting() { }, [closeOverlayToPreviousRoute, commandCenterOpen, navigate]) const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate]) + const openStarmap = useCallback(() => navigate(STARMAP_ROUTE), [navigate]) return { agentsOpen, @@ -64,8 +73,10 @@ export function useOverlayRouting() { currentView, openAgents, openCommandCenterSection, + openStarmap, profilesOpen, settingsOpen, + starmapOpen, toggleCommandCenter } } diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index c58483ad095..753c3893bbd 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' -import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' +import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' @@ -369,11 +369,7 @@ export function useStatusbarItems({ menuAlign: 'end', menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0', menuContent: ( - + ), title: copy.openContextUsage, variant: 'menu' diff --git a/apps/desktop/src/app/starmap/color.ts b/apps/desktop/src/app/starmap/color.ts new file mode 100644 index 00000000000..0d5e4448071 --- /dev/null +++ b/apps/desktop/src/app/starmap/color.ts @@ -0,0 +1,126 @@ +import { BLACK, MODE_DEFAULTS } from './constants' +import { clamp } from './geometry' +import type { Palette, Rgb } from './types' + +// Theme tokens come through `color-mix()`/oklch, so getComputedStyle returns a +// non-rgb() string. Rasterize through a 1x1 canvas to get real sRGB bytes — +// naive string parsing of oklab()/color(srgb …) silently yields black. +let _probe: CanvasRenderingContext2D | null = null + +export function resolveRgb(color: string): Rgb { + if (!_probe) { + const c = document.createElement('canvas') + c.width = 1 + c.height = 1 + _probe = c.getContext('2d', { willReadFrequently: true }) + } + + if (!_probe) { + return { b: 184, g: 163, r: 148 } + } + + _probe.clearRect(0, 0, 1, 1) + _probe.fillStyle = '#888888' + _probe.fillStyle = color + _probe.fillRect(0, 0, 1, 1) + const d = _probe.getImageData(0, 0, 1, 1).data + + return { b: d[2], g: d[1], r: d[0] } +} + +export function rgba(c: Rgb, a: number): string { + return `rgba(${c.r},${c.g},${c.b},${a})` +} + +export function mixRgb(a: Rgb, b: Rgb, t: number): Rgb { + const p = clamp(t, 0, 1) + + return { + b: Math.round(a.b + (b.b - a.b) * p), + g: Math.round(a.g + (b.g - a.g) * p), + r: Math.round(a.r + (b.r - a.r) * p) + } +} + +export function darken(c: Rgb, amount: number): Rgb { + return mixRgb(c, BLACK, amount) +} + +export function luminance(r: number, g: number, b: number): number { + return (0.2126 * r + 0.7152 * g + 0.114 * b) / 255 +} + +function rgbToHsl(c: Rgb): [number, number, number] { + const r = c.r / 255 + const g = c.g / 255 + const b = c.b / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + const d = max - min + let h = 0 + let s = 0 + + if (d) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + } + + return [h, s, l] +} + +function hslToRgb(h: number, s: number, l: number): Rgb { + const hue = ((h % 360) + 360) % 360 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)) + const m = l - c / 2 + + const [r, g, b] = + hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x] + + return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) } +} + +// Complementary ink: rotate the source hue (the theme primary) and keep it vivid +// so memories read as a distinct color from skills, in any theme. +function complementaryInk(c: Rgb): Rgb { + const [h, s, l] = rgbToHsl(c) + + return hslToRgb(h + 165, Math.max(s, 0.5), clamp(l, 0.5, 0.7)) +} + +// Memory ink: the complementary hue muted toward the overlay background so it +// reads as a distinct-but-quiet color (fake alpha), not a loud full-sat pop. +export function memoryInkFor(primary: Rgb, bg: Rgb): Rgb { + return mixRgb(complementaryInk(primary), bg, 0.45) +} + +// Resolve the theme-derived palette once per theme change — the resolveRgb probe +// does a getImageData readback, so this stays out of the per-frame path. Node +// groups borrow restrained tint from the theme; structure stays foreground ink. +export function computePalette(canvas: HTMLCanvasElement): Palette { + const style = getComputedStyle(canvas) + const fg = resolveRgb(style.color) + const darkTheme = luminance(fg.r, fg.g, fg.b) > 0.55 + const base: Rgb = darkTheme ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 } + const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color) + + const bg = resolveRgb( + style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || (darkTheme ? '#000' : '#fff') + ) + + return { + // Band tint derives from the theme primary so rings read consistently in + // both modes (foreground ink would go white on dark / black on light). + bandInk: mixRgb(primary, base, darkTheme ? 0.3 : 0), + base, + bg, + c: MODE_DEFAULTS[darkTheme ? 'dark' : 'light'], + chipBg: darkTheme ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.85)', + darkTheme, + inkInv: darkTheme ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)', + memoryInk: memoryInkFor(primary, bg), + primary, + skillInk: mixRgb(primary, base, darkTheme ? 0.12 : 0.18) + } +} diff --git a/apps/desktop/src/app/starmap/constants.ts b/apps/desktop/src/app/starmap/constants.ts new file mode 100644 index 00000000000..02f44ab4986 --- /dev/null +++ b/apps/desktop/src/app/starmap/constants.ts @@ -0,0 +1,62 @@ +import type { StarmapNode } from '@/types/hermes' + +import type { GraphParams, Rgb, RingParams, Shape } from './types' + +// ── Disk geometry ──────────────────────────────────────────────────────────── +export const RING_INNER = 58 +export const RING_OUTER = 340 +export const ZOOM_MIN = 0.3 +export const ZOOM_MAX = 5 +export const FIT_PADDING = 80 +export const TILT = 1 // vertical squash → "looking down at a tilted disk" +export const RING_STEPS = 4 + +export const WHITE: Rgb = { b: 255, g: 255, r: 255 } +export const BLACK: Rgb = { b: 0, g: 0, r: 0 } + +// Fixed recency (age) gradient — old content quiet, recent content bright. +export const AGE_GRADIENT = { mid: 0.52, midInk: 0.74, newInk: 0.95, oldInk: 0.42, reach: 1 } + +// Node glyph per kind — pure path geometry (the seam a future sprite/instanced +// renderer would bake from). +export const NODE_SHAPE: Record = { memory: 'diamond', skill: 'circle' } + +// Darken the orb body so a bright primary doesn't swallow the sheen (the +// highlight is computed from the original ink, so it still reads). +export const ORB_DARKEN = 0.3 + +// Sheen forced this high when the orb ink is near-white (a white body needs a +// pure-white core to read as a sphere at all). +export const WHITEISH_SHEEN = 0.95 + +// Flat wash alpha for a lit (hovered/selected) date's band. The focused ring +// outline derives from this (×2). +export const LIT_BAND_ALPHA = 0.04 + +export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = { + dark: { + lineAlpha: 0.12, + lineDash: 1.5, + lineDashed: true, + lineWidth: 0.5, + ringAlpha: 0.1, + ringDash: 4, + ringDashed: false, + ringWidth: 1.5 + }, + light: { + lineAlpha: 0.18, + lineDash: 1.5, + lineDashed: true, + lineWidth: 0.5, + ringAlpha: 0.06, + ringDash: 4, + ringDashed: false, + ringWidth: 2 + } +} + +export const RING_PARAMS: Record<'dark' | 'light', RingParams> = { + dark: { bandAlpha: 0.01, lightSize: 0.64, ringAlpha: 0.03, sheen: 0.12 }, + light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.028, sheen: 0.1 } +} diff --git a/apps/desktop/src/app/starmap/geometry.ts b/apps/desktop/src/app/starmap/geometry.ts new file mode 100644 index 00000000000..c4395e66b0c --- /dev/null +++ b/apps/desktop/src/app/starmap/geometry.ts @@ -0,0 +1,132 @@ +import type { StarmapNode } from '@/types/hermes' + +import { AGE_GRADIENT, FIT_PADDING, RING_INNER, RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' +import type { Ring, Shape, Viewport } from './types' + +export function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)) +} + +// FNV-1a — stable per-id seed for layout angle / starfield. +export function hash(input: string): number { + let h = 2166136261 + + for (let i = 0; i < input.length; i += 1) { + h ^= input.charCodeAt(i) + h = Math.imul(h, 16777619) + } + + return h >>> 0 +} + +export function nodeRadius(n: StarmapNode): number { + if (n.kind === 'memory') { + return 4.4 + } + + const base = n.state === 'archived' || n.state === 'stale' ? 2.4 : 3 + + return base + Math.sqrt(Math.max(0, n.useCount)) * 0.55 + (n.pinned ? 0.8 : 0) +} + +// Smoothstep recency → ink alpha along the age gradient. +export function recencyInk(rec: number): number { + const reach = Math.max(0.01, AGE_GRADIENT.reach) + const mid = clamp(AGE_GRADIENT.mid, 0.01, 0.99) + const t = clamp(rec / reach, 0, 1) + + if (t <= mid) { + const p = t / mid + + return AGE_GRADIENT.oldInk + (AGE_GRADIENT.midInk - AGE_GRADIENT.oldInk) * (p * p * (3 - 2 * p)) + } + + const p = (t - mid) / (1 - mid) + + return AGE_GRADIENT.midInk + (AGE_GRADIENT.newInk - AGE_GRADIENT.midInk) * (p * p * (3 - 2 * p)) +} + +// Trace a centred geometric shape of radius r into the current path. +export function shapePath(ctx: CanvasRenderingContext2D, shape: Shape, x: number, y: number, r: number): void { + ctx.beginPath() + + if (shape === 'square') { + ctx.rect(x - r, y - r, r * 2, r * 2) + + return + } + + if (shape === 'circle') { + ctx.arc(x, y, r, 0, Math.PI * 2) + + return + } + + const pts = shape === 'diamond' ? 4 : shape === 'triangle' ? 3 : 6 + // Diamond/triangle point up; hexagon is flat-topped. + const rot = shape === 'hexagon' ? Math.PI / 6 : -Math.PI / 2 + + for (let i = 0; i < pts; i += 1) { + const a = rot + (i / pts) * Math.PI * 2 + const px = x + Math.cos(a) * r + const py = y + Math.sin(a) * r + + if (i === 0) { + ctx.moveTo(px, py) + } else { + ctx.lineTo(px, py) + } + } + + ctx.closePath() +} + +// Center the tilted disk in the viewport at a fit zoom. `outer` is the radius to +// fit (defaults to the full disk); the scrubber passes the revealed extent so the +// camera tightens at the core and zooms out as the rings grow. +export function fitViewport(w: number, h: number, outer: number = RING_OUTER): Viewport { + if (w <= 0 || h <= 0) { + return { k: 1, x: w / 2, y: h / 2 } + } + + // Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in). + const kFor = (r: number): number => { + const spanX = (r + 30) * 2 + + return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2) + } + + // Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map + // renders at that constant scale and overflows — you pan it — instead of + // shrinking every node to fit. Smaller extents (few rings, or the playback + // core) still fit tightly / zoom in. + const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX) + + // Bias the center down a touch — the timeline along the top adds visual weight + // up there, so true-center reads as sitting high. + return { k, x: w / 2, y: h / 2 + h * 0.05 } +} + +// Target radius for a node at recency `rec` (oldest at the core), scaled to a +// disk of the given outer radius. +export function radiusForRecency(rec: number, outer: number = RING_OUTER): number { + return RING_INNER + rec * (outer - RING_INNER) +} + +// Screen-space scale at the graph's fully-rested fit. Nodes size against THIS, +// not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how +// big they read (billboarded), while a full-map view keeps its honest density. +export const fitScale = (w: number, h: number, rings: Ring[]): number => + fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k + +// Squared distance from point (px,py) to segment a→b — for cheap link hit-tests. +export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax + const dy = by - ay + const len = dx * dx + dy * dy + const t = len ? clamp(((px - ax) * dx + (py - ay) * dy) / len, 0, 1) : 0 + const cx = ax + dx * t + const cy = ay + dy * t + + return (px - cx) ** 2 + (py - cy) ** 2 +} diff --git a/apps/desktop/src/app/starmap/index.tsx b/apps/desktop/src/app/starmap/index.tsx new file mode 100644 index 00000000000..7603006d8f8 --- /dev/null +++ b/apps/desktop/src/app/starmap/index.tsx @@ -0,0 +1,53 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { useI18n } from '@/i18n' +import { $starmapError, $starmapGraph, $starmapLoading, loadStarmapGraph } from '@/store/starmap' +import type { StarmapGraph } from '@/types/hermes' + +import { Panel, PanelEmpty } from '../overlays/panel' + +import { StarMap } from './star-map' + +// Star map overlay: a top-down map of what Hermes has learned for a profile, +// over a radial time axis. Data is fetched on demand into the $starmap* atoms; +// the map itself lives in ./star-map. The chrome is owned by the map itself +// (timeline scrubber + legend float over the canvas), so there's no panel +// header here. +export function StarmapView({ onClose }: { onClose: () => void }) { + const { t } = useI18n() + const graph = useStore($starmapGraph) + const loading = useStore($starmapLoading) + const error = useStore($starmapError) + + // A pasted share code populates the map with someone else's (or an exported) + // graph, overriding the live profile scan. Cleared by "back to my map" and + // whenever a fresh profile graph loads in. + const [imported, setImported] = useState(null) + + useEffect(() => { + void loadStarmapGraph() + }, []) + + // Drop a stale import when the underlying profile graph changes out from under it. + useEffect(() => { + setImported(null) + }, [graph]) + + const shown = imported ?? graph + + return ( + + {error ? ( + + ) : !shown && loading ? ( + + ) : shown && shown.nodes.length === 0 && !imported ? ( + + ) : shown ? ( + setImported(null)} /> + ) : null} + + ) +} diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts new file mode 100644 index 00000000000..12e26eab171 --- /dev/null +++ b/apps/desktop/src/app/starmap/render.ts @@ -0,0 +1,864 @@ +import { darken, luminance, mixRgb, rgba } from './color' +import { + LIT_BAND_ALPHA, + NODE_SHAPE, + ORB_DARKEN, + RING_INNER, + RING_PARAMS, + TILT, + WHITE, + WHITEISH_SHEEN +} from './constants' +import { clamp, fitScale, nodeRadius, recencyInk, shapePath } from './geometry' +import { countLabel, ellipsize, metaBadges, nodeFooter, wrapText } from './text' +import type { + FadeBuckets, + MemoryCard, + Palette, + Rect, + Rgb, + Ring, + RingLabelRect, + SimLink, + SimNode, + Viewport +} from './types' + +export interface Scene { + adjacency: Map> + byId: Map + ctx: CanvasRenderingContext2D + dpr: number + fades: FadeBuckets + focusId: null | string + hoverId: null | string + hoverLink: null | string + hoverRing: null | number + links: SimLink[] + memById: Map + nodes: SimNode[] + palette: Palette + // Time scrubber: only paint nodes/links whose recency has been reached. 1 = + // everything (the default, idle state); lower values "build up" the map. + reveal: number + rings: Ring[] + selectedRing: null | number + size: { h: number; w: number } + // Scrub jumps: snap every ease to its target this frame (no birth/fade replay). + snapMotion?: boolean + vp: Viewport +} + +export interface DrawResult { + animating: boolean + ringLabelRects: RingLabelRect[] +} + +// Smoothstep — eases the birth animations (position grow-out) in and out. +const ease = (t: number): number => { + const u = t < 0 ? 0 : t > 1 ? 1 : t + + return u * u * (3 - 2 * u) +} + +// EVE-style warp arrival for node births: the star streaks outward fast, then +// decelerates hard (exponential ease-out) and drops onto its ring — like a ship +// dropping out of warp. WARP_FROM is how deep toward the core it launches from. +const WARP_FROM = 0.32 + +const warpIn = (t: number): number => { + const u = t < 0 ? 0 : t > 1 ? 1 : t + + return u >= 1 ? 1 : 1 - 2 ** (-9 * u) +} + +// Layered birth speeds for the scrubber's parallax: rings expand slowly and +// grandly in the background, stars pop in quicker up front — both well below the +// default hover/focus speeds so the build-up reads as a cinematic settle. +const RING_BIRTH = { down: 0.055, up: 0.032 } +const NODE_BIRTH = { down: 0.11, up: 0.075 } + +// Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus +// a few digits/symbols for the "digital rain / decoding" look. +const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' + +// Sphere-sprite atlas: a lit orb is the same picture at every size, so we render +// each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and +// blit it (scaled) per node — instead of allocating a fresh radial gradient for +// every star on every frame. Keyed by appearance, not size; drawImage scales it. +// Reference radius the sprite is rendered at — larger than the usual billboarded +// screen-space orb, so sprites scale down in normal use and stay crisp. +const SPRITE_R = 96 + +const spriteCache = new Map() + +// Build (or fetch) the orb sprite for one appearance: an offset radial gradient +// from a hot core → darkened body → translucent rim, clipped to the disk, so a +// flat circle reads with volume. `strength` is how white the core is; `bodyDarken` +// darkens the body (0 for active/hover nodes so they pop full bright). Near-white +// inks skip the darken and force a near-full sheen so the white core still reads. +function sphereSprite(ink: Rgb, strength: number, bodyDarken: number): HTMLCanvasElement { + const key = `${ink.r},${ink.g},${ink.b}|${strength}|${bodyDarken}` + const cached = spriteCache.get(key) + + if (cached) { + return cached + } + + const R = SPRITE_R + // Margin for the gradient's rim (extends to 1.15·R) so it isn't clipped. + const pad = Math.ceil(R * 0.15) + 1 + const size = (R + pad) * 2 + const c = R + pad + const cv = document.createElement('canvas') + cv.width = size + cv.height = size + const g2 = cv.getContext('2d') + + if (!g2) { + return cv + } + + const mx = Math.max(ink.r, ink.g, ink.b) + const mn = Math.min(ink.r, ink.g, ink.b) + const sat = mx ? (mx - mn) / mx : 0 + const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat) + const eff = strength + (WHITEISH_SHEEN - strength) * whiteness + const hi = mixRgb(ink, WHITE, 0.7 * eff) + const body = darken(ink, bodyDarken * (1 - whiteness)) + const grad = g2.createRadialGradient(c - R * 0.35, c - R * 0.4, R * 0.05, c, c, R * 1.15) + grad.addColorStop(0, rgba(hi, 1)) + grad.addColorStop(0.5, rgba(body, 1)) + grad.addColorStop(1, rgba(body, 0.85)) + g2.fillStyle = grad + g2.beginPath() + g2.arc(c, c, R, 0, Math.PI * 2) + g2.fill() + spriteCache.set(key, cv) + + return cv +} + +// Paint a lit orb of radius `r` centered at (x, y) by blitting its cached sprite. +// Honors the caller's globalAlpha (drawImage multiplies it), matching the old +// gradient fill. No path needed — the sprite already carries the disk + AA rim. +function sphereFill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + ink: Rgb, + strength: number, + bodyDarken: number +): void { + const sprite = sphereSprite(ink, strength, bodyDarken) + const scale = r / SPRITE_R + const drawSize = sprite.width * scale + ctx.drawImage(sprite, x - drawSize / 2, y - drawSize / 2, drawSize, drawSize) +} + +const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + +// Paint a full frame of the star map. Pure given its inputs (draws to the +// canvas + advances the fade buckets); returns whether it's still animating and +// the ring-label hit rects for pointer picking. +export function drawScene(scene: Scene): DrawResult { + const { + adjacency, + byId, + ctx, + dpr, + fades, + focusId, + hoverId, + hoverLink, + hoverRing, + links, + memById, + nodes, + palette, + reveal, + rings, + selectedRing, + size, + snapMotion = false, + vp + } = scene + + // Small epsilon so a node exactly at the playhead counts as revealed. + const seen = (rec: number) => rec <= reveal + 1e-3 + // Recency for styling is RELATIVE to the newest revealed node — the current + // "present" — not the bare playhead. So a lone frontier node still reads as + // fresh (bright/full size) even with empty space between it and the scrubber. + // At reveal = 1 the frontier is the newest node, collapsing back to raw recency. + let frontier = 0 + + for (const fn of nodes) { + if (fn.rec <= reveal + 1e-3 && fn.rec > frontier) { + frontier = fn.rec + } + } + + const erec = (rec: number) => (frontier > 0 ? clamp(rec / frontier, 0, 1) : 1) + const { h, w } = size + const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, skillInk } = palette + const { bandAlpha, lightSize, ringAlpha, sheen } = RING_PARAMS[darkTheme ? 'dark' : 'light'] + + let animating = false + const ringLabelRects: RingLabelRect[] = [] + + // Eased opacity per element: snaps up when newly highlighted, eases otherwise. + // `rates` overrides the default in/out lerp speed (the slow births pass their + // own gentler pair so the build-up reads as a graceful settle, not a flash). + const fadeAlpha = ( + bucket: Map, + key: string, + target: number, + snapUp = false, + rates?: { down: number; up: number } + ) => { + const targetAlpha = clamp(target, 0, 1) + const prev = bucket.get(key) + + // Scrub: jump straight to the target so a fast drag doesn't replay easing. + if (snapMotion) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + if (prev == null || (snapUp && targetAlpha > prev)) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + const up = rates?.up ?? 0.22 + const down = rates?.down ?? 0.32 + const rate = targetAlpha > prev ? up : down + const next = prev + (targetAlpha - prev) * rate + + if (Math.abs(next - targetAlpha) < 0.01) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + animating = true + bucket.set(key, next) + + return next + } + + const shade = (a: number) => `rgba(${base.r},${base.g},${base.b},${a})` + const projX = (wx: number) => wx * vp.k + vp.x + const projY = (wy: number) => wy * vp.k * TILT + vp.y + // Baseline node scale: the rested fit, held stable while the playback camera + // dives into the core — so t≈0 nodes don't balloon (see fitScale). + const nodeK = fitScale(w, h, rings) + + // Two composable layers: node highlight (selected ?? hovered) in full ink, and + // a selection-only ring/date filter that only shifts alpha. + const focusSet = focusId ? (adjacency.get(focusId) ?? new Set()) : null + const ringIdx = selectedRing + const ring = ringIdx != null ? (rings[ringIdx] ?? null) : null + // A selected ring owns the band it caps: previous ring → this ring. Ring 0 is + // visual-only/unlabeled, so the first selectable date naturally owns shell 0→1. + const ringLo = ring && ringIdx != null ? (rings[ringIdx - 1]?.ratio ?? 0) - 1e-3 : 0 + const ringHi = ring ? ring.ratio + 1e-3 : 1 + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, w, h) + + ctx.globalAlpha = 1 + + // Tilted world transform for the disk structure. + ctx.setTransform(vp.k * dpr, 0, 0, vp.k * TILT * dpr, vp.x * dpr, vp.y * dpr) + + // The "lit" date = hovered (preview) or selected (locked) — drives the band + // flatten only; the ring outline reacts to selection. + const litRingIdx = hoverRing ?? ringIdx + + // A ring is "laid" one band AHEAD of the playhead — it appears the moment the + // scrubber enters the band beneath it (its inner neighbor's date), so the date + // gridline that caps a region is always drawn before any node in that region. + // Ring 0 is just the visual core; the first real shell still needs non-zero + // playback progress so replay starts empty instead of showing it pre-laid. + const ringSeen = (i: number) => { + const threshold = rings[i - 1]?.ratio ?? 0 + + return i === 0 || (threshold <= 0 ? reveal > 1e-3 : reveal + 1e-3 >= threshold) + } + + // Per-ring "grow out" progress (advanced once per frame, reused by bands / + // outlines / labels): a revealed ring eases its radius from its inner neighbor + // outward to its resting radius, so it expands into place instead of popping. + const ringAppear = rings.map((rg, i) => + ease(fadeAlpha(fades.appear, `ring:${i}`, ringSeen(i) ? 1 : 0, false, RING_BIRTH)) + ) + + // Direction-based origin (the sign of the reveal): a ring growing IN expands + // outward from its inner neighbour — never from the dead centre — while a ring + // fading OUT collapses all the way to the core. ringSeen is the direction tell: + // true = revealing/at rest, false = receding. + const ringDrawR = rings.map((rg, i) => { + const startR = ringSeen(i) ? (rings[i - 1]?.r ?? rg.r) : RING_INNER + + return startR + (rg.r - startR) * (ringAppear[i] ?? 1) + }) + + // Opacity envelope that stays near-full through most of the grow/shrink and + // only fades in the final stretch — so the radius TRAVEL is visible (the ring + // shrinks back into place) instead of just dimming out where it stands. + const ringVis = ringAppear.map(a => clamp(a / 0.55, 0, 1)) + + // Inter-ring bands: a theme-tinted wash sliver at the outer edge; the lit + // date's band flattens to an even wash. + if (bandAlpha > 0 || litRingIdx != null) { + for (let i = 0; i < rings.length - 1; i += 1) { + const lit = litRingIdx != null && i + 1 === litRingIdx + + if (!lit && bandAlpha <= 0) { + continue + } + + // The band tracks its outer ring's grow-in. + if ((ringAppear[i + 1] ?? 1) <= 0.01) { + continue + } + + const inner = ringDrawR[i] ?? 0 + const outer = ringDrawR[i + 1] ?? 0 + + if (lit) { + ctx.fillStyle = rgba(bandInk, LIT_BAND_ALPHA) + } else { + const grad = ctx.createRadialGradient(0, 0, inner, 0, 0, outer) + + if (darkTheme) { + // Dark: a light wash on each band's OUTER rim — reads as light catching + // a raised edge → depth. + grad.addColorStop(0, rgba(bandInk, 0)) + grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, bandAlpha)) + } else { + // Light: flip it — the (darker) wash sits on the INNER edge and fades + // outward, so each shell reads as recessed toward the core (depth), + // not a raised mound. + grad.addColorStop(0, rgba(bandInk, bandAlpha)) + grad.addColorStop(clamp(lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, 0)) + } + + ctx.fillStyle = grad + } + + ctx.beginPath() + ctx.arc(0, 0, outer, 0, Math.PI * 2) + ctx.arc(0, 0, inner, 0, Math.PI * 2, true) + ctx.fill() + } + } + + // Ring outline: brightens only on selection — the selected ring + its inner + // neighbor (the two bounding the lit band). + ctx.lineWidth = c.ringWidth / vp.k + ctx.setLineDash(c.ringDashed ? [c.ringDash / vp.k, c.ringDash / vp.k] : []) + rings.forEach((rg, i) => { + const emphasized = ringIdx != null && (i === ringIdx || i === ringIdx - 1) + // Reveal in/out rides the smooth (slow) ringAppear envelope so a ring fades + // out as gracefully as it grew in; the alpha bucket only carries the snappy + // selection emphasis. + const emphasisAlpha = emphasized ? clamp(LIT_BAND_ALPHA * 2, 0, 1) : ringAlpha + // The core ring (i 0) fades in from reveal 0 so the scramble orb starts + // un-enclosed (no outline boxing it in) and the shell appears as it plays. + const coreFade = i === 0 ? clamp(reveal / 0.08, 0, 1) : 1 + const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) * coreFade + + if (ringAlphaNow < 0.004) { + return + } + + ctx.strokeStyle = shade(ringAlphaNow) + ctx.beginPath() + ctx.arc(0, 0, ringDrawR[i] ?? rg.r, 0, Math.PI * 2) + ctx.stroke() + }) + ctx.setLineDash([]) + + // Screen space for the jump routes and glyphs (crisp, easy to trim). The empty + // core's animated scramble is NOT painted here — it's the only perpetually + // moving layer, so it's drawn live each frame by drawScramble() on top of the + // (cached) static scene. Everything else in this function is static until an + // input changes, which is why `animating` now reflects only in-flight fades. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + // Jump routes — a focused node's links stop at its selection ring. + const focusNode = focusId ? (byId.get(focusId) ?? null) : null + const focusRingR = focusNode ? nodeRadius(focusNode) * nodeK + 4 : 0 + + for (const link of links) { + const s = typeof link.source === 'object' ? link.source : byId.get(String(link.source)) + const t = typeof link.target === 'object' ? link.target : byId.get(String(link.target)) + + if (!s || !t) { + continue + } + + // A jump route only exists once both of its endpoints have ignited. + const revealed = seen(s.rec) && seen(t.rec) + + const lit = + revealed && + !!focusId && + (s.id === focusId || t.id === focusId || (!!focusSet && focusSet.has(s.id) && focusSet.has(t.id))) + + let x1 = projX(s.x) + let y1 = projY(s.y) + let x2 = projX(t.x) + let y2 = projY(t.y) + + if (s.id === focusId) { + const d = Math.hypot(x2 - x1, y2 - y1) || 1 + x1 += ((x2 - x1) / d) * focusRingR + y1 += ((y2 - y1) / d) * focusRingR + } + + if (t.id === focusId) { + const d = Math.hypot(x1 - x2, y1 - y2) || 1 + x2 += ((x1 - x2) / d) * focusRingR + y2 += ((y1 - y2) / d) * focusRingR + } + + const key = `${s.id}->${t.id}` + const ambient = recencyInk(erec((s.rec + t.rec) / 2)) * c.lineAlpha + + // Hovering a line fades it in a bit (×2, capped — never full white). + const targetAlpha = !revealed + ? 0 + : lit + ? 1 + : key === hoverLink + ? clamp(ambient * 2, 0, 0.7) + : focusId || ring + ? 0.025 + : ambient + + const linkAlpha = fadeAlpha(fades.links, key, targetAlpha, lit) + + if (linkAlpha < 0.004) { + continue + } + + ctx.strokeStyle = shade(linkAlpha) + ctx.setLineDash(lit || !c.lineDashed ? [] : [c.lineDash, c.lineDash]) + ctx.lineWidth = lit ? 1.5 : c.lineWidth + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } + + ctx.setLineDash([]) + + // Nodes: the node layer paints pure ink (focused node + neighbors); the date + // filter is alpha-only, so the two states compose. Track which rings have at + // least one revealed node so a ring's date only shows once it has content. + const revealedRings = new Set() + + for (const n of nodes) { + // The land comes first: a node waits for the ring that CAPS its region (its + // outer date gridline) to grow in before it ignites — so the ring is always + // drawn before any star inside it, not after. + const landLaid = (ringAppear[n.outerRingIndex] ?? 1) >= 0.5 + const revealed = seen(n.rec) && landLaid + + if (revealed) { + revealedRings.add(n.outerRingIndex) + } + + const isFocus = revealed && n.id === focusId + const isNeighbor = revealed && !!focusSet && focusSet.has(n.id) + const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi + const nodeHigh = isFocus || isNeighbor + const er = erec(n.rec) + const ageScale = nodeHigh || inRing ? 1 : 0.34 + Math.min(1, er / 0.4) * 0.66 + // Stable screen-space radius: use the graph's resting fit zoom, not the + // current playback camera zoom. Full-map views keep their original density, + // while t≈0 spore-zoom no longer inflates nodes into bubbles. + const r = nodeRadius(n) * nodeK * ageScale + + const baseAlpha = nodeHigh ? 1 : ring ? (inRing ? (focusId ? 0.55 : 1) : 0.16) : focusId ? 0.16 : recencyInk(er) + const alpha = fadeAlpha(fades.nodes, n.id, revealed ? baseAlpha : 0, nodeHigh || inRing) + + // Birth fade + warp rise are coupled (slow rates) so a star grows in instead + // of flashing. Focus snaps (no drift). + const rawBorn = fadeAlpha(fades.appear, n.id, revealed ? 1 : 0, nodeHigh || inRing, NODE_BIRTH) + const born = ease(rawBorn) + const vis = alpha * born + + if (vis < 0.004) { + continue + } + + // Warp-in: streak outward from WARP_FROM·radius and decelerate hard onto the + // ring (origin = disk core), echoing an EVE ship dropping out of warp. + const posScale = WARP_FROM + (1 - WARP_FROM) * warpIn(rawBorn) + const sx = projX(n.x * posScale) + const sy = projY(n.y * posScale) + + ctx.globalAlpha = vis + const nodeInk = nodeHigh ? base : n.kind === 'memory' ? memoryInk : skillInk + const shape = NODE_SHAPE[n.kind] + + if (shape === 'circle') { + // Highlighted orbs pop full bright; others darken so the sheen reads. The + // sprite carries the disk, so no path is built for circles. + sphereFill(ctx, sx, sy, r, nodeInk, sheen, nodeHigh ? 0 : ORB_DARKEN) + } else { + shapePath(ctx, shape, sx, sy, r) + ctx.fillStyle = rgba(nodeInk, 1) + ctx.fill() + } + + if (isFocus) { + ctx.globalAlpha = 1 + ctx.strokeStyle = rgba(nodeInk, 1) + ctx.lineWidth = 1.4 + shapePath(ctx, shape, sx, sy, r + 4) + ctx.stroke() + } + } + + ctx.globalAlpha = 1 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + // Ring date labels (top of each ellipse) — hoverable to focus the ring. Many + // adaptive rings can crowd the top, so labels thin out: skip any that would + // land within LABEL_GAP of the last one drawn (the gridline still shows). + ctx.font = '10px ui-sans-serif, system-ui, sans-serif' + ctx.textAlign = 'center' + const LABEL_GAP = 15 + let lastLabelY = Number.POSITIVE_INFINITY + // A ring's date only shows once it actually has a revealed node — no floating + // date over a blank disk (t=0) or a lone empty ring. + rings.forEach((rg, i) => { + if (!rg.label || !revealedRings.has(i)) { + return + } + + const sx = projX(0) + // Track the growing radius so the date rides the ring as it expands out. + const sy = projY(-(ringDrawR[i] ?? rg.r)) + + if (sy < 8 || sy > h - 8 || lastLabelY - sy < LABEL_GAP) { + return + } + + lastLabelY = sy + const tw = ctx.measureText(rg.label).width + const boxW = tw + 6 + const isThis = ringIdx === i || hoverRing === i + const faded = (focusId != null || ringIdx != null) && !isThis + // The date rides the same smooth ringAppear envelope, so it recedes as + // gently as it appears; the bucket carries only the snappy focus/selection dim. + const emphasisAlpha = faded ? 0.33 : 1 + const labelAlpha = fadeAlpha(fades.labels, String(i), emphasisAlpha, isThis) * (ringVis[i] ?? 1) + + if (labelAlpha < 0.01) { + return + } + + ctx.globalAlpha = labelAlpha + ctx.fillStyle = rgba(bg, 1) + ctx.fillRect(sx - boxW / 2, sy - 6, boxW, 13) + ctx.fillStyle = shade(isThis ? 1 : 0.2) + ctx.fillText(rg.label, sx, sy + 3) + ctx.globalAlpha = 1 + // Hidden labels (mid fade-out / not yet reached) drop out of hit-testing. + ringLabelRects.push({ h: 18, i, w: boxW + 6, x: sx - boxW / 2 - 3, y: sy - 10 }) + }) + + // Tooltip on focus — measured first so its rect joins the avoidance set and + // neighbor labels route around it. + const tipNode = focusId ? byId.get(focusId) : null + const tip = tipNode && seen(tipNode.rec) ? tipNode : null + let tipRect: null | Rect = null + + if (tip) { + const PADX = 6 + const PADY = 4 + const BADGE_H = 14 + const ROW_GAP = 3 + const LINE_H = 16 + const ITEM_GAP = 8 + const badgeFont = '9px ui-sans-serif, system-ui, sans-serif' + const monoFont = '9px ui-monospace, SFMono-Regular, Menlo, monospace' + const titleFont = '600 11px ui-sans-serif, system-ui, sans-serif' + const footerFont = '9px ui-sans-serif, system-ui, sans-serif' + const FOOTER_H = 13 + // The date (index 0) stays sans; the rest of the tags are monospace. + const badgeFontFor = (i: number) => (i === 0 ? badgeFont : monoFont) + + const badges = metaBadges(tip) + const use = countLabel(tip) + const titleText = tip.kind === 'memory' ? memById.get(tip.id)?.body.split('\n')[0]?.trim() || tip.label : tip.label + + const badgeW = badges.map((b, i) => { + ctx.font = badgeFontFor(i) + + return ctx.measureText(b).width + }) + + const rowW = badgeW.reduce((a, b) => a + b, 0) + ITEM_GAP * Math.max(0, badges.length - 1) + ctx.font = monoFont + const useW = use ? ctx.measureText(use).width : 0 + const metaW = rowW + (use ? ITEM_GAP + useW : 0) + + ctx.font = titleFont + const maxTitleW = Math.min(380, w - 16) - PADX * 2 + const titleLines = wrapText(ctx, titleText, maxTitleW) + const titleW = Math.max(0, ...titleLines.map(l => ctx.measureText(l).width)) + const titleBgW = titleW + PADX * 2 + const titleBgH = titleLines.length * LINE_H + PADY * 2 + + const footerText = nodeFooter(tip) + ctx.font = footerFont + const footerW = footerText ? ctx.measureText(footerText).width : 0 + + const totalW = Math.max(metaW, footerW, titleBgW) + const totalH = BADGE_H + ROW_GAP + titleBgH + (footerText ? ROW_GAP + FOOTER_H : 0) + const bx = clamp(projX(tip.x) - totalW / 2, 4, Math.max(4, w - totalW - 4)) + const by = clamp(projY(tip.y) - (nodeRadius(tip) * nodeK + 8) - totalH, 4, Math.max(4, h - totalH - 4)) + tipRect = { h: totalH, w: totalW, x: bx, y: by } + + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + const badgeMidY = by + BADGE_H / 2 + + // Metadata row, flush at the left edge. + ctx.fillStyle = shade(0.7) + let cx = bx + badges.forEach((label, i) => { + ctx.font = badgeFontFor(i) + ctx.fillText(label, cx, badgeMidY) + cx += badgeW[i] + ITEM_GAP + }) + + if (use) { + ctx.font = monoFont + ctx.fillStyle = shade(0.5) + ctx.fillText(use, cx, badgeMidY) + } + + // Title: inverted (fg/bg flipped) so the focused tooltip pops. + const ty = by + BADGE_H + ROW_GAP + ctx.fillStyle = shade(1) + ctx.fillRect(bx, ty, titleBgW, titleBgH) + ctx.font = titleFont + ctx.fillStyle = inkInv + titleLines.forEach((line, i) => { + ctx.fillText(line, bx + PADX, ty + PADY + LINE_H * i + LINE_H / 2) + }) + + if (footerText) { + ctx.font = footerFont + ctx.fillStyle = shade(0.45) + ctx.fillText(footerText, bx, ty + titleBgH + ROW_GAP + FOOTER_H / 2) + } + + ctx.textBaseline = 'alphabetic' + } + + // Neighbor constellation labels — greedy placement that clamps to the overlay + // and dodges placed labels (date labels + tooltip) so nothing overlaps/clips. + ctx.font = '11px ui-sans-serif, system-ui, sans-serif' + ctx.textAlign = 'center' + const LBL_M = 6 + const LBL_H = 15 + const placed: Rect[] = ringLabelRects.map(r => ({ h: r.h, w: r.w, x: r.x, y: r.y })) + + if (tipRect) { + placed.push(tipRect) + } + + for (const id of focusSet ?? []) { + if (id === hoverId) { + continue + } + + const n = byId.get(id) + + if (!n || !seen(n.rec)) { + continue + } + + const label = ellipsize(ctx, n.label, Math.min(180, w * 0.32)) + const bw = ctx.measureText(label).width + 8 + const x = clamp(projX(n.x) - bw / 2, LBL_M, Math.max(LBL_M, w - bw - LBL_M)) + const top = projY(n.y) - (nodeRadius(n) * nodeK + 7) - LBL_H + 4 + const clampY = (v: number) => clamp(v, LBL_M, Math.max(LBL_M, h - LBL_H - LBL_M)) + const step = LBL_H + 3 + let y: null | number = null + + // Prefer above the node, then fan outward; skip if nothing stays clear (a + // label on the tooltip reads worse than no label). + for (let k = 0; k <= 7 && y == null; k += 1) { + for (const dy of k === 0 ? [0] : [-k * step, k * step]) { + const cand = { h: LBL_H, w: bw, x, y: clampY(top + dy) } + + if (!placed.some(p => rectsOverlap(cand, p))) { + y = cand.y + + break + } + } + } + + if (y == null) { + continue + } + + placed.push({ h: LBL_H, w: bw, x, y }) + ctx.fillStyle = chipBg + ctx.fillRect(x, y, bw, LBL_H) + ctx.fillStyle = shade(0.85) + ctx.fillText(label, x + bw / 2, y + 11) + } + + return { animating, ringLabelRects } +} + +// Glyph cells from the core's center to its rim — the target density. In the mid +// range the field is this many cells across (constant "amount of text"), and the +// glyph size tracks the camera. Bump for denser, drop for sparser. +const SCRAMBLE_RADIUS = 6 + +// Glyph size (px) is clamped to this band: the font grows with the camera but +// never balloons on a big/zoomed-in core — past the ceiling the core fills with +// MORE, smaller glyphs instead of fewer huge ones — and stays legible when tiny. +const SCRAMBLE_CELL_MIN = 5 +const SCRAMBLE_CELL_MAX = 13 + +// The empty-core scramble: a tilted, Matrix-style decoding-glyph field laid on +// the disk plane (rows squashed by TILT, clipped to the core ellipse) so the +// empty center reads as "computing", not missing. PURELY decorative — the glyphs +// are a seeded PRNG field, never derived from nodes/memories. Drawn live each +// frame on top of the cached static scene, since it's the only animated layer. +export function drawScramble({ + ctx, + dpr, + palette, + rings, + vp +}: { + ctx: CanvasRenderingContext2D + dpr: number + palette: Palette + rings: Ring[] + vp: Viewport +}): void { + const { bg, darkTheme, primary } = palette + const projX = (wx: number) => wx * vp.k + vp.x + const projY = (wy: number) => wy * vp.k * TILT + vp.y + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const coreX = projX(0) + const coreY = projY(0) + // Scale with the world (like the rings), but ~1.25× bigger than the bare inner + // shell so the core reads prominently at the rested fit. + const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25 + + if (coreRx <= 0) { + return + } + + // Backdrop wash: a background-colour radial dimming the core ellipse, so on a + // busy map the nodes/links crowding through the centre recede behind the orb. + // Self-masking — bg over empty bg is invisible, so a sparse map shows no disc. + const washR = coreRx * 1.15 + ctx.save() + ctx.translate(coreX, coreY) + ctx.scale(1, TILT) + const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR) + // Near-opaque across the core (busy graph effectively vanishes behind the orb) + // with a soft falloff only at the rim so there's no hard disc edge. + wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93)) + wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88)) + wash.addColorStop(1, rgba(bg, 0)) + ctx.fillStyle = wash + ctx.beginPath() + ctx.arc(0, 0, washR, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + + // Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the + // glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more, + // smaller glyphs rather than a few giant ones — and stays legible when tiny. + const cell = clamp(coreRx / SCRAMBLE_RADIUS, SCRAMBLE_CELL_MIN, SCRAMBLE_CELL_MAX) + // Aspect-correct on the tilt: rows are spaced by the full glyph height (square + // cells, no vertical squish), but the field is clipped to the disk's ELLIPSE + // (vertical extent = coreRx * TILT), so it sits on the tilted plane while the + // glyphs themselves stay un-squished. Fewer rows fit vertically — that's it. + const coreRy = coreRx * TILT + const half = Math.max(3, Math.round(coreRx / cell)) + const now = performance.now() + const t = now / 1000 // seconds, for the travelling-glow highlight + + ctx.save() + ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + for (let r = -half; r <= half; r += 1) { + // Per-row flow: half the rows drift left, half right, each at its own speed. + // The drift is a continuous pixel scroll (not a per-cell swap), and each + // glyph's identity is tied to its slot index — so a character visibly slides + // across instead of the whole row flickering in place. Combined with the + // TILT squash + opposite directions, the field reads as a turning surface. + const rowSeed = (r * 19349663) >>> 0 || 1 + const dir = rowSeed & 1 ? 1 : -1 + const speed = 8 + (rowSeed % 16) // px/sec + const scroll = (now / 1000) * speed * dir + const ny = (r * cell) / coreRy + // Latitude dimming: rows away from the equator fade, selling the sphere read. + const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny)) + const kMin = Math.floor((-coreRx - scroll) / cell) - 1 + const kMax = Math.ceil((coreRx - scroll) / cell) + 1 + + for (let k = kMin; k <= kMax; k += 1) { + const sx = k * cell + scroll // screen-space x relative to the core center + const nx = sx / coreRx + const d2 = nx * nx + ny * ny + + if (d2 > 1) { + continue + } + + const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0 + const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0' + // Mostly flat brightness, fading only near the rim (reduced gradient). + const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1) + const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100) + // Travelling glow: two crossing sine waves (drifting in time) light a + // lattice of bright spots that ripple ACROSS the orb — so the highlight + // moves and twinkles instead of being a fixed random set. A per-glyph + // phase keeps neighbours from pulsing in lockstep. + const phase = (seed & 7) * 0.35 + const glow = Math.sin(nx * 4.5 + t * 1.3 + phase) * Math.sin(ny * 4.5 - t * 0.9 + phase) + const pop = 1 + clamp((glow - 0.25) / 0.75, 0, 1) * 2.6 + const a = clamp((darkTheme ? 0.22 : 0.3) * edge * flick * rowDim * pop, 0, 0.9) + + if (a < 0.02) { + continue + } + + ctx.fillStyle = rgba(primary, a) + ctx.fillText(ch, coreX + sx, coreY + r * cell) + } + } + + ctx.restore() + ctx.globalAlpha = 1 +} diff --git a/apps/desktop/src/app/starmap/share-code.test.ts b/apps/desktop/src/app/starmap/share-code.test.ts new file mode 100644 index 00000000000..a011292d8de --- /dev/null +++ b/apps/desktop/src/app/starmap/share-code.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import type { StarmapGraph } from '@/types/hermes' + +import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' + +function sampleGraph(): StarmapGraph { + return { + clusters: [], + edges: [ + { source: 'skill-a', target: 'skill-b' }, + { source: 'skill-b', target: 'memory:profile:0' } + ], + memory: [ + { body: 'Prefers concise answers.', source: 'profile', timestamp: 1_700_000_000, title: 'Tone' }, + { body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' } + ], + nodes: [ + { category: 'devops', createdBy: 'agent', id: 'skill-a', kind: 'skill', label: 'skill-a', pinned: true, state: 'active', timestamp: 1_699_900_000, useCount: 7 }, + { category: 'devops', createdBy: null, id: 'skill-b', kind: 'skill', label: 'skill-b', pinned: false, state: 'draft', timestamp: 1_699_950_000, useCount: 0 }, + { category: 'memory', createdBy: null, id: 'memory:profile:0', kind: 'memory', label: 'A fact', memorySource: 'profile', pinned: false, state: 'active', timestamp: 1_700_000_000, useCount: 0 } + ], + stats: {} + } +} + +// Decoded edges compared by node POSITION (ids are synthesized), so topology is +// the invariant, not the literal id strings. +const topology = (g: StarmapGraph): [number, number][] => { + const idx = new Map(g.nodes.map((n, i) => [n.id, i])) + + return g.edges.map(e => [idx.get(e.source)!, idx.get(e.target)!]) +} + +describe('share-code', () => { + // The viz contract: everything the star map RENDERS survives — kinds, radius + // inputs, time position, edge topology — while text is dropped (it's a loadout, + // not a backup). + it('preserves the visualization', () => { + const g = sampleGraph() + const decoded = decodeShareCode(encodeShareCode(g)) + const span = 1_700_000_000 - 1_699_900_000 + const tol = Math.ceil(span / 4095) + 1 + + expect(decoded.nodes).toHaveLength(g.nodes.length) + + decoded.nodes.forEach((d, i) => { + const o = g.nodes[i]! + expect(d.kind).toBe(o.kind) + expect(d.label).toBe(o.label) + expect(d.useCount).toBe(o.useCount) + expect(d.state).toBe(o.state) + expect(d.pinned).toBe(o.pinned) + expect(d.category).toBe(o.category) + expect(d.memorySource).toBe(o.memorySource) + expect(d.createdBy).toBe(o.createdBy) + + if (o.timestamp == null) { + expect(d.timestamp).toBeNull() + } else { + expect(Math.abs((d.timestamp ?? 0) - o.timestamp)).toBeLessThanOrEqual(tol) + } + }) + + expect(topology(decoded)).toEqual(topology(g)) + }) + + it('drops memory prose (loadout is viz-only)', () => { + expect(decodeShareCode(encodeShareCode(sampleGraph())).memory).toHaveLength(0) + }) + + it('rebuilds clusters from node categories', () => { + const decoded = decodeShareCode(encodeShareCode(sampleGraph())) + + expect(decoded.clusters.find(c => c.category === 'devops')?.count).toBe(2) + }) + + it('produces a short, opaque, prefixed code', () => { + const code = encodeShareCode(sampleGraph()) + + expect(code.startsWith('HML')).toBe(true) + expect(code.slice(3)).toMatch(/^[A-Za-z0-9_-]+$/) + // Strictly smaller than the naive JSON it replaces — the whole point. + expect(code.length).toBeLessThan(JSON.stringify(sampleGraph()).length) + }) + + it('stays compact on a large graph (no string bloat)', () => { + const nodes = Array.from({ length: 500 }, (_, i) => ({ + category: `cat-${i % 8}`, + createdBy: 'agent' as const, + id: `s${i}`, + kind: 'skill' as const, + label: `A fairly verbose skill label number ${i}`, + pinned: false, + state: 'active', + timestamp: 1_700_000_000 + i * 3600, + useCount: i % 50 + })) + + const graph: StarmapGraph = { clusters: [], edges: [], memory: [], nodes, stats: {} } + const code = encodeShareCode(graph) + + // Deflate keeps even verbose, repetitive labels far below the naive JSON. + expect(code.length).toBeLessThan(JSON.stringify(graph).length / 5) + }) + + it('handles an empty graph', () => { + const decoded = decodeShareCode(encodeShareCode({ clusters: [], edges: [], memory: [], nodes: [], stats: {} })) + + expect(decoded.nodes).toHaveLength(0) + expect(decoded.edges).toHaveLength(0) + }) + + it('drops edges whose endpoints are missing', () => { + const g = sampleGraph() + g.edges.push({ source: 'skill-a', target: 'does-not-exist' }) + + expect(decodeShareCode(encodeShareCode(g)).edges).toHaveLength(2) + }) + + it('rejects garbage with a ShareCodeError', () => { + expect(() => decodeShareCode('not a real code !!!')).toThrow(ShareCodeError) + expect(() => decodeShareCode('')).toThrow(ShareCodeError) + }) + + it('rejects a corrupted (bit-flipped) code', () => { + const code = encodeShareCode(sampleGraph()) + // Flip a mid-payload char (trailing base64 bits can be dropped on decode). + const i = Math.floor(code.length / 2) + const corrupted = code.slice(0, i) + (code[i] === 'A' ? 'B' : 'A') + code.slice(i + 1) + + expect(() => decodeShareCode(corrupted)).toThrow(ShareCodeError) + }) + + it('tolerates whitespace, including internal wraps', () => { + const code = encodeShareCode(sampleGraph()) + const wrapped = ` ${code.slice(0, 20)}\n${code.slice(20)}\t` + + expect(() => decodeShareCode(wrapped)).not.toThrow() + expect(decodeShareCode(wrapped).nodes).toHaveLength(sampleGraph().nodes.length) + }) +}) diff --git a/apps/desktop/src/app/starmap/share-code.ts b/apps/desktop/src/app/starmap/share-code.ts new file mode 100644 index 00000000000..6e1e0d70ac1 --- /dev/null +++ b/apps/desktop/src/app/starmap/share-code.ts @@ -0,0 +1,186 @@ +import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout' +import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes' + +// ── Star-map share code ─────────────────────────────────────────────────────── +// +// The body schema for a star map, riding the generic loadout codec (@/lib/loadout +// owns the bitstream, DEFLATE, version+checksum frame, and base64url). We encode +// what the map RENDERS — each node's kind, its time POSITION (12-bit quantized, +// not an absolute epoch), radius inputs (useCount/state/pinned), and an interned +// label + category — plus edges as fixed-width node indices. Memory prose is +// dropped; labels are trimmed. DEFLATE then makes the repetitive label/category +// text almost free. A 60-skill map is a few hundred chars. + +const VERSION = 3 +const PREFIX = 'HML' // "Hermes Memory Loadout" — namespaces our codes like WoW's leading bytes. +const MAX_LABEL = 64 // trim runaway memory titles so one card can't bloat the code. + +const trim = (s: string): string => (s.length > MAX_LABEL ? s.slice(0, MAX_LABEL) : s) + +const KINDS = ['skill', 'memory'] as const +const STATES = ['active', 'archived', 'disabled', 'draft'] as const +const MEM_SOURCES = ['none', 'memory', 'profile'] as const +const CREATED_BY = ['none', 'agent', 'user'] as const + +const REC_BITS = 12 // time position resolution: 1/4096 of the span — sub-pixel here. +const REC_MAX = (1 << REC_BITS) - 1 + +const finiteTs = (v?: null | number): null | number => + typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : null + +function writeNode(w: BitWriter, n: StarmapNode, dict: Dict, minTs: number, span: number): void { + w.uint(idxOf(KINDS, n.kind), 1) + w.varint(dict.id(trim(n.label || ''))) + w.varint(dict.id(n.category || '')) + w.varint(Math.max(0, n.useCount | 0)) + w.uint(idxOf(STATES, n.state), 2) + w.uint(idxOf(MEM_SOURCES, n.memorySource ?? 'none'), 2) + w.uint(idxOf(CREATED_BY, n.createdBy ?? 'none'), 2) + w.bit(n.pinned) + + // Time as a 12-bit POSITION within [minTs, maxTs] — not an absolute epoch. + const ts = finiteTs(n.timestamp) + + if (ts === null) { + w.bit(0) + } else { + w.bit(1) + w.uint(span > 0 ? Math.round(((ts - minTs) / span) * REC_MAX) : 0, REC_BITS) + } +} + +function readNode(r: BitReader, dict: string[], i: number, minTs: number, span: number): StarmapNode { + const kind = KINDS[r.uint(1)] ?? 'skill' + const label = dict[r.varint()] ?? '' + const category = dict[r.varint()] ?? '' + const useCount = r.varint() + const state = STATES[r.uint(2)] ?? 'active' + const memSrc = MEM_SOURCES[r.uint(2)] ?? 'none' + const createdBy = CREATED_BY[r.uint(2)] ?? 'none' + const pinned = r.bit() === 1 + const timestamp = r.bit() === 1 ? minTs + (span > 0 ? Math.round((r.uint(REC_BITS) / REC_MAX) * span) : 0) : null + + // Ids are synthesized (they're never displayed); memory ids mirror the scan's + // `memory::` shape so the rest of the UI is none the wiser. + const isMemory = kind === 'memory' + const source = memSrc === 'none' ? 'memory' : memSrc + + return { + category, + createdBy: createdBy === 'none' ? null : createdBy, + id: isMemory ? `memory:${source}:${i}` : `s${i}`, + kind, + label, + memorySource: isMemory ? source : undefined, + pinned, + state, + timestamp, + useCount + } +} + +function writeGraph(w: BitWriter, graph: StarmapGraph): void { + const dict = new Dict() + + // Intern labels + categories; deflate later squeezes the inevitable repetition. + for (const n of graph.nodes) { + dict.id(trim(n.label || '')) + dict.id(n.category || '') + } + + const stamps = graph.nodes.map(n => finiteTs(n.timestamp)).filter((v): v is number => v !== null) + const minTs = stamps.length ? Math.min(...stamps) : 0 + const maxTs = stamps.length ? Math.max(...stamps) : 0 + const span = maxTs - minTs + + w.varint(minTs) + w.varint(maxTs) + w.varint(dict.list.length) + + for (const s of dict.list) { + w.str(s) + } + + w.varint(graph.nodes.length) + + for (const n of graph.nodes) { + writeNode(w, n, dict, minTs, span) + } + + // Edges reference nodes by position; drop any whose endpoints aren't both nodes. + const order = new Map(graph.nodes.map((n, i) => [n.id, i])) + const edges = graph.edges.filter(e => order.has(e.source) && order.has(e.target)) + const bits = indexBits(graph.nodes.length) + w.varint(edges.length) + + for (const e of edges) { + w.uint(order.get(e.source)!, bits) + w.uint(order.get(e.target)!, bits) + } +} + +function readGraph(r: BitReader): StarmapGraph { + const minTs = r.varint() + const maxTs = r.varint() + const span = maxTs - minTs + + const dictLen = r.varint() + const dict: string[] = [] + + for (let i = 0; i < dictLen; i += 1) { + dict.push(r.str()) + } + + const nodeCount = r.varint() + const nodes: StarmapNode[] = [] + + for (let i = 0; i < nodeCount; i += 1) { + nodes.push(readNode(r, dict, i, minTs, span)) + } + + const bits = indexBits(nodeCount) + const edgeCount = r.varint() + const edges: StarmapEdge[] = [] + + for (let i = 0; i < edgeCount; i += 1) { + const src = nodes[r.uint(bits)] + const dst = nodes[r.uint(bits)] + + if (src && dst) { + edges.push({ source: src.id, target: dst.id }) + } + } + + const counts = new Map() + + for (const n of nodes) { + counts.set(n.category, (counts.get(n.category) ?? 0) + 1) + } + + const clusters = [...counts.entries()].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count) + + // Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map + // apart from a freshly-scanned one. + return { clusters, edges, memory: [], nodes, stats: { imported: true } } +} + +export class ShareCodeError extends LoadoutError {} + +const codec = createLoadout({ + error: ShareCodeError, + noun: 'map code', + prefix: PREFIX, + read: readGraph, + version: VERSION, + write: writeGraph +}) + +// Serialize a star-map graph to a short, opaque, clipboard-safe loadout string. +export function encodeShareCode(graph: StarmapGraph): string { + return codec.encode(graph) +} + +// Parse a loadout string back into a (viz-complete, text-synthesized) graph. +export function decodeShareCode(code: string): StarmapGraph { + return codec.decode(code) +} diff --git a/apps/desktop/src/app/starmap/share-controls.tsx b/apps/desktop/src/app/starmap/share-controls.tsx new file mode 100644 index 00000000000..45c9bcc0f1b --- /dev/null +++ b/apps/desktop/src/app/starmap/share-controls.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { CopyButton } from '@/components/ui/copy-button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { useI18n } from '@/i18n' +import { Upload } from '@/lib/icons' + +interface ShareControlsProps { + // True when the shown map was loaded from a pasted code (not the live scan). + imported?: boolean + // Decode + apply a pasted code. Returns an error string to show inline, or null. + onImport?: (code: string) => null | string + onResetMap?: () => void + // The current map serialized as a WoW-style share code (the copy target). + shareCode?: string +} + +// Share / import a map as a single code. The textarea shows the current map's +// code (copy it to share); edit/replace it and hit Load to view someone else's. +// One field, one button — a standard Dialog matching rename/create. +export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) { + const { t } = useI18n() + const [open, setOpen] = useState(false) + const [value, setValue] = useState('') + const [error, setError] = useState(null) + + const own = (shareCode ?? '').trim() + const code = value.trim() + const canLoad = code !== '' && code !== own + + const load = () => { + if (!code) { + setError(t.starmap.importEmpty) + + return + } + + const err = onImport?.(code) ?? null + setError(err) + + if (err === null) { + setOpen(false) + } + } + + return ( +
+ {imported && ( + + )} + + { + setOpen(next) + setError(null) + + if (next) { + setValue(shareCode ?? '') + } + }} + open={open} + > + + + + + + + {t.starmap.shareTitle} + {t.starmap.shareHint} + + + {/* One code field: pre-filled with this map's code (copy to share); edit + or paste another and Load. Copy button floats in on hover, like a + thread code block. */} +
+