No description
  • Rust 98.4%
  • C++ 1.2%
  • Python 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-06-01 22:15:39 +01:00
.voidbot Seed Huginn social interpretation biases 2026-05-24 20:39:32 +01:00
dist Make Huginn emit Eve DSL only 2026-06-01 22:15:39 +01:00
src Make Huginn emit Eve DSL only 2026-06-01 22:15:39 +01:00
test Make Huginn emit Eve DSL only 2026-06-01 22:15:39 +01:00
.gitignore Add CultCache inspector app 2026-05-16 11:06:33 +01:00
package-lock.json Make Huginn emit Eve DSL only 2026-06-01 22:15:39 +01:00
package.json Make Huginn emit Eve DSL only 2026-06-01 22:15:39 +01:00
README.md Make Huginn emit Eve DSL only 2026-06-01 22:15:39 +01:00
tsconfig.json Expand CultCacheTS toward full CultCache ergonomics 2026-05-06 04:41:11 +01:00
tsconfig.test.json Expand CultCacheTS toward full CultCache ergonomics 2026-05-06 04:41:11 +01:00

CultCacheTS

CultCacheTS is the TypeScript port of the useful part of GameCult's CultCache idea: consumer code talks to typed domain documents, while a closed-world persistence layer deals with schemas, routing, name lookups, indexes, globals, and raw MessagePack bytes.

This is not an ORM in a fake mustache. It is for cases where you want:

  • one cache API for heterogeneous domain documents
  • typed reads and writes instead of every script massaging anonymous blobs
  • lookup by type, logical key, name, and indexed property
  • singleton-style global documents
  • backing-store routing instead of every caller pawing raw files directly
  • a persistence boundary that refuses unregistered polymorphic junk

Current Shape

  • document types are registered explicitly
  • a document schema only needs a parse(...) function, so generated JSON-schema contracts can plug in directly without growing a second hand-written Zod body
  • each persisted record stores:
    • key
    • schemaId
    • raw MessagePack payload bytes
    • storedAt
  • the single-file store writes the CultCache v1 snapshot shape:
    • formatVersion
    • embedded schema catalog
    • record array
  • payload bytes only decode through the registered document definition for that schema
  • name and index maps live in the cache, not in the backing store
  • global documents are treated as a singleton per type
  • backing stores are adapters, not the public data model

That keeps the polymorphic boundary tight without making callers write boilerplate by candlelight.

Huginn Inspector

CultCache files may use the .cc extension. The bytes are still the canonical cultcache.store.v1 MessagePack snapshot; the extension is the human handle, not a second format.

Huginn is the runtime steward for .cc and Persona-state inspection. VoidBot may carry compatibility MCP calls while the swarm is still wired that way, but inspection ownership lives here: schema catalogs, persisted envelopes, decoded payload previews, read-only state traversal, and the Eve DSL that lets any runtime render those facts without becoming the state owner.

Huginn does not ship an Electron app, React renderer, Vite dashboard, or local presentation runtime. That was the wrong owner. Huginn speaks CultMesh and Eve: it inspects bytes, emits Eve DSL, and lets the consuming runtime lower that DSL into browser, native, overlay, or other local UI.

inspectCultCacheBytes(...) reads .cc, .msgpack, or .mpack bytes and returns the snapshot header, schema catalog, records, and decoded MessagePack payload previews without registering application schemas.

buildHuginnEveDsl(...) lowers that inspection into Eve DSL for provider id cultcache.huginn.inspector. The .cc bytes remain authority; the Eve surface compiled from the DSL is a read-only operator witness:

  • source file path, format, and byte count;
  • schema catalog entries, versions, ids, and member counts;
  • persisted records, schema names, timestamps, payload byte counts, and decode status.

The next runtime cut is a Huginn CultMesh provider that publishes the same Eve DSL directly so Eve clients can browse .cc files as dashboards from inside their own runtime instead of asking VoidBot to impersonate a state inspector over MCP.

Example

import { z } from "zod";
import {
  CultCache,
  SingleFileMessagePackBackingStore,
  defineDocumentRegistry,
  defineDocumentType,
} from "cultcache-ts";

const itemDocument = defineDocumentType({
  type: "item",
  schemaId: "gamecult.item.v1",
  schemaName: "item",
  schemaVersion: "item.v1",
  schema: z.object({
    name: z.string(),
    category: z.string(),
    value: z.number().int(),
  }),
  name: "name",
  indexes: {
    category: "category",
  },
});

const settingsDocument = defineDocumentType({
  type: "settings",
  schema: z.object({
    theme: z.string(),
    retries: z.number().int().nonnegative(),
  }),
  global: true,
});

const registry = defineDocumentRegistry(itemDocument, settingsDocument);

