hermes-agent/apps/desktop/src/lib/loadout.ts
Brooklyn Nicholson dec44994a5 feat(desktop): Memory Graph — playable radial timeline of memories + skills
A top-down Memory Graph panel: memories and skills on a radial time axis
(core = oldest, outer rings = newer) with a playable / scrubbable timeline
that builds the map up over time.

- Reveal lives off the React tree (a ref drives the canvas, a nanostore atom
  drives the timeline + legend), so a play-through or scrub never re-renders
  the panel; paint is coalesced to one rAF and playback is abortable, so even
  frantic scrubbing stays responsive.
- Adaptive dated rings: one equal-width ring per POPULATED calendar bucket,
  a "nice-tick" count scaled to the span. Constant (orthographic) core/band
  scale — more data grows the disk outward (more rings), never thinner.
- A bucket's nodes fill the band inside their ring and ignite staggered by
  real timestamp across it (no end-dump), with an EVE-style warp-in; the
  camera steps out band-by-band as rings are reached.
- ASCII "computing" core, theme-aware palette with a distinct memory hue,
  shared trackpad-gesture primitives.
- Shareable WoW-style "loadout" codes on a generic, reusable codec
  (@/lib/loadout: bitstream + DEFLATE + version/checksum frame + base64url).
- Opens from the statusbar and command palette; i18n across all locales.

Deps: d3-force, fflate (drops unused react-force-graph-2d).
2026-06-30 00:54:21 -05:00

277 lines
7.6 KiB
TypeScript

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