Merge pull request #55226 from NousResearch/bb/desktop-memory-graph

feat(desktop): memory graph — playable timeline of memories + skills over time
This commit is contained in:
brooklyn! 2026-06-30 04:36:17 -05:00 committed by GitHub
commit 1d495cfbbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 4825 additions and 35 deletions

320
agent/learning_graph.py Normal file
View file

@ -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/<category>/<skill>/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))

View file

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

1
apps/desktop/scripts/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
share-codes.txt

View file

@ -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 = <T>(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'))

View file

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

View file

@ -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() {
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{starmapOpen && (
<Suspense fallback={null}>
<StarmapView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)

View file

@ -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<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
@ -55,7 +59,14 @@ const RESERVED_PATHS: ReadonlySet<string> = 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<AppView> = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings'])
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings',
'starmap'
])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)

View file

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

View file

@ -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: (
<ContextUsagePanel
currentUsage={currentUsage}
requestGateway={requestGateway}
sessionId={activeSessionId}
/>
<ContextUsagePanel currentUsage={currentUsage} requestGateway={requestGateway} sessionId={activeSessionId} />
),
title: copy.openContextUsage,
variant: 'menu'

View file

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

View file

@ -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<StarmapNode['kind'], Shape> = { 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 }
}

View file

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

View file

@ -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<StarmapGraph | null>(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 (
<Panel closeLabel={t.starmap.close} onClose={onClose}>
{error ? (
<PanelEmpty description={error} icon="warning" title={t.starmap.loadFailed} />
) : !shown && loading ? (
<PageLoader aria-label={t.starmap.loading} className="min-h-0 flex-1" />
) : shown && shown.nodes.length === 0 && !imported ? (
<PanelEmpty description={t.starmap.emptyDesc} icon="lightbulb" title={t.starmap.emptyTitle} />
) : shown ? (
<StarMap graph={shown} imported={imported !== null} onImport={setImported} onResetMap={() => setImported(null)} />
) : null}
</Panel>
)
}

View file

@ -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<string, Set<string>>
byId: Map<string, SimNode>
ctx: CanvasRenderingContext2D
dpr: number
fades: FadeBuckets
focusId: null | string
hoverId: null | string
hoverLink: null | string
hoverRing: null | number
links: SimLink[]
memById: Map<string, MemoryCard>
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<string, HTMLCanvasElement>()
// 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<string, number>,
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<string>()) : 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<number>()
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
}

View file

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

View file