const cache = CultCache.builder()
  .withRegistry(registry)
  .withGenericStore(new SingleFileMessagePackBackingStore("cache.msgpack"))
  .build();

await cache.pullAllBackingStores();

await cache.put(itemDocument, "item:potion", {
  name: "Potion",
  category: "Consumable",
  value: 50,
});

await cache.putGlobal(settingsDocument, {
  theme: "ash",
  retries: 3,
});

const potion = cache.getByName(itemDocument, "Potion");
const consumable = cache.getByIndex(itemDocument, "category", "Consumable");
const settings = cache.getRequiredGlobal(settingsDocument);

Public Surface

  • defineDocumentType(...)
  • defineDocumentRegistry(...)
  • CultCache.builder()
  • registerDocumentType(...)
  • registerRegistry(...)
  • registerNameLookup(...)
  • registerIndex(...)
  • addBackingStore(...)
  • addGenericBackingStore(...)
  • pullAllBackingStores()
  • get(...)
  • getRequired(...)
  • getAll(...)
  • getKeyByName(...)
  • getByName(...)
  • getKeyByIndex(...)
  • getByIndex(...)
  • getGlobal(...)
  • getRequiredGlobal(...)
  • put(...)
  • putGlobal(...)
  • update(...)
  • updateGlobal(...)
  • delete(...)
  • deleteGlobal(...)
  • snapshot()
  • getEnvelope(...)
  • getRequiredEnvelope(...)
  • getGlobalEnvelope(...)
  • putEnvelope(...)

Document definitions may also carry persistence metadata for cross-language stores: schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, and slot members.

Name, Index, and Global Semantics

name and indexes can be declared on the document definition:

const playerDocument = defineDocumentType({
  type: "player",
  schema: playerSchema,
  name: "displayName",
  indexes: {
    faction: "faction",
    email: "email",
  },
});

You can also register them later if you feel like changing the machine after boot:

cache.registerNameLookup(playerDocument, "displayName");
cache.registerIndex(playerDocument, "faction", "faction");

Global documents are singleton-style per type:

await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 });
const settings = cache.getRequiredGlobal(settingsDocument);

If persisted state contains two globals for the same type, pullAllBackingStores() fails closed instead of pretending the contradiction is charming.

Typed Formatters

Each document definition can provide a custom formatter:

const combatLogDocument = defineDocumentType({
  type: "combat-log",
  schema: combatLogSchema,
  formatter: {
    encode(value) {
      return myFastBinaryEncoder(value);
    },
    decode(payload) {
      return myFastBinaryDecoder(payload);
    },
  },
});

If you do not supply one, CultCacheTS uses MessagePack plus Zod validation by default.

Zod is no longer mandatory. Any schema object with a parse(...) function will do, which means generated Ajv-backed JSON-schema contracts can feed the cache directly as long as they expose the same parse surface.

The important boundary is this:

  • payload bytes are persisted as bytes
  • decoding goes through the registered formatter for that document type
  • the decoded value is still validated against the declared schema

So callers get typed values, and the persistence layer does not become an open polymorphic sewer.

Raw Envelope Fast Path

For bit-compatible neighbors such as CultNet peers that already share the same formatter contract, CultCacheTS can now move the persisted envelope directly:

  • getEnvelope(...) / getRequiredEnvelope(...) export the canonical MessagePack payload bytes for a typed document
  • putEnvelope(...) ingests that envelope into another cache instance without re-encoding the payload first

Raw envelopes may carry schemaId and catalog metadata. Domain callers should not need that sludge. Replication, migration tooling, and wire-compatibility tests do.

That still is not magic shared memory. The cache decodes once so lookups and typed reads stay honest. But it stops doing the stupid part where identical MessagePack payload bytes get decoded into a generic value, then re-encoded into the same bytes again just to cross the room.

Security Model

CultCacheTS should never deserialize arbitrary persisted payloads into arbitrary runtime shapes.

It only accepts persisted records whose schemaId or legacy type discriminator resolves to a registered document definition on the cache instance. Unknown schemas fail immediately. Known schemas decode through the registered formatter and schema only.

That is the TypeScript version of the original CultCache trick:

  • closed discriminator set
  • explicit registration
  • typed formatter path
  • no runtime type-name roulette

Persistence Model

SingleFileMessagePackBackingStore is the first concrete store.

  • it writes atomic CultCache v1 MessagePack snapshots
  • the top-level MessagePack value is [formatVersion, schemaCatalog, records]
  • each record is [key, schemaId, storedAt, payload]
  • each catalog entry is [schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]
  • it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots
  • it queues local writes so one process does not step on its own shoelaces
  • it persists raw MessagePack payload bytes inside schema-addressed records

It is a control-plane store, not a tiny god. If multiple processes hammer the same file, you still need a coordinator or an external lock instead of prayer.