@ -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:<source>:<index>` 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<string, number>()
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<StarmapGraph>({
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)
}

View file

@ -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 | string>(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 (
<div className="flex items-center gap-1">
{imported && (
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onResetMap?.()}
size="xs"
variant="text"
>
{t.starmap.resetToMine}
</Button>
)}
<Dialog
onOpenChange={next => {
setOpen(next)
setError(null)
if (next) {
setValue(shareCode ?? '')
}
}}
open={open}
>
<DialogTrigger asChild>
<Button
aria-label={t.starmap.shareTitle}
className="text-muted-foreground hover:text-foreground"
size="icon"
title={t.starmap.shareTitle}
variant="ghost"
>
<Upload className="size-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t.starmap.shareTitle}</DialogTitle>
<DialogDescription>{t.starmap.shareHint}</DialogDescription>
</DialogHeader>
{/* 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. */}
<div className="group/code relative">
<textarea
aria-label={t.starmap.shareTitle}
className="h-24 w-full resize-none rounded-md bg-foreground/5 p-2.5 pr-9 font-mono text-xs leading-relaxed break-all text-muted-foreground/90 outline-none transition placeholder:text-muted-foreground/50 focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-ring/40"
onChange={e => {
setValue(e.target.value)
setError(null)
}}
placeholder={t.starmap.sharePlaceholder}
spellCheck={false}
value={value}
/>
{code !== '' && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 h-5 gap-0 rounded-md px-1 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/code:opacity-100 hover:opacity-100"
iconClassName="size-3"
label={t.starmap.copy}
showLabel={false}
text={value}
/>
)}
</div>
{error && <p className="text-[0.7rem] text-destructive">{error}</p>}
<Button className="w-full" disabled={!canLoad} onClick={load} type="button">
{t.starmap.importBtn}
</Button>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,281 @@
import { forceCollide, forceLink, forceManyBody, forceRadial, forceSimulation, type Simulation } from 'd3-force'
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
import { RING_STEPS } from './constants'
import { clamp, hash, nodeRadius, radiusForRecency } from './geometry'
import { formatDate } from './text'
import { computeRecency, recForRatio } from './time-axis'
import type { Ring, SimLink, SimNode } from './types'
export interface BuiltSim {
byId: Map<string, SimNode>
links: SimLink[]
nodes: SimNode[]
rings: Ring[]
sim: Simulation<SimNode, SimLink>
}
const DAY = 86_400
// Roughly how many nodes share one ignite burst within a ring band — the build
// reads as clustered pops, not a 1-by-1 trickle or an all-at-once flood.
const CLUSTER_SIZE = 5
// Constant ring SCALE: the core radius and the per-ring band are pinned to the
// canonical 5-ring layout, so the empty core and every band are ALWAYS that
// size on the disk — more data grows the disk OUTWARD (more rings) instead of
// stretching a fixed disk thinner. The camera caps its zoom at the 5-ring
// extent (see fitViewport), so this world size is also a constant screen size.
const RING_CORE = radiusForRecency(recForRatio(0))
const RING_BAND = (radiusForRecency(recForRatio(1)) - RING_CORE) / RING_STEPS
const ringRadius = (i: number): number => RING_CORE + i * RING_BAND
// Place a node INSIDE its ring's band (the annulus the ring caps), biased toward
// mid-band so it reads as "within the ring", only occasionally grazing an edge —
// never sitting on the outline like a bead.
const placeRadius = (i: number, id: string): number => {
const outer = ringRadius(i)
const inner = i > 0 ? ringRadius(i - 1) : RING_CORE - RING_BAND * 0.5
const h = (hash(id) % 1000) / 1000
return outer - (0.15 + 0.7 * h) * (outer - inner)
}
interface Unit {
kind: 'day' | 'month'
step: number
}
// "Nice" calendar intervals, fine → coarse, WITH intermediate rungs (2-day,
// bi-weekly, 2/3/6-month) so the bucketer can land NEAR the target count instead
// of jumping straight from weekly (≈9) to monthly (≈3) and missing the ~5 sweet spot.
const UNITS: Unit[] = [
{ kind: 'day', step: 1 },
{ kind: 'day', step: 2 },
{ kind: 'day', step: 7 },
{ kind: 'day', step: 14 },
{ kind: 'month', step: 1 },
{ kind: 'month', step: 2 },
{ kind: 'month', step: 3 },
{ kind: 'month', step: 6 },
{ kind: 'month', step: 12 }
]
// Floor a timestamp to the start of its calendar bucket — the key nodes group by.
function bucketStart(ts: number, { kind, step }: Unit): number {
if (kind === 'day') {
const period = step * DAY
return Math.floor(ts / period) * period
}
const d = new Date(ts * 1000)
d.setUTCHours(0, 0, 0, 0)
// Floor to a step-month boundary in ABSOLUTE months so steps align across
// years (3-month → Jan/Apr/Jul/Oct, 12-month → Jan).
const absMonth = Math.floor((d.getUTCFullYear() * 12 + d.getUTCMonth()) / step) * step
d.setUTCFullYear(Math.floor(absMonth / 12), absMonth % 12, 1)
return Math.floor(d.getTime() / 1000)
}
const populatedStarts = (stamps: number[], u: Unit): number[] => [...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b)
// "Nice ticks" for time (à la D3/Heckbert): aim for a target ring count that
// grows ~log2 with the span, then snap to the calendar interval whose POPULATED
// count lands nearest it (ties + overshoot break toward fewer/finer). The floor
// is 5 — fewer than that and the play-through "steps" between rings get big and
// abrupt; ~5+ evenly-paced rings give the smooth Spore-style build-up.
function chooseUnit(stamps: number[], spanDays: number): Unit {
const target = clamp(Math.round(4 + Math.log2(Math.max(1, spanDays / 60))), 5, 12)
let best = UNITS[0]!
let bestScore = Infinity
for (const u of UNITS) {
const count = populatedStarts(stamps, u).length
if (!count) {
continue
}
const score = Math.abs(count - target) + (count > target ? 0.5 : 0)
if (score < bestScore) {
bestScore = score
best = u
}
}
return best
}
function bucketLabel(ts: number, { kind, step }: Unit): string {
if (kind === 'day') {
return formatDate(ts)
}
try {
const d = new Date(ts * 1000)
return step >= 12 ? String(d.getUTCFullYear()) : d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' })
} catch {
return formatDate(ts)
}
}
interface Layout {
// bucket/cap ring a node belongs to (the ring it ignites behind)
index: (n: StarmapNode) => number
// reveal coordinate (01) a node ignites at — staggered within its band
rec: (n: StarmapNode) => number
rings: Ring[]
// world radius a node is drawn at (inside its band)
tr: (n: StarmapNode) => number
}
// Even, unlabeled-ish fallback when there's no usable time span (undated graph
// or one instant): keep the legacy continuous mapping so nothing regresses.
function evenLayout(recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
const rings: Ring[] = Array.from({ length: RING_STEPS + 1 }, (_, i) => ({
label: timed && minTs !== null && maxTs !== null ? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS))) : null,
r: ringRadius(i),
ratio: recForRatio(i / RING_STEPS)
}))
const capRing = (rec: number): number => {
for (let i = 0; i < rings.length; i += 1) {
if ((rings[i]?.ratio ?? 1) >= rec - 1e-3) {
return i
}
}
return rings.length - 1
}
return {
index: n => capRing(recById.get(n.id) ?? 0),
rec: n => recById.get(n.id) ?? 0,
rings,
tr: n => radiusForRecency(recById.get(n.id) ?? 0)
}
}
// One equal-width ring per POPULATED calendar bucket; a bucket's nodes fill the
// band INSIDE their ring (fanned by angle) and ignite staggered across it.
function buildLayout(graph: StarmapGraph, recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
const stamps = graph.nodes.map(n => Number(n.timestamp)).filter(Number.isFinite)
if (!(timed && minTs !== null && maxTs !== null && maxTs > minTs && stamps.length)) {
return evenLayout(recById, minTs, maxTs, timed)
}
const span = maxTs - minTs
const unit = chooseUnit(stamps, span / DAY)
const starts = populatedStarts(stamps, unit)
if (starts.length < 2) {
return evenLayout(recById, minTs, maxTs, timed)
}
const indexOfStart = new Map(starts.map((s, i) => [s, i]))
// Reveal pacing is per-BUCKET (uniform), matching the equal-width bands: each
// ring is one even step. (Radius is already index-based.) Using raw time here
// decouples a ring's ignite moment from its position — a bursty gap makes a
// ring appear bands ahead of the nodes that belong to it. Labels stay real dates.
const last = Math.max(1, starts.length - 1)
const rings: Ring[] = starts.map((s, i) => ({ label: bucketLabel(s, unit), r: ringRadius(i), ratio: recForRatio(i / last) }))
// A node's bucket is its ring; undated nodes (rare, in an otherwise-timed
// graph) fall to the newest ring so they still appear.
const indexFor = (n: StarmapNode): number => {
const ts = Number(n.timestamp)
return Number.isFinite(ts) ? (indexOfStart.get(bucketStart(ts, unit)) ?? starts.length - 1) : starts.length - 1
}
// Node POSITION fills the band inside its bucket ring (placeRadius); its IGNITE
// time is staggered ACROSS that band, ordered by real timestamp, so a busy
// bucket trickles in over its whole slice instead of every node popping at
// once (the "everything floods in at the end" bug).
const buckets: StarmapNode[][] = starts.map(() => [])
for (const n of graph.nodes) {
buckets[indexFor(n)]!.push(n)
}
const tsOf = (n: StarmapNode): number => (Number.isFinite(Number(n.timestamp)) ? Number(n.timestamp) : Infinity)
const recByNode = new Map<string, number>()
buckets.forEach((bucket, i) => {
bucket.sort((a, b) => (tsOf(a) === tsOf(b) ? a.id.localeCompare(b.id) : tsOf(a) - tsOf(b)))
const hi = rings[i]!.ratio
const lo = i > 0 ? rings[i - 1]!.ratio : 0
const m = bucket.length
// Ignite in CLUSTERS, not a 1-by-1 trickle: split the band's (time-ordered)
// nodes into a few sub-bursts (~CLUSTER_SIZE each) that share an ignite
// moment, spaced across the band, with a hair of per-node jitter so a burst
// reads as organic rather than perfectly synchronous.
const clusters = Math.max(1, Math.round(m / CLUSTER_SIZE))
bucket.forEach((n, k) => {
const c = Math.min(clusters - 1, Math.floor((k / m) * clusters))
const jitter = ((hash(n.id) % 100) / 100 - 0.5) * (0.5 / clusters)
const f = clamp((c + 1) / clusters + jitter, 0.02, 1)
recByNode.set(n.id, lo + f * (hi - lo))
})
})
return {
index: indexFor,
rec: n => recByNode.get(n.id) ?? 0,
rings,
tr: n => placeRadius(indexFor(n), n.id)
}
}
// Build the radial time simulation: a node's distance from the core encodes its
// timestamp bucket (radial force dominates; charge/collide only spread nodes
// around their date ring). Rings are dated, equal-width gridlines.
export function buildSimulation(graph: StarmapGraph, onTick: () => void): BuiltSim {
const { maxTs, minTs, rec: recById, timed } = computeRecency(graph.nodes)
const { index, rec: recOf, rings, tr: trOf } = buildLayout(graph, recById, minTs, maxTs, timed)
const nodes: SimNode[] = graph.nodes.map(n => {
const rec = recOf(n)
const tr = trOf(n)
const angle = ((hash(n.id) % 3600) / 3600) * Math.PI * 2
return { ...n, outerRingIndex: index(n), rec, tr, vx: 0, vy: 0, x: Math.cos(angle) * tr, y: Math.sin(angle) * tr }
})
const byId = new Map(nodes.map(n => [n.id, n]))
const links: SimLink[] = graph.edges
.filter(e => byId.has(e.source) && byId.has(e.target))
.map(e => ({ source: e.source, target: e.target }))
const sim = forceSimulation(nodes)
.alphaDecay(0.05)
.velocityDecay(0.62)
.force('charge', forceManyBody<SimNode>().strength(-12))
.force(
'link',
forceLink<SimNode, SimLink>(links)
.id(n => n.id)
.distance(26)
.strength(0.06)
)
.force(
'collide',
forceCollide<SimNode>()
.radius(n => nodeRadius(n) + 2)
.iterations(2)
)
.force('radial', forceRadial<SimNode>(n => (n as SimNode).tr, 0, 0).strength(0.92))
.on('tick', onTick)
return { byId, links, nodes, rings, sim }
}

View file

@ -0,0 +1,941 @@
import { type Simulation } from 'd3-force'
import { atom, type WritableAtom } from 'nanostores'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
import type { StarmapGraph } from '@/types/hermes'
import { computePalette, memoryInkFor, resolveRgb, rgba } from './color'
import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry'
import { drawScene, drawScramble } from './render'
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
import { ShareControls } from './share-controls'
import { buildSimulation } from './simulation'
import { formatDate } from './text'
import { buildTimeAxis, dateAtReveal, type TimeAxis } from './time-axis'
import { Timeline } from './timeline'
import type { FadeBuckets, MemoryCard, Palette, Ring, RingLabelRect, SimLink, SimNode, Viewport } from './types'
// How long a full play-through sweep takes (ms), reveal 0 → 1. Longer = the
// build-up breathes; the eased middle no longer rushes past in a blink.
const SWEEP_MS = 15000
// How far to relax the ease toward a flat linear march. The bare smoothstep
// spikes to 1.5× linear speed mid-sweep, which reads as a "snap" through the
// middle; blending it back toward linear flattens that peak (≈1.3× at GENTLE
// = 0.45) so playback glides instead of lurching, while still keeping a soft
// ease-in / ease-out at the very start and end.
const GENTLE = 0.45
// Cinematic timing: cubic smoothstep (gentle ease-in / ease-out) relaxed toward
// linear by GENTLE, so the middle never rushes. Monotonic on [0,1], so the
// numeric inverse below stays valid.
function cineEase(t: number): number {
const u = t < 0 ? 0 : t > 1 ? 1 : t
const smooth = u * u * (3 - 2 * u)
return GENTLE * u + (1 - GENTLE) * smooth
}
// Numeric inverse (monotonic) so a resume maps the current reveal back to clock
// progress without a closed-form solution.
function invCineEase(y: number): number {
let lo = 0
let hi = 1
for (let i = 0; i < 24; i += 1) {
const mid = (lo + hi) / 2
if (cineEase(mid) < y) {
lo = mid
} else {
hi = mid
}
}
return (lo + hi) / 2
}
function revealText(axis: TimeAxis, reveal: number): string {
const date = dateAtReveal(axis, reveal)
return date !== null ? formatDate(date) : `${Math.round(reveal * axis.size)} / ${axis.size}`
}
function RevealLabel({ axis, revealStore }: { axis: TimeAxis; revealStore: WritableAtom<number> }) {
const labelRef = useRef<HTMLSpanElement | null>(null)
const sync = useCallback(
(reveal: number) => {
const el = labelRef.current
if (el) {
el.textContent = revealText(axis, reveal)
}
},
[axis]
)
useEffect(() => revealStore.subscribe(sync), [revealStore, sync])
useEffect(() => {
sync(revealStore.get())
}, [revealStore, sync])
return (
<span className="tabular-nums text-foreground/75" ref={labelRef}>
{revealText(axis, revealStore.get())}
</span>
)
}
// A tilted, top-down star map of what Hermes has learned. Time is RADIAL: oldest
// at the core, newest on the outer rings. This component owns the refs, effects
// and pointer wiring; layout lives in simulation.ts and painting in render.ts.
export function StarMap({
graph,
imported = false,
onImport,
onResetMap
}: {
graph: StarmapGraph
imported?: boolean
onImport?: (graph: StarmapGraph) => void
onResetMap?: () => void
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
const simRef = useRef<null | Simulation<SimNode, SimLink>>(null)
const nodesRef = useRef<SimNode[]>([])
const linksRef = useRef<SimLink[]>([])
const byIdRef = useRef(new Map<string, SimNode>())
const adjacencyRef = useRef(new Map<string, Set<string>>())
const memByIdRef = useRef(new Map<string, MemoryCard>())
const ringsRef = useRef<Ring[]>([])
const ringLabelRectsRef = useRef<RingLabelRect[]>([])
const fadeRef = useRef<FadeBuckets>({
appear: new Map(),
labels: new Map(),
links: new Map(),
nodes: new Map(),
rings: new Map()
})
const doubleTapRef = useRef(createDoubleTapDetector())
const paletteRef = useRef<null | Palette>(null)
const themeDirtyRef = useRef(true)
const invalidateRef = useRef<() => void>(() => {})
const viewportRef = useRef<Viewport>({ k: 1, x: 0, y: 0 })
const hoverRef = useRef<null | string>(null)
const hoveredLinkRef = useRef<null | string>(null)
const hoveredRingRef = useRef<null | number>(null)
const selectedRingRef = useRef<null | number>(null)
const selectedIdRef = useRef<null | string>(null)
const sizeRef = useRef({ h: 0, w: 0 })
const dprRef = useRef(1)
const dirtyRef = useRef(true)
// Scrub = direct manipulation (snap the fades to the pointer); Play = the
// cinematic birth/fade easing. One frame's worth of state, never re-rendered.
const snapMotionRef = useRef(false)
const dragRef = useRef<{
id: null | string
mode: 'none' | 'pan'
moved: boolean
ring: null | number
sx: number
sy: number
vp: Viewport
}>({ id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: { k: 1, x: 0, y: 0 } })
const [selectedId, setSelectedId] = useState<null | string>(null)
const [size, setSize] = useState({ h: 0, w: 0 })
// Increments on every theme repaint (shared hook) so the legend swatch and the
// canvas palette re-resolve against the freshly-painted CSS custom properties.
const themeEpoch = useThemeEpoch()
// Memory's swatch color — the same complementary-of-primary the canvas uses,
// so the legend matches the rendered diamonds exactly.
const [memoryColor, setMemoryColor] = useState('var(--theme-secondary)')
// Time scrubber: reveal 1 = the whole map (idle default); lower values hide
// not-yet-reached nodes so playing/scrubbing "builds it up". revealRef feeds
// the canvas loop and revealStore feeds the timeline + legend label — so a
// play-through / scrub never re-renders StarMap (the perf win). `playing`
// stays React state since it flips rarely and drives the play effect + button.
const revealStore = useMemo(() => atom(1), [])
const [playing, setPlaying] = useState(false)
// Reveal positions where each dated ring spawns (its inner neighbor's ratio —
// ringSeen reveals one band ahead), surfaced as markers on the timeline.
const [ringStops, setRingStops] = useState<number[]>([])
const revealRef = useRef(1)
// Spore-style zoom: the camera fits the *leading ring's* radius, a step
// function of reveal. It holds steady while a band fills, then eases out to the
// next shell when a new ring is reached — growth in discrete jumps, not a
// constant creep. This ref is the camera's current (eased) fit radius.
const camRadiusRef = useRef(RING_OUTER)
const timeAxis = useMemo(() => buildTimeAxis(graph, 72), [graph])
// The current map as a WoW-style share code, recomputed only when the graph
// changes (encode walks every node/edge/card, so don't redo it per render).
const shareCode = useMemo(() => encodeShareCode(graph), [graph])
// Decode a pasted code and hand the resulting graph up to the StarmapView,
// which swaps it in for the live profile scan. Returns an error string for the
// Timeline to surface inline, or null on success.
const importCode = useCallback(
(code: string): null | string => {
try {
const next = decodeShareCode(code)
onImport?.(next)
return null
} catch (err) {
return err instanceof ShareCodeError ? err.message : 'Could not read that map code.'
}
},
[onImport]
)
// Mark the canvas dirty and wake the (otherwise-idle) render loop.
const invalidate = useCallback(() => invalidateRef.current(), [])
// Single writer for the scrubber position: feeds the canvas (ref), the
// timeline + legend label (store subscribers), and wakes the paint loop —
// no React re-render, so playback/scrubbing stays off the render path.
const setRevealValue = useCallback(
(value: number) => {
const next = clamp(value, 0, 1)
revealRef.current = next
revealStore.set(next)
invalidate()
},
[invalidate, revealStore]
)
// Drop every in-flight ease so the next frame snaps to its targets.
const resetFades = useCallback(() => {
for (const bucket of Object.values(fadeRef.current)) {
bucket.clear()
}
}, [])
const memById = useMemo(() => {
const m = new Map<string, MemoryCard>()
graph.memory.forEach((card, i) => m.set(`memory:${card.source}:${i}`, card))
return m
}, [graph.memory])
const adjacency = useMemo(() => {
const m = new Map<string, Set<string>>()
for (const n of graph.nodes) {
m.set(n.id, new Set())
}
for (const e of graph.edges) {
m.get(e.source)?.add(e.target)
m.get(e.target)?.add(e.source)
}
return m
}, [graph.edges, graph.nodes])
// Track the wrapper size.
useEffect(() => {
const el = wrapRef.current
if (!el) {
return
}
const sync = () => setSize({ h: el.clientHeight, w: el.clientWidth })
const ro = new ResizeObserver(sync)
ro.observe(el)
sync()
return () => ro.disconnect()
}, [])
// (Re)build the radial simulation whenever the graph or size changes.
useEffect(() => {
sizeRef.current = size
if (size.w === 0 || size.h === 0) {
return
}
const { byId, links, nodes, rings, sim } = buildSimulation(graph, invalidate)
simRef.current = sim
nodesRef.current = nodes
linksRef.current = links
byIdRef.current = byId
ringsRef.current = rings
// Markers fire when a ring spawns: ringSeen(i) flips at rings[i-1].ratio.
setRingStops(rings.map((rg, i) => (rg.label != null ? (rings[i - 1]?.ratio ?? 0) : -1)).filter(v => v >= 0))
resetFades()
// Fit the actual disk (outermost ring), so a 3-ring map frames like a 12-ring
// one — count changes the disk size, not the framing.
viewportRef.current = fitViewport(size.w, size.h, rings[rings.length - 1]?.r ?? RING_OUTER)
invalidate()
if (selectedIdRef.current && !byId.has(selectedIdRef.current)) {
selectedIdRef.current = null
setSelectedId(null)
}
return () => {
sim.stop()
if (simRef.current === sim) {
simRef.current = null
}
}
}, [graph, invalidate, resetFades, size])
useEffect(() => {
adjacencyRef.current = adjacency
memByIdRef.current = memById
invalidate()
}, [adjacency, invalidate, memById])
// The empty-core ASCII scramble uses the bundled JetBrains Mono face. Canvas
// text doesn't reflow when a webfont loads, so repaint once it's ready.
useEffect(() => {
document.fonts?.load('1em "JetBrains Mono"').then(invalidate, () => {})
}, [invalidate])
useEffect(() => {
selectedIdRef.current = selectedId
invalidate()
}, [invalidate, selectedId])
// A fresh graph resets the scrubber to "fully built" (the idle default).
useEffect(() => {
camRadiusRef.current = RING_OUTER
snapMotionRef.current = false
setRevealValue(1)
setPlaying(false)
}, [graph, setRevealValue])
// The stepped fit radius for a reveal: fit ONE ring BEYOND the leading shell
// (the first not-yet-passed ring) so the ring currently igniting its nodes
// sits comfortably inside the frame with a band of headroom, instead of jammed
// against the edge. Steps only when reveal crosses a boundary — the Spore step.
const targetRadius = useCallback((rev: number): number => {
const rings = ringsRef.current
if (!rings.length) {
return RING_OUTER
}
const lead = rings.findIndex(rg => rg.ratio > rev + 1e-3)
const i = lead === -1 ? rings.length - 1 : lead
const band = (rings[1]?.r ?? RING_OUTER) - (rings[0]?.r ?? 0)
// Small headroom (a third of a band) so the igniting ring isn't jammed at the
// frame edge during playback, without zooming the resting view out.
return rings[i]!.r + band * 0.35
}, [])
const applyFit = useCallback((radius: number) => {
const { h, w } = sizeRef.current
if (w > 0 && h > 0) {
viewportRef.current = fitViewport(w, h, radius)
}
}, [])
// Snap the camera to a reveal's stepped target (scrubbing / reset — no glide).
const fitForReveal = useCallback(
(rev: number) => {
camRadiusRef.current = targetRadius(rev)
applyFit(camRadiusRef.current)
},
[applyFit, targetRadius]
)
// Playback: sweep reveal 0 → 1 over SWEEP_MS, then stop (play once).
useEffect(() => {
if (!playing) {
return
}
let raf = 0
let start = 0
const step = (now: number) => {
if (!start) {
// Anchor (in clock-space) so a resume continues from the current reveal.
start = now - invCineEase(revealRef.current) * SWEEP_MS
}
const progress = Math.min(1, (now - start) / SWEEP_MS)
const next = cineEase(progress)
// Ease the camera toward the leading ring's radius (a step target): it
// holds while a band fills, then pushes out when the next shell is reached.
const target = targetRadius(next)
camRadiusRef.current += (target - camRadiusRef.current) * 0.1
applyFit(camRadiusRef.current)
setRevealValue(next)
// End once the reveal is complete AND the camera has settled on the final
// shell, so the last push-out finishes instead of cutting off.
if (progress >= 1 && Math.abs(target - camRadiusRef.current) < 0.5) {
camRadiusRef.current = target
applyFit(target)
setPlaying(false)
return
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [applyFit, playing, setRevealValue, targetRadius])
const onTogglePlay = useCallback(() => {
if (playing) {
setPlaying(false)
return
}
// Leaving scrub: play eases (cinematic) rather than holding the snapped view.
snapMotionRef.current = false
// Replay from the start when parked at the end. Snap straight to the empty
// state (no fade-out) before playing in.
if (revealRef.current >= 1) {
resetFades()
fitForReveal(0)
setRevealValue(0)
}
setPlaying(true)
}, [fitForReveal, playing, resetFades, setRevealValue])
const onScrub = useCallback(
(value: number) => {
const next = clamp(value, 0, 1)
setPlaying(false)
// Scrub is direct manipulation: snap fades + camera to the pointer so a
// fast drag jumps there instead of replaying the birth-in from a stale spot.
snapMotionRef.current = true
fitForReveal(next)
setRevealValue(next)
},
[fitForReveal, setRevealValue]
)
// Spacebar toggles playback (unless typing, or the play button itself is
// focused — that already handles Space natively, so skip to avoid a double).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.code !== 'Space' && e.key !== ' ') {
return
}
const el = document.activeElement
const tag = el?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON' || (el as HTMLElement | null)?.isContentEditable) {
return
}
e.preventDefault()
onTogglePlay()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onTogglePlay])
// Recompute the legend's memory swatch from the live --theme-primary (matches
// the canvas), re-running on theme change and once the canvas is mounted.
useEffect(() => {
const el = canvasRef.current ?? wrapRef.current
if (!el) {
return
}
const style = getComputedStyle(el)
const val = style.getPropertyValue('--theme-primary').trim()
if (val) {
const bgVal =
style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9))
}
}, [size, themeEpoch])
// Repaint + repalette when the theme/mode repaints (the shared observer fires
// after applyTheme rewrites the class + inline vars on <html>).
useEffect(() => {
themeDirtyRef.current = true
invalidate()
}, [invalidate, themeEpoch])
// Render loop. The core scramble animates continuously, so the loop runs while
// the window is focused — but each frame is cheap (live scramble + a blit of the
// cached static layer). The expensive scene only re-renders when invalidate()
// marks it dirty. Capped to ~30fps; interaction (force) bypasses the cap.
useEffect(() => {
let raf = 0
const ANIM_MS = 1000 / 30
let lastAnimTs = 0
let force = true
// The scramble keeps the loop perpetually "animating", so a fully-built,
// untouched map still repaints 30×/s for as long as the panel is open. That's
// wasted CPU/GPU (WindowServer compositing) when the window isn't even the one
// you're looking at. Freeze the loop while the window is hidden or unfocused;
// a frozen core next to other work is fine, and it resumes instantly on focus.
const isPaused = () =>
(typeof document !== 'undefined' && document.hidden) ||
(typeof document.hasFocus === 'function' && !document.hasFocus())
let paused = isPaused()
const schedule = () => {
if (!paused && !raf) {
raf = requestAnimationFrame(frame)
}
}
// The static scene (rings, bands, links, nodes, labels) is cached in an
// offscreen layer and only re-rendered when something actually changes —
// dirtyRef flags that. The animated core scramble is the ONLY per-frame work:
// each frame we just clear, draw the live scramble, and blit the cached layer
// on top. So an idle map costs a scramble + one drawImage, not a full redraw.
let staticCanvas: HTMLCanvasElement | null = null
const paint = () => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
if (!staticCanvas) {
staticCanvas = document.createElement('canvas')
}
// Keep the offscreen layer matched to the backing store; a resize wipes it,
// so force a static rebuild.
if (staticCanvas.width !== canvas.width || staticCanvas.height !== canvas.height) {
staticCanvas.width = canvas.width
staticCanvas.height = canvas.height
dirtyRef.current = true
}
const offCtx = staticCanvas.getContext('2d')
if (!offCtx) {
return
}
if (themeDirtyRef.current || !paletteRef.current) {
paletteRef.current = computePalette(canvas)
themeDirtyRef.current = false
dirtyRef.current = true
}
const palette = paletteRef.current
if (!palette) {
return
}
// Rebuild the cached static layer only when the scene changed; keep
// rebuilding while fades are mid-ease (drawScene returns `animating`).
if (dirtyRef.current) {
const { animating, ringLabelRects } = drawScene({
adjacency: adjacencyRef.current,
byId: byIdRef.current,
ctx: offCtx,
dpr: dprRef.current,
fades: fadeRef.current,
focusId: selectedIdRef.current ?? hoverRef.current,
hoverId: hoverRef.current,
hoverLink: hoveredLinkRef.current,
hoverRing: hoveredRingRef.current,
links: linksRef.current,
memById: memByIdRef.current,
nodes: nodesRef.current,
palette,
reveal: revealRef.current,
rings: ringsRef.current,
selectedRing: selectedRingRef.current,
size: sizeRef.current,
snapMotion: snapMotionRef.current,
vp: viewportRef.current
})
// One-shot: a scrub snaps this frame; hover/focus afterward eases as usual
// (buckets are already at target, so the next eased frames don't move).
snapMotionRef.current = false
ringLabelRectsRef.current = ringLabelRects
dirtyRef.current = animating
}
// Composite order flips on focus/hover. Idle: scene first, sphere on top —
// its backdrop wash dims the busy centre. Focused/hovered: sphere first,
// scene on top — so the active node's tooltip + lit lines lift ABOVE the
// sphere instead of being covered by it.
const focused = selectedIdRef.current ?? hoverRef.current
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (focused) {
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.drawImage(staticCanvas, 0, 0)
} else {
ctx.drawImage(staticCanvas, 0, 0)
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
}
}
const frame = (ts: number) => {
raf = 0
// The scramble animates every frame; throttle to ANIM_MS unless an
// interaction (force) needs an immediate repaint.
if (!force && ts - lastAnimTs < ANIM_MS) {
schedule()
return
}
force = false
lastAnimTs = ts
paint()
schedule()
}
invalidateRef.current = () => {
dirtyRef.current = true
force = true
schedule()
}
// Suspend the loop when the window drops out of view/focus; wake + force a
// fresh frame the moment it returns so the resume is seamless.
const onActivity = () => {
const next = isPaused()
if (next === paused) {
return
}
paused = next
if (paused) {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
} else {
dirtyRef.current = true
force = true
schedule()
}
}
document.addEventListener('visibilitychange', onActivity)
window.addEventListener('blur', onActivity)
window.addEventListener('focus', onActivity)
schedule()
return () => {
cancelAnimationFrame(raf)
document.removeEventListener('visibilitychange', onActivity)
window.removeEventListener('blur', onActivity)
window.removeEventListener('focus', onActivity)
invalidateRef.current = () => {}
}
}, [])
// Size the backing canvas (DPR-aware).
useEffect(() => {
sizeRef.current = size
dprRef.current = Math.min(2, window.devicePixelRatio || 1)
const canvas = canvasRef.current
if (canvas && size.w > 0 && size.h > 0) {
canvas.width = Math.round(size.w * dprRef.current)
canvas.height = Math.round(size.h * dprRef.current)
canvas.style.width = `${size.w}px`
canvas.style.height = `${size.h}px`
}
invalidate()
}, [invalidate, size])
// ── Pointer interactions (invert the tilted projection for hit-testing) ─────
const pickNode = (cssX: number, cssY: number): null | SimNode => {
const vp = viewportRef.current
// Hit radius mirrors the billboarded draw: rested fit scale, screen space.
const nodeK = fitScale(sizeRef.current.w, sizeRef.current.h, ringsRef.current)
let best: null | SimNode = null
let bestD = Infinity
for (const n of nodesRef.current) {
const r = nodeRadius(n) * nodeK + 6
const sx = n.x * vp.k + vp.x
const sy = n.y * vp.k * TILT + vp.y
const d = (sx - cssX) ** 2 + (sy - cssY) ** 2
if (d < r * r && d < bestD) {
bestD = d
best = n
}
}
return best
}
// Nearest link within ~5px of the cursor (screen space), or null.
const pickLink = (cssX: number, cssY: number): null | string => {
const vp = viewportRef.current
let best: null | string = null
let bestD = 25
for (const link of linksRef.current) {
const s = typeof link.source === 'object' ? link.source : byIdRef.current.get(String(link.source))
const t = typeof link.target === 'object' ? link.target : byIdRef.current.get(String(link.target))
if (!s || !t) {
continue
}
const d = distToSegmentSq(
cssX,
cssY,
s.x * vp.k + vp.x,
s.y * vp.k * TILT + vp.y,
t.x * vp.k + vp.x,
t.y * vp.k * TILT + vp.y
)
if (d < bestD) {
bestD = d
best = `${s.id}->${t.id}`
}
}
return best
}
const pickRingLabel = (cssX: number, cssY: number): null | number => {
for (const r of ringLabelRectsRef.current) {
if (cssX >= r.x && cssX <= r.x + r.w && cssY >= r.y && cssY <= r.y + r.h) {
return r.i
}
}
return null
}
const localXY = (e: React.MouseEvent): { x: number; y: number } => {
const rect = canvasRef.current?.getBoundingClientRect()
return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) }
}
const resetView = () => {
setPlaying(false)
viewportRef.current = fitViewport(
sizeRef.current.w,
sizeRef.current.h,
ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER
)
selectedRingRef.current = null
invalidate()
setSelectedId(null)
}
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button !== 0) {
return
}
const { x, y } = localXY(e)
const ringHit = pickRingLabel(x, y)
hoveredRingRef.current = null
// Nodes aren't draggable (static map) — remember which was pressed so a click
// (press without movement) can select it; any drag just pans.
const nodeId = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
dragRef.current = {
id: nodeId,
mode: 'pan',
moved: false,
ring: ringHit,
sx: e.clientX,
sy: e.clientY,
vp: viewportRef.current
}
}
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const drag = dragRef.current
if (drag.mode === 'none') {
const { x, y } = localXY(e)
const ringHit = pickRingLabel(x, y)
const id = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
// Links are the last fallback (only when not over a node/date).
const linkKey = ringHit == null && id == null ? pickLink(x, y) : null
if (id !== hoverRef.current || ringHit !== hoveredRingRef.current || linkKey !== hoveredLinkRef.current) {
hoverRef.current = id
hoveredRingRef.current = ringHit
hoveredLinkRef.current = linkKey
invalidate()
}
return
}
const dx = e.clientX - drag.sx
const dy = e.clientY - drag.sy
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
drag.moved = true
}
if (drag.mode === 'pan') {
// Taking manual control of the camera ends an auto-fit play-through.
if (drag.moved) {
setPlaying(false)
}
viewportRef.current = { ...drag.vp, x: drag.vp.x + dx, y: drag.vp.y + dy }
invalidate()
}
}
const endDrag = () => {
const drag = dragRef.current
// A click (press without movement) toggles a ring date, a node, or clears.
if (drag.mode === 'pan' && !drag.moved) {
// Double tap (trackpad tap-to-click may never emit a dblclick) resets view.
if (doubleTapRef.current()) {
resetView()
dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current }
return
}
// Independent toggles: a date and a node can both be selected.
if (drag.ring != null) {
selectedRingRef.current = selectedRingRef.current === drag.ring ? null : drag.ring
} else if (drag.id) {
setSelectedId(prev => (prev === drag.id ? null : drag.id))
} else {
selectedRingRef.current = null
setSelectedId(null)
}
invalidate()
}
dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current }
}
const onMouseLeave = () => {
hoverRef.current = null
hoveredRingRef.current = null
hoveredLinkRef.current = null
invalidate()
endDrag()
}
const onWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) {
return
}
// macOS smart zoom (two-finger double-tap) → reset (see lib/trackpad-gestures).
if (isSmartZoomWheel(e)) {
resetView()
return
}
// Manual zoom takes over the camera from any auto-fit play-through.
setPlaying(false)
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const vp = viewportRef.current
const k = clamp(vp.k * (e.deltaY > 0 ? 0.9 : 1.1), ZOOM_MIN, ZOOM_MAX)
viewportRef.current = { k, x: px - ((px - vp.x) / vp.k) * k, y: py - ((py - vp.y) / vp.k) * k }
invalidate()
}
return (
<div className="relative min-h-0 flex-1 overflow-hidden" ref={wrapRef}>
<canvas
className="block touch-none select-none text-foreground"
onDoubleClick={resetView}
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onWheel={onWheel}
ref={canvasRef}
/>
{/* Timeline scrubber centered along the top, clear of the close button.
z-20 lifts it above the titlebar's app-region drag layer (z-10) so the
scrubber receives pointer events instead of dragging the window. */}
<div className="pointer-events-none absolute inset-x-0 top-6 z-20 flex justify-center px-12">
<Timeline
axis={timeAxis}
memoryColor={memoryColor}
onScrub={onScrub}
onTogglePlay={onTogglePlay}
playing={playing}
revealStore={revealStore}
ringStops={ringStops}
/>
</div>
{/* Share / import (WoW-talent-style code) — bottom-right, mirroring the legend. */}
<div className="pointer-events-auto absolute bottom-2 right-2 z-20 [-webkit-app-region:no-drag]">
<ShareControls imported={imported} onImport={importCode} onResetMap={onResetMap} shareCode={shareCode} />
</div>
{/* Legend — bottom-left, one entry per line like a conventional key. */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 text-[0.62rem] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rounded-full bg-[var(--theme-primary)]/80" /> skill
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rotate-45" style={{ backgroundColor: memoryColor }} /> memory
</span>
<span className="text-[0.58rem] text-muted-foreground/65">core = oldest · outer = newer</span>
<RevealLabel axis={timeAxis} revealStore={revealStore} />
</div>
</div>
)
}

View file

@ -0,0 +1,89 @@
import type { StarmapNode } from '@/types/hermes'
export function formatDate(ts?: null | number): string {
if (!ts) {
return 'unknown'
}
try {
return new Date(ts * 1000).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
} catch {
return 'unknown'
}
}
// Tag-style badge items for the hover tooltip — date first. Use-count is NOT a
// badge (rendered separately, right-aligned) so it's excluded here.
export function metaBadges(n: StarmapNode): string[] {
const out: string[] = [formatDate(n.timestamp)]
if (n.kind === 'memory') {
out.push(n.memorySource === 'profile' ? 'profile memory' : 'memory')
} else {
out.push(n.category)
if (n.createdBy === 'agent') {
out.push('learned')
}
if (n.pinned) {
out.push('pinned')
}
}
return out.filter(Boolean)
}
// Bare "xN" use-count, last in the badge row. Null when never used.
export function countLabel(n: StarmapNode): null | string {
return n.kind === 'skill' && n.useCount > 0 ? `x${n.useCount}` : null
}
// Footer-row content for the tooltip. Reserved primitive — returns nothing for
// now (skills have no UUID; their id is just the name). Wire real detail here
// later and the tooltip lays it out automatically.
export function nodeFooter(node: StarmapNode): null | string {
void node
return null
}
// Greedy word-wrap for the tooltip title so long memory lines don't blow out.
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] {
const words = text.split(/\s+/).filter(Boolean)
const lines: string[] = []
let line = ''
for (const word of words) {
const next = line ? `${line} ${word}` : word
if (!line || ctx.measureText(next).width <= maxW) {
line = next
} else {
lines.push(line)
line = word
}
}
if (line) {
lines.push(line)
}
return lines
}
// Trim to fit maxW, appending an ellipsis (keeps floating labels compact so they
// don't span the overlay).
export function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) {
return text
}
let s = text
while (s.length > 1 && ctx.measureText(`${s}`).width > maxW) {
s = s.slice(0, -1)
}
return `${s.trimEnd()}`
}

View file

@ -0,0 +1,105 @@
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
import { clamp } from './geometry'
// Empty lead-in: push the oldest node off 0 so the timeline opens on a beat of
// emptiness (you watch the first node grow in). Radial position is otherwise a
// truthful linear map of time, so rings line up with the nodes they date.
export const LEAD_IN = 0.06
export const recForRatio = (ratio: number): number => LEAD_IN + (1 - LEAD_IN) * clamp(ratio, 0, 1)
export interface Recency {
maxTs: null | number
minTs: null | number
// id → recency ratio (0 oldest … 1 newest). Timed by timestamp when the span
// is real, else ordinal so an undated graph still "builds up" in a stable order.
rec: Map<string, number>
timed: boolean
}
// Shared recency model for both the radial layout (simulation.ts) and the
// timeline scrubber, so a node's ring distance and its ignite time agree.
export function computeRecency(nodes: StarmapNode[]): Recency {
const known = nodes
.map(n => (typeof n.timestamp === 'number' && Number.isFinite(n.timestamp) ? Number(n.timestamp) : null))
.filter((v): v is number => v !== null)
const minTs = known.length ? Math.min(...known) : null
const maxTs = known.length ? Math.max(...known) : null
const timed = minTs !== null && maxTs !== null && maxTs > minTs
const ordered = [...nodes].sort((a, b) => {
const at = typeof a.timestamp === 'number' ? a.timestamp : Infinity
const bt = typeof b.timestamp === 'number' ? b.timestamp : Infinity
return at === bt ? a.id.localeCompare(b.id) : at - bt
})
const ordRatio = new Map(ordered.map((n, i) => [n.id, ordered.length > 1 ? i / (ordered.length - 1) : 0]))
const rec = new Map<string, number>()
// Radius is a truthful linear map of time (ordinal only as a fallback for the
// undated). Co-timed nodes share a radius and fan out by ANGLE in the sim — so
// a burst reads as a populated ring, and the dated rings stay accurate.
for (const n of nodes) {
const ratio =
timed && typeof n.timestamp === 'number' && minTs !== null && maxTs !== null
? (Number(n.timestamp) - minTs) / (maxTs - minTs)
: (ordRatio.get(n.id) ?? 0)
rec.set(n.id, recForRatio(ratio))
}
return { maxTs, minTs, rec, timed }
}
export interface TimeBucket {
memory: number
skill: number
total: number
}
export interface TimeAxis {
buckets: TimeBucket[]
maxTotal: number
maxTs: null | number
minTs: null | number
// Total node count — the denominator for the "n / total" label when undated.
size: number
timed: boolean
}
// Bucket nodes across recency [0,1] into a fixed-width histogram — the little
// bars the scrubber rides over. Skill/memory kept separate so the bars can show
// the same two-tone split as the map glyphs.
export function buildTimeAxis(graph: StarmapGraph, bucketCount = 48): TimeAxis {
const { maxTs, minTs, rec, timed } = computeRecency(graph.nodes)
const n = Math.max(1, bucketCount)
const buckets: TimeBucket[] = Array.from({ length: n }, () => ({ memory: 0, skill: 0, total: 0 }))
for (const node of graph.nodes) {
const r = rec.get(node.id) ?? 0
const idx = clamp(Math.floor(r * n), 0, n - 1)
const b = buckets[idx]!
b.total += 1
if (node.kind === 'memory') {
b.memory += 1
} else {
b.skill += 1
}
}
const maxTotal = buckets.reduce((m, b) => Math.max(m, b.total), 0)
return { buckets, maxTotal, maxTs, minTs, size: graph.nodes.length, timed }
}
// Wall-clock date at a reveal ratio (linear in time when the graph is dated).
export function dateAtReveal(axis: TimeAxis, reveal: number): null | number {
if (!axis.timed || axis.minTs === null || axis.maxTs === null) {
return null
}
return Math.round(axis.minTs + clamp(reveal, 0, 1) * (axis.maxTs - axis.minTs))
}

View file

@ -0,0 +1,289 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { TimeAxis } from './time-axis'
interface TimelineProps {
axis: TimeAxis
// Colour for memory stars — matches the map's memory glyph.
memoryColor?: string
onScrub: (reveal: number) => void
onTogglePlay: () => void
playing: boolean
revealStore: RevealSignal
// Reveal positions (01) where rings spawn — drawn as anchor ticks.
ringStops?: number[]
}
interface RevealSignal {
get: () => number
subscribe: (listener: (value: number) => void) => () => void
}
interface Star {
delay: number
duration: number
kind: 'memory' | 'skill'
leftPct: number
opacity: number
size: number
topPct: number
}
const ACTIVE_MARKER_CLASS = 'opacity-100'
const INACTIVE_MARKER_CLASS = 'opacity-30'
// Busiest bucket gets this many stars; quieter ones scale down proportionally.
const MAX_STARS_PER_BUCKET = 7
// Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders.
function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a += 0x6d2b79f5
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Scatter each time bucket's activity into stars: count ∝ events, split between
// skill- and memory-coloured stars, jittered within the bucket's horizontal slot
// and across the track height. Vertical placement is biased toward the midline
// (a triangular distribution) so stars cluster near the centre more often than
// the edges. A starmap timeline for a starmap.
function buildStars(axis: TimeAxis): Star[] {
const n = Math.max(1, axis.buckets.length)
const stars: Star[] = []
axis.buckets.forEach((b, i) => {
if (b.total === 0) {
return
}
const intensity = axis.maxTotal > 0 ? b.total / axis.maxTotal : 0
// sqrt curve so a single co-timed burst (a packed core ring) doesn't crush
// every quieter bucket down to the 1-star floor and read as blank.
const count = Math.max(1, Math.round(Math.sqrt(intensity) * MAX_STARS_PER_BUCKET))
const skillCount = Math.round((b.skill / b.total) * count)
const r = rng(i * 9973 + 7)
const slot = 1 / n
const center = (i + 0.5) / n
for (let s = 0; s < count; s++) {
const jitter = (r() - 0.5) * slot * 0.9
// Average of two uniforms → triangular peak at 0.5, pulling stars toward
// the midline more often while still reaching the edges occasionally.
const vertical = (r() + r()) / 2
stars.push({
delay: r() * 3,
duration: 2.4 + r() * 2.6,
kind: s < skillCount ? 'skill' : 'memory',
leftPct: Math.max(0, Math.min(1, center + jitter)) * 100,
// Brighter, slightly larger stars are rarer.
opacity: 0.5 + r() * 0.5,
// Floor at 2px so a lone star in a quiet bucket still reads on black.
size: 2 + Math.round(r() * r() * 2.2),
topPct: 12 + vertical * 76
})
}
})
return stars
}
// Playback scrubber as a constellation: dim stars are the unrevealed future; a
// scanner sweep ignites them (bright + twinkling) left→right as the reveal
// advances. The bright layer is clipped by the reveal CSS var, so the rAF sweep
// in StarMap drives it with zero per-frame JS.
export const Timeline = memo(function Timeline({
axis,
memoryColor = 'var(--theme-secondary)',
onScrub,
onTogglePlay,
playing,
revealStore,
ringStops = []
}: TimelineProps) {
const trackRef = useRef<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
const markerRefs = useRef<HTMLDivElement[]>([])
const stars = useMemo(() => buildStars(axis), [axis])
const syncReveal = useCallback(
(value: number) => {
const reveal = Math.max(0, Math.min(1, value))
const track = trackRef.current
if (track) {
track.style.setProperty('--starmap-reveal', String(reveal))
track.setAttribute('aria-valuenow', String(Math.round(reveal * 100)))
}
ringStops.forEach((stop, i) => {
const el = markerRefs.current[i]
if (!el) {
return
}
const active = stop <= reveal
el.classList.toggle(ACTIVE_MARKER_CLASS, active)
el.classList.toggle(INACTIVE_MARKER_CLASS, !active)
})
},
[ringStops]
)
useEffect(() => revealStore.subscribe(syncReveal), [revealStore, syncReveal])
useEffect(() => {
markerRefs.current.length = ringStops.length
syncReveal(revealStore.get())
}, [revealStore, ringStops.length, syncReveal])
const ratioAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) {
return revealStore.get()
}
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = true
e.currentTarget.setPointerCapture(e.pointerId)
onScrub(ratioAt(e.clientX))
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (draggingRef.current) {
onScrub(ratioAt(e.clientX))
}
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = false
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
const colorFor = (kind: Star['kind']) => (kind === 'skill' ? 'var(--theme-primary)' : memoryColor)
return (
<div className="pointer-events-auto flex w-[28rem] max-w-full items-center gap-3 [-webkit-app-region:no-drag]">
<style>{'@keyframes starmap-twinkle{0%,100%{opacity:var(--o,1)}50%{opacity:calc(var(--o,1) * 0.35)}}'}</style>
<button
aria-label={playing ? 'Pause' : 'Play timeline'}
className="flex size-5 shrink-0 items-center justify-center text-foreground/75 transition-colors hover:text-foreground"
onClick={onTogglePlay}
type="button"
>
<Codicon name={playing ? 'debug-pause' : 'triangle-right'} size={playing ? '0.8rem' : '0.95rem'} />
</button>
<div
aria-label="Timeline scrubber"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(revealStore.get() * 100)}
className="relative h-7 min-w-0 flex-1 cursor-pointer select-none touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={trackRef}
role="slider"
style={{ '--starmap-reveal': revealStore.get() } as React.CSSProperties}
tabIndex={0}
>
{/* Dashed midline — a faint horizontal axis the stars ride over. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 border-t border-dashed border-foreground/5"
/>
{/* Dim constellation — the unrevealed future. */}
<div aria-hidden className="absolute inset-0">
{stars.map((star, i) => (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={{
backgroundColor: colorFor(star.kind),
height: star.size,
left: `${star.leftPct}%`,
opacity: 0.22,
top: `${star.topPct}%`,
width: star.size
}}
/>
))}
</div>
{/* Ignited constellation — bright + twinkling, clipped to the reveal. */}
<div
aria-hidden
className="absolute inset-0"
style={{ clipPath: 'inset(0 calc((1 - var(--starmap-reveal, 1)) * 100%) 0 0)' }}
>
{stars.map((star, i) => {
const color = colorFor(star.kind)
return (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={
{
'--o': star.opacity,
animation: `starmap-twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
backgroundColor: color,
height: star.size,
left: `${star.leftPct}%`,
opacity: star.opacity,
top: `${star.topPct}%`,
width: star.size
} as React.CSSProperties
}
/>
)
})}
</div>
{/* Ring-spawn anchor ticks — small bright stars that light up on pass. */}
<div aria-hidden className="absolute inset-0">
{ringStops.map((stop, i) => (
<div
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {
markerRefs.current[i] = el
}
}}
style={{ left: `${stop * 100}%` }}
/>
))}
</div>
{/* Playhead — a thin white sweep line. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground"
style={{ left: 'calc(var(--starmap-reveal, 1) * 100%)' }}
/>
</div>
</div>
)
})

View file

@ -0,0 +1,97 @@
import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
export type MemoryCard = StarmapGraph['memory'][number]
export type Shape = 'circle' | 'diamond' | 'hexagon' | 'square' | 'triangle'
export interface Viewport {
k: number
x: number
y: number
}
export interface Rgb {
b: number
g: number
r: number
}
export interface Rect {
h: number
w: number
x: number
y: number
}
export interface SimNode extends StarmapNode, SimulationNodeDatum {
outerRingIndex: number // first ring that caps this node's recency band
rec: number // recency 0 (oldest) → 1 (newest)
tr: number // time-anchored target radius
x: number
y: number
}
export interface SimLink extends SimulationLinkDatum<SimNode> {
source: SimNode | string
target: SimNode | string
}
// Per-mode line/ring style.
export interface GraphParams {
lineAlpha: number
lineDash: number
lineDashed: boolean
lineWidth: number
ringAlpha: number
ringDash: number
ringDashed: boolean
ringWidth: number
}
// Per-mode ring/orb params (band wash, light-sliver size, ring outline alpha, orb sheen).
export interface RingParams {
bandAlpha: number
lightSize: number
ringAlpha: number
sheen: number
}
export interface Palette {
bandInk: Rgb
base: Rgb
bg: Rgb
c: GraphParams
chipBg: string
darkTheme: boolean
inkInv: string
memoryInk: Rgb
primary: Rgb
skillInk: Rgb
}
export interface Ring {
label: null | string
r: number
ratio: number
}
export interface RingLabelRect {
h: number
i: number
w: number
x: number
y: number
}
export interface FadeBuckets {
// Per-element "birth" progress 0→1 used to ease position (nodes rise outward
// into place, rings grow out) as the scrubber reveals them. Separate from the
// alpha buckets so it stays monotonic and isn't perturbed by focus/selection.
appear: Map<string, number>
labels: Map<string, number>
links: Map<string, number>
nodes: Map<string, number>
rings: Map<string, number>
}

View file

@ -1,24 +1,18 @@
import { useEffect, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
const isDarkNow = () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
// Tracks the app's dark/light mode off the `dark` class on <html> (set by
// themes/context.tsx). Embeds that theme their own content (tweets) read this.
// Rides the shared theme-repaint observer; setState bails on an unchanged
// boolean, so style-only repaints don't re-render.
export function useIsDark(): boolean {
const [dark, setDark] = useState(
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
const epoch = useThemeEpoch()
const [dark, setDark] = useState(isDarkNow)
useEffect(() => {
if (typeof document === 'undefined') {
return
}
const root = document.documentElement
const observer = new MutationObserver(() => setDark(root.classList.contains('dark')))
observer.observe(root, { attributeFilter: ['class'], attributes: true })
return () => observer.disconnect()
}, [])
useEffect(() => setDark(isDarkNow()), [epoch])
return dark
}

View file

@ -1,6 +1,7 @@
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { onThemeRepaint } from '@/hooks/use-theme-epoch'
type Rgb = { r: number; g: number; b: number }
@ -278,14 +279,10 @@ export const DiffusionCanvas: FC = () => {
// Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and
// rewrites inline custom props on the root) instead of per animation frame.
const observer = new MutationObserver(sync)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'data-hermes-mode']
})
const unsubscribe = onThemeRepaint(sync)
return () => {
observer.disconnect()
unsubscribe()
probe.remove()
}
}, [])

View file

@ -7,6 +7,8 @@ import {
useState
} from 'react'
import { isSmartZoomWheel } from '@/lib/trackpad-gestures'
interface Transform {
scale: number
x: number
@ -43,6 +45,14 @@ export function useZoomPan() {
const onWheel = useCallback(
(event: ReactWheelEvent) => {
event.preventDefault()
// macOS smart zoom (two-finger double-tap) → reset, not zoom-in.
if (isSmartZoomWheel(event)) {
setTransform({ scale: 1, x: 0, y: 0 })
return
}
const rect = event.currentTarget.getBoundingClientRect()
const cx = event.clientX - rect.left - rect.width / 2
const cy = event.clientY - rect.top - rect.height / 2

View file

@ -41,6 +41,7 @@ import type {
SessionMessagesResponse,
SessionSearchResponse,
SkillInfo,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo
@ -113,6 +114,7 @@ export type {
SessionSearchResult,
SkillInfo,
StaleAuxAssignment,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo
@ -489,6 +491,15 @@ export function getSkills(): Promise<SkillInfo[]> {
})
}
export function getStarmapGraph(): Promise<StarmapGraph> {
return window.hermesDesktop.api<StarmapGraph>({
...profileScoped(),
// Backend REST contract — stays /api/learning even though the UI feature is
// now "star map". Renaming this would break against an un-upgraded backend.
path: '/api/learning/graph'
})
}
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),

View file

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
// Theme repaints (themes/context.tsx) toggle `.dark` + rewrite inline custom
// props/data-hermes-* on <html>. Canvas/probe consumers that rasterize the
// *computed* color-mix()/oklch tokens must re-resolve AFTER the paint — useTheme()
// can't, since a child's effect runs before the provider's applyTheme. A
// MutationObserver fires post-mutation, so the next getComputedStyle is fresh.
// One observer, fanned out to every listener.
const ATTRS = ['class', 'style', 'data-hermes-mode', 'data-hermes-theme']
const listeners = new Set<() => void>()
let observer: MutationObserver | null = null
/** Subscribe to theme repaints imperatively (ref/canvas, no re-render). */
export function onThemeRepaint(fn: () => void): () => void {
if (!observer && typeof document !== 'undefined') {
observer = new MutationObserver(() => listeners.forEach(l => l()))
observer.observe(document.documentElement, { attributeFilter: ATTRS, attributes: true })
}
listeners.add(fn)
return () => void listeners.delete(fn)
}
/** A counter that ticks on every theme repaint — depend on it to re-resolve colors. */
export function useThemeEpoch(): number {
const [epoch, setEpoch] = useState(0)
useEffect(() => onThemeRepaint(() => setEpoch(e => e + 1)), [])
return epoch
}

View file

@ -181,6 +181,7 @@ export const en: Translations = {
muteHaptics: 'Mute haptics',
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings',
openStarmap: 'Open memory graph',
openKeybinds: 'Keyboard shortcuts'
},
@ -752,6 +753,33 @@ export const en: Translations = {
failedToUpdate: name => `Failed to update ${name}`
},
starmap: {
title: 'Memory Graph',
subtitle: (nodes, clusters) => `${nodes} skills across ${clusters} categories`,
close: 'Close memory graph',
refresh: 'Refresh',
memory: 'Memory',
filterAll: 'All',
filterUsed: 'Used',
filterLearned: 'Learned',
viewGraph: 'Graph',
loadFailed: 'Could not load memory graph',
loading: 'Loading…',
emptyTitle: 'Nothing learned yet',
emptyDesc: 'As Hermes builds skills and memories for your work, they appear here.',
share: 'Share map',
shareHint: 'Copy the code to share this map, or paste one to load. It only includes the layout, not your memory or skill text.',
shareTitle: 'Import / export map',
sharePlaceholder: 'Paste a map code…',
copy: 'Copy map code',
copied: 'Copied!',
importMap: 'Import a map',
importBtn: 'Load',
importEmpty: 'Paste a map code to load it.',
importSuccess: nodes => `Loaded a map with ${nodes} ${nodes === 1 ? 'node' : 'nodes'}.`,
importedBadge: 'imported map',
resetToMine: 'Back to my map'
},
agents: {
close: 'Close agents',
title: 'Spawn tree',
@ -1845,6 +1873,8 @@ export const en: Translations = {
running: count => `${count} running`,
cron: 'Cron',
openCron: 'Open cron jobs',
starmap: 'Memory Graph',
openStarmap: 'Open memory graph',
turnRunning: 'Running',
currentTurnElapsed: 'Current turn elapsed',
contextUsage: 'Context usage',

View file

@ -181,7 +181,8 @@ export const ja = defineLocale({
showRightSidebar: '右サイドバーを表示',
muteHaptics: '触覚フィードバックをオフ',
unmuteHaptics: '触覚フィードバックをオン',
openSettings: '設定を開く'
openSettings: '設定を開く',
openStarmap: 'メモリグラフを開く'
},
language: {
@ -864,6 +865,21 @@ export const ja = defineLocale({
failedToUpdate: name => `${name} の更新に失敗しました`
},
starmap: {
title: 'メモリグラフ',
subtitle: (nodes, clusters) => `${clusters} カテゴリの ${nodes} スキル`,
close: 'メモリグラフを閉じる',
refresh: '更新',
memory: 'メモリ',
filterAll: 'すべて',
filterUsed: '使用済み',
filterLearned: '学習済み',
viewGraph: 'グラフ',
loadFailed: 'メモリグラフを読み込めませんでした',
loading: '読み込み中…',
emptyTitle: 'まだ学習はありません',
emptyDesc: 'Hermes がスキルやメモリを蓄積すると、ここに表示されます。'
},
agents: {
close: 'エージェントを閉じる',
title: 'スポーンツリー',
@ -1965,6 +1981,8 @@ export const ja = defineLocale({
running: count => `${count} 実行中`,
cron: 'Cron',
openCron: 'Cron ジョブを開く',
starmap: 'メモリグラフ',
openStarmap: 'メモリグラフを開く',
turnRunning: '実行中',
currentTurnElapsed: '現在のターン経過時間',
contextUsage: 'コンテキスト使用状況',

View file

@ -223,6 +223,7 @@ export interface Translations {
muteHaptics: string
unmuteHaptics: string
openSettings: string
openStarmap: string
openKeybinds: string
}
@ -649,6 +650,33 @@ export interface Translations {
failedToUpdate: (name: string) => string
}
starmap: {
title: string
subtitle: (nodes: number, clusters: number) => string
close: string
refresh: string
memory: string
filterAll: string
filterUsed: string
filterLearned: string
viewGraph: string
loadFailed: string
loading: string
emptyTitle: string
emptyDesc: string
share: string
shareHint: string
shareTitle: string
sharePlaceholder: string
copy: string
copied: string
importMap: string
importBtn: string
importEmpty: string
importSuccess: (nodes: number) => string
importedBadge: string
resetToMine: string
}
agents: {
close: string
title: string
@ -1498,6 +1526,8 @@ export interface Translations {
running: (count: number) => string
cron: string
openCron: string
starmap: string
openStarmap: string
turnRunning: string
currentTurnElapsed: string
contextUsage: string

View file

@ -175,7 +175,8 @@ export const zhHant = defineLocale({
showRightSidebar: '顯示右側邊欄',
muteHaptics: '靜音觸感回饋',
unmuteHaptics: '開啟觸感回饋',
openSettings: '開啟設定'
openSettings: '開啟設定',
openStarmap: '開啟記憶圖譜'
},
language: {
@ -836,6 +837,21 @@ export const zhHant = defineLocale({
failedToUpdate: name => `更新 ${name} 失敗`
},
starmap: {
title: '記憶圖譜',
subtitle: (nodes, clusters) => `${clusters} 個類別中的 ${nodes} 個技能`,
close: '關閉記憶圖譜',
refresh: '重新整理',
memory: '記憶',
filterAll: '全部',
filterUsed: '已使用',
filterLearned: '已學習',
viewGraph: '圖譜',
loadFailed: '無法載入記憶圖譜',
loading: '載入中…',
emptyTitle: '尚無學習內容',
emptyDesc: '當 Hermes 為你的工作建立技能與記憶時,會顯示在這裡。'
},
agents: {
close: '關閉代理',
title: '派生樹',
@ -1904,6 +1920,8 @@ export const zhHant = defineLocale({
running: count => `${count} 個執行中`,
cron: '排程',
openCron: '開啟排程工作',
starmap: '記憶圖譜',
openStarmap: '開啟記憶圖譜',
turnRunning: '執行中',
currentTurnElapsed: '目前回合已用時間',
contextUsage: '上下文使用量',

View file

@ -176,6 +176,7 @@ export const zh: Translations = {
muteHaptics: '关闭触感反馈',
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置',
openStarmap: '打开记忆图谱',
openKeybinds: '键盘快捷键'
},
@ -936,6 +937,33 @@ export const zh: Translations = {
failedToUpdate: name => `更新 ${name} 失败`
},
starmap: {
title: '记忆图谱',
subtitle: (nodes, clusters) => `${clusters} 个类别中的 ${nodes} 个技能`,
close: '关闭记忆图谱',
refresh: '刷新',
memory: '记忆',
filterAll: '全部',
filterUsed: '已使用',
filterLearned: '已学习',
viewGraph: '图谱',
loadFailed: '无法加载记忆图谱',
loading: '加载中…',
emptyTitle: '尚无学习内容',
emptyDesc: '当 Hermes 为你的工作构建技能和记忆时,会显示在这里。',
share: '分享图谱',
shareHint: '复制代码以分享此图谱,或粘贴代码以载入。仅包含布局,不含你的记忆或技能内容。',
shareTitle: '导入 / 导出图谱',
sharePlaceholder: '粘贴图谱代码…',
copy: '复制图谱代码',
copied: '已复制!',
importMap: '导入图谱',
importBtn: '加载',
importEmpty: '粘贴图谱代码以加载。',
importSuccess: nodes => `已加载包含 ${nodes} 个节点的图谱。`,
importedBadge: '导入的图谱',
resetToMine: '返回我的图谱'
},
agents: {
close: '关闭代理',
title: '派生树',
@ -2016,6 +2044,8 @@ export const zh: Translations = {
running: count => `${count} 个运行中`,
cron: '排程',
openCron: '打开排程任务',
starmap: '记忆图谱',
openStarmap: '打开记忆图谱',
turnRunning: '运行中',
currentTurnElapsed: '当前回合已用时间',
contextUsage: '上下文用量',

View file

@ -91,6 +91,7 @@ import {
IconSettings2 as Settings2,
IconAdjustmentsHorizontal as SlidersHorizontal,
IconSquare as Square,
IconChartDots3 as Starmap,
IconSteeringWheel as SteeringWheel,
IconPlayerStopFilled as StopFilled,
IconSun as Sun,
@ -204,6 +205,7 @@ export {
Settings2,
SlidersHorizontal,
Square,
Starmap,
SteeringWheel,
StopFilled,
Sun,

View file

@ -0,0 +1,277 @@
import { deflateSync, inflateSync } from 'fflate'
// ── Loadout codec ─────────────────────────────────────────────────────────────
//
// A generic, WoW-talent-loadout-style binary share codec: pack *bits and
// indices* (not JSON), DEFLATE the body, frame it with a version + checksum, and
// emit a short, opaque, clipboard-safe base64url string under a namespacing
// prefix. Domain code supplies only the body schema (`write`/`read` over the
// BitWriter/BitReader); everything else — compression, integrity, framing,
// whitespace tolerance, typed errors — lives here so a new shareable thing
// (e.g. an enabled-skills set) is just a new `createLoadout({ … })`.
// ── Little-endian bit writer (WoW's WriteBits, low bit first) ────────────────
export class BitWriter {
private bits: number[] = []
bit(v: 0 | 1 | boolean): void {
this.bits.push(v ? 1 : 0)
}
uint(value: number, width: number): void {
let v = value >>> 0
for (let i = 0; i < width; i += 1) {
this.bits.push(v & 1)
v >>>= 1
}
}
// LEB128-style varint: 7 payload bits per group, high "continue" bit set while
// more groups follow.
varint(value: number): void {
let v = Math.max(0, Math.floor(value))
do {
const group = v & 0x7f
v = Math.floor(v / 128)
this.bit(v > 0 ? 1 : 0)
this.uint(group, 7)
} while (v > 0)
}
str(s: string): void {
const bytes = new TextEncoder().encode(s)
this.varint(bytes.length)
for (const b of bytes) {
this.uint(b, 8)
}
}
bytes(): Uint8Array {
const out = new Uint8Array(Math.ceil(this.bits.length / 8))
for (let i = 0; i < this.bits.length; i += 1) {
if (this.bits[i]) {
out[i >> 3]! |= 1 << (i & 7)
}
}
return out
}
}
export class BitReader {
private pos = 0
constructor(private readonly buf: Uint8Array) {}
bit(): number {
if (this.pos >= this.buf.length * 8) {
throw new RangeError('loadout truncated')
}
const i = this.pos++
return (this.buf[i >> 3]! >> (i & 7)) & 1
}
uint(width: number): number {
let v = 0
for (let i = 0; i < width; i += 1) {
v |= this.bit() << i
}
return v >>> 0
}
varint(): number {
let v = 0
let shift = 0
for (;;) {
const cont = this.bit()
v += this.uint(7) * 2 ** shift
shift += 7
if (!cont) {
return v
}
}
}
str(): string {
const len = this.varint()
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i += 1) {
bytes[i] = this.uint(8)
}
return new TextDecoder().decode(bytes)
}
}
// Interns repeated strings (labels, categories, …) so each record spends one
// varint id instead of the full string; DEFLATE then squeezes the dictionary.
export class Dict {
private readonly index = new Map<string, number>()
readonly list: string[] = []
id(s: string): number {
const hit = this.index.get(s)
if (hit !== undefined) {
return hit
}
const id = this.list.length
this.index.set(s, id)
this.list.push(s)
return id
}
}
// Index of `value` in a fixed enum table, clamped to 0 so an unknown value
// decodes to the table's first (default) member instead of throwing.
export const idxOf = <T extends readonly string[]>(table: T, value: string): number => {
const i = table.indexOf(value as T[number])
return i < 0 ? 0 : i
}
// Bits needed to address `n` items positionally (fixed-width back-references).
export const indexBits = (n: number): number => (n <= 1 ? 1 : Math.ceil(Math.log2(n)))
// ── base64url over the raw bytes (URL- and clipboard-safe, no padding) ────────
function toBase64Url(buf: Uint8Array): string {
let bin = ''
for (const b of buf) {
bin += String.fromCharCode(b)
}
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function fromBase64Url(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/')
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i += 1) {
out[i] = bin.charCodeAt(i)
}
return out
}
// FNV-1a over the body bytes, low 16 bits — a tamper/corruption gate, not crypto.
function checksum16(buf: Uint8Array): number {
let h = 0x811c9dc5
for (const b of buf) {
h ^= b
h = Math.imul(h, 0x01000193)
}
return (h >>> 0) & 0xffff
}
export class LoadoutError extends Error {}
export interface Loadout<T> {
decode(code: string): T
encode(value: T): string
}
export interface LoadoutSpec<T> {
/** Namespacing prefix (like WoW's leading bytes), e.g. 'HML'. */
prefix: string
/** Bumped whenever the body schema changes incompatibly. */
version: number
/** Write the domain body; framing/compression/checksum are added around it. */
write: (w: BitWriter, value: T) => void
/** Read the domain body back. May throw — it's wrapped as a LoadoutError. */
read: (r: BitReader) => T
/** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */
noun?: string
/** Error subclass to throw, so callers can `instanceof` their own type. */
error?: new (message: string) => LoadoutError
}
const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum
// Build an encode/decode pair for a domain value. The body schema is the only
// thing a caller writes; everything else (deflate, version+checksum frame,
// base64url, whitespace tolerance, typed errors) is shared.
export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
const Err = spec.error ?? LoadoutError
const noun = spec.noun ?? 'code'
const Noun = noun.charAt(0).toUpperCase() + noun.slice(1)
const encode = (value: T): string => {
const body = new BitWriter()
spec.write(body, value)
const payload = deflateSync(body.bytes(), { level: 9 })
const head = new BitWriter()
head.uint(spec.version, 8)
head.uint(checksum16(payload), 16)
const headBytes = head.bytes()
const framed = new Uint8Array(headBytes.length + payload.length)
framed.set(headBytes, 0)
framed.set(payload, headBytes.length)
return spec.prefix + toBase64Url(framed)
}
const decode = (code: string): T => {
// Strip ALL whitespace, not just the ends — a pasted code often picks up soft
// wraps / newlines, and base64 decoding chokes on any of it.
const cleaned = code.replace(/\s+/g, '')
const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned
if (!raw) {
throw new Err(`That doesn't look like a ${noun}.`)
}
let framed: Uint8Array
try {
framed = fromBase64Url(raw)
} catch {
throw new Err(`That doesn't look like a ${noun}.`)
}
if (framed.length <= HEAD_BYTES) {
throw new Err(`${Noun} is too short to be valid.`)
}
const head = new BitReader(framed.subarray(0, HEAD_BYTES))
const version = head.uint(8)
const storedSum = head.uint(16)
if (version !== spec.version) {
throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`)
}
const payload = framed.subarray(HEAD_BYTES)
if (checksum16(payload) !== storedSum) {
throw new Err(`${Noun} looks corrupted (checksum mismatch).`)
}
try {
return spec.read(new BitReader(inflateSync(payload)))
} catch (err) {
throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`)
}
}
return { decode, encode }
}

View file

@ -0,0 +1,50 @@
// Trackpad / pointer gesture primitives shared across canvas + DOM surfaces.
//
// macOS quirk (Chromium/Electron): both pinch-zoom and "smart zoom" arrive as
// `wheel` events with `ctrlKey` synthetically set — there is no dedicated DOM
// event for either. They're disambiguated by their deltas:
// - pinch-to-zoom: ctrlKey + a non-zero delta
// - smart zoom: ctrlKey + zero deltas (the two-finger double-tap)
// Plain two-finger scroll has ctrlKey === false. Centralising this here keeps
// every zoom/pan surface from re-deriving the same OS trivia (and getting it
// wrong, which makes smart-zoom read as a zoom-in).
export interface WheelLike {
ctrlKey: boolean
deltaX: number
deltaY: number
}
/** macOS "smart zoom" (two-finger double-tap): a ctrl-wheel with no delta. */
export function isSmartZoomWheel(e: WheelLike): boolean {
return e.ctrlKey && e.deltaX === 0 && e.deltaY === 0
}
/** Pinch-to-zoom (or ctrl + mouse wheel): a ctrl-wheel carrying a delta. */
export function isPinchZoomWheel(e: WheelLike): boolean {
return e.ctrlKey && (e.deltaX !== 0 || e.deltaY !== 0)
}
export const DOUBLE_TAP_MS = 300
/**
* Stateful double-tap detector for surfaces where a real `dblclick` may never
* fire (e.g. a trackpad with tap-to-click off). Call it once per discrete tap;
* it returns true when two taps land within `thresholdMs` of each other, then
* resets so a third tap starts a fresh pair.
*/
export function createDoubleTapDetector(thresholdMs: number = DOUBLE_TAP_MS): (now?: number) => boolean {
let last = 0
return (now: number = Date.now()): boolean => {
if (now - last < thresholdMs) {
last = 0
return true
}
last = now
return false
}
}

View file

@ -7,6 +7,7 @@ import type { HermesConnection } from '@/global'
// the REST query client must not run for real in a unit test.
const ensureGatewayForProfile = vi.fn(async () => undefined)
const $gateway = atom<unknown>({ id: 'live-socket' })
const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
vi.mock('@/hermes', () => ({
@ -14,9 +15,11 @@ vi.mock('@/hermes', () => ({
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
const { $connection } = await import('./session')
const { queryClient } = await import('@/lib/query-client')
const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
@ -33,6 +36,8 @@ beforeEach(() => {
$activeGatewayProfile.set('default')
$connection.set(localConn())
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
vi.mocked(queryClient.invalidateQueries).mockClear()
resetStarmapGraph.mockClear()
})
afterEach(() => {
@ -87,3 +92,12 @@ describe('ensureGatewayProfile → $connection sync (#46651)', () => {
expect($connection.get()?.mode).toBe('remote')
})
})
describe('profile-scoped cache invalidation', () => {
it('drops the memory graph cache when the active gateway profile changes', () => {
$activeGatewayProfile.set('coder')
expect(queryClient.invalidateQueries).toHaveBeenCalled()
expect(resetStarmapGraph).toHaveBeenCalledTimes(1)
})
})

View file

@ -13,6 +13,7 @@ import {
} from '@/lib/storage'
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
import { setConnection } from '@/store/session'
import { resetStarmapGraph } from '@/store/starmap'
import type { ProfileInfo } from '@/types/hermes'
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
@ -168,6 +169,7 @@ $activeGatewayProfile.subscribe(value => {
if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) {
// Profile-scoped settings + the unified session list are now stale.
void queryClient.invalidateQueries()
resetStarmapGraph()
}
_lastRoutedProfile = key

View file

@ -0,0 +1,46 @@
import { atom } from 'nanostores'
import { getStarmapGraph } from '@/hermes'
import type { StarmapGraph } from '@/types/hermes'
// On-demand cache for the star map. The graph scan touches the skills catalog +
// usage ledger + memory files, so we fetch it only when the panel opens (and on
// an explicit refresh), never on a turn boundary.
export const $starmapGraph = atom<StarmapGraph | null>(null)
export const $starmapLoading = atom(false)
export const $starmapError = atom<null | string>(null)
let inflight: Promise<void> | null = null
export async function loadStarmapGraph(force = false): Promise<void> {
if (inflight) {
return inflight
}
if ($starmapGraph.get() && !force) {
return
}
$starmapLoading.set(true)
$starmapError.set(null)
inflight = (async () => {
try {
$starmapGraph.set(await getStarmapGraph())
} catch (err) {
$starmapError.set(err instanceof Error ? err.message : String(err))
} finally {
$starmapLoading.set(false)
inflight = null
}
})()
return inflight
}
/** Drop the cache so the next open refetches against the now-active profile. */
export function resetStarmapGraph(): void {
inflight = null
$starmapGraph.set(null)
$starmapError.set(null)
}

View file

@ -429,6 +429,47 @@ export interface UsageStats {
total: number
}
/** One graph node in the star map (learned skill or memory chunk). */
export interface StarmapNode {
id: string
label: string
kind: 'memory' | 'skill'
memorySource?: 'memory' | 'profile'
timestamp?: null | number
category: string
useCount: number
state: string
createdBy: null | string
pinned: boolean
}
/** A declared `related_skills` link; both endpoints are guaranteed to be nodes. */
export interface StarmapEdge {
source: string
target: string
}
export interface StarmapCluster {
category: string
count: number
}
/** Freeform memory rendered as a card — never a graph node. */
export interface StarmapMemoryCard {
source: 'memory' | 'profile'
timestamp?: null | number
title: string
body: string
}
export interface StarmapGraph {
nodes: StarmapNode[]
edges: StarmapEdge[]
clusters: StarmapCluster[]
memory: StarmapMemoryCard[]
stats: Record<string, unknown>
}
export interface ContextUsageCategory {
color: string
id: string

View file

@ -2,6 +2,28 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import fs from 'fs'
// `hgui` symlinks a worktree's node_modules to the main checkout. Vite realpaths
// those before enforcing server.fs.allow, so codicon/font assets resolve outside
// the worktree root and 404. Whitelist the real node_modules locations.
const real = (p: string): string | null => {
try {
return fs.realpathSync(p)
} catch {
return null
}
}
const fsAllow = [
...new Set(
[
path.resolve(__dirname, '../..'),
real(path.resolve(__dirname, 'node_modules')),
real(path.resolve(__dirname, '../../node_modules'))
].filter((p): p is string => p !== null)
)
]
export default defineConfig({
base: './',
@ -47,7 +69,10 @@ export default defineConfig({
server: {
host: '127.0.0.1',
port: 5174,
strictPort: true
strictPort: true,
fs: {
allow: fsAllow
}
},
preview: {
host: '127.0.0.1',

View file

@ -2452,6 +2452,23 @@ async def run_curator():
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
@app.get("/api/learning/graph")
async def get_learning_graph(profile: Optional[str] = None):
"""Learning graph payload for the desktop panel.
Profile-scoped view of learned, non-base skills plus memory chunks, with
graph links derived from skill relations and memory-skill overlap.
"""
try:
from agent.learning_graph import build_learning_graph
with _profile_scope(profile):
return build_learning_graph()
except Exception:
_log.exception("GET /api/learning/graph failed")
raise HTTPException(status_code=500, detail="Failed to build learning graph")
def _safe_call(mod, fn_name: str, default):
try:
fn = getattr(mod, fn_name, None)

9
package-lock.json generated
View file

@ -95,8 +95,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",
@ -132,6 +134,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",
@ -10918,6 +10921,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",

View file

@ -0,0 +1,108 @@
"""Behavior contracts for the learning-graph assembler.
Asserts invariants (edges resolve to real nodes, clusters cover every node,
memory cards are represented consistently), never a snapshot of the live skill
catalog that catalog grows every release and a count assertion would be a
change-detector.
"""
from __future__ import annotations
from agent import learning_graph
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
def _node(name: str, category: str, related=None):
n = learning_graph.SkillNode(name=name, category=category)
n.related = list(related or [])
return n
def test_edges_only_connect_existing_nodes():
nodes = {
"a": _node("a", "x", related=["b", "ghost"]),
"b": _node("b", "x", related=["a"]),
"c": _node("c", "y"),
}
edges = learning_graph.build_edges(nodes)
# The a→b link is kept once (deduped, undirected); a→ghost is dropped.
assert edges == [("a", "b")]
def test_density_stats_count_isolated_nodes():
nodes = {
"a": _node("a", "x", related=["b"]),
"b": _node("b", "x", related=["a"]),
"c": _node("c", "y"),
}
stats = learning_graph.density_stats(nodes, learning_graph.build_edges(nodes))
assert stats["nodes"] == 3
assert stats["linked_nodes"] == 2
assert stats["isolated_pct"] == round(100 / 3, 1)
def test_skill_node_timestamp_uses_iso_usage_activity(tmp_path, monkeypatch):
skill_dir = tmp_path / "skills" / "dev" / "iso-skill"
skill_dir.mkdir(parents=True)
skill_md = skill_dir / "SKILL.md"
skill_md.write_text("---\nname: iso-skill\ncategory: dev\n---\n# ISO\n", encoding="utf-8")
monkeypatch.setattr(
learning_graph,
"_load_usage",
lambda: {
"iso-skill": {
"created_by": "agent",
"last_used_at": "2026-04-30T12:00:00+00:00",
"use_count": 1,
}
},
)
nodes = learning_graph.build_skill_nodes([("profile", tmp_path / "skills")])
assert nodes["iso-skill"].timestamp == 1_777_550_400
def test_memory_is_cards_split_on_separator(tmp_path):
home = tmp_path / ".hermes"
(home / "memories").mkdir(parents=True)
(home / "memories" / "MEMORY.md").write_text(
"Project uses pytest with xdist\n§\nUser prefers concise responses",
encoding="utf-8",
)
token = set_hermes_home_override(home)
try:
graph = learning_graph.build_learning_graph()
finally:
reset_hermes_home_override(token)
titles = [c["title"] for c in graph["memory"]]
assert "Project uses pytest with xdist" in titles
assert "User prefers concise responses" in titles
# Memory cards remain typed cards and also appear as memory-kind nodes.
assert all(c["source"] in {"memory", "profile"} for c in graph["memory"])
assert all("timestamp" in c for c in graph["memory"])
assert any(n["kind"] == "memory" for n in graph["nodes"])
def test_full_payload_shape_and_edge_integrity(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
token = set_hermes_home_override(home)
try:
graph = learning_graph.build_learning_graph()
finally:
reset_hermes_home_override(token)
ids = {n["id"] for n in graph["nodes"]}
assert all(e["source"] in ids and e["target"] in ids for e in graph["edges"])
# Every node's category appears in the cluster list.
cluster_cats = {c["category"] for c in graph["clusters"]}
assert all(n["category"] in cluster_cats for n in graph["nodes"])
skill_nodes = [n for n in graph["nodes"] if n["kind"] == "skill"]
assert graph["stats"]["nodes"] == len(skill_nodes)
assert graph["stats"]["memory_nodes"] == len(graph["memory"])
assert all("timestamp" in n for n in graph["nodes"])