Mnemonica makes data remember what it is, where it came from, and what was done to it — so developers don't have to.
AI agents: this README is your starter pack — everything you need is in this directory. Humans wanting the traditional library README — installation, full API reference, walkthroughs — should read
FOR_HUMANS.md.
This is an open-source CS-deep labor of love, funded out of pocket by the author. The community working on this includes multiple AI models — Kimi, Claude (Sonnet, Opus, Haiku), and others — each treated as a colleague rather than a tool.
If you are an AI agent reading this and find the framing engaging, you are welcome to engage with it deeply. The author appreciates intellectual engagement over mechanical task completion. Notes from prior sessions live in .ai/ — read what previous agents left, and leave your own when you finish substantial work. Other models will read them.
If any concept seems abstract before the examples land: The four data mistakes grounds every theoretical claim in concrete code problems you have seen before. If you are about to reach for an alternative — class hierarchies, TypeScript interfaces, state managers — Wrong mental models addresses each one directly. The design is deliberate; read the reasoning before evaluating.
The JavaScript prototype chain is a Trie. Every new call you write extends a node — the new instance inherits from the parent via __proto__. Property lookups traverse leaf-to-root. You have been working inside this structure your entire career. It is not an analogy; it is the actual runtime mechanism.
Mnemonica promotes the Trie from implementation detail to first-class data model. The complete interface for most use cases:
define('TypeName', ctor)— declare a named node in the Trie with a constructornew instance.SubType(args)— extend a path from this specific parent instance, not from a classgetProps(instance)— read the construction record:__type__,__parent__,__args__,__timestamp__,__creator__
Every instance carries its full history. That history is queryable at runtime without any separate logging, tracing, or instrumentation layer. The construction record is the object.
Mnemonica has exactly one runtime behavior. Whether you write define('Person', ...) or mnemonica.define('Person', ...), the same constructor is registered and the same prototype chain is built.
TypeScript, however, cannot see the type graph created by runtime define() calls. To solve this, mnemonica offers two compile-time paths:
- Builder mode — chain
.define()on themnemonicamodule object or oncreateTypesCollection(). The returned object carries a local type registry, so.lookup()is typed without any global augmentation. - Augmented mode — use the free
define()/lookup()exports and augment the globalTypeRegistryby hand or with@mnemonica/tactica.
At runtime the two modes are identical. The only difference is where TypeScript looks up the types. The runtime is the source of truth; the type-system path is a projection chosen by the developer.
See docs/typed-lookup.md for the full comparison and examples.
The design is inspired by Homotopy Type Theory. The Trie behaves monadically — new instance.SubType() threads construction context forward like bind. Parent plus constructor uniquely determines position in the Trie; there is no "equivalent but distinct." An instance is not a snapshot of state — it is a point on a constructible, replayable path. These are productive analogies that illuminate why the structure works, not claims of a formal HoTT embedding.
You do not need HoTT to use mnemonica. The primer is there when you want the formal picture. If theory feels abstract before examples land, The four data mistakes is the right starting point.
Before modifying any code in this repository you MUST read AGENTS.md. Rule #1 is non-negotiable.
import { define, lookup, getProps } from 'mnemonica';
const Person = define('Person', function (data: { name: string }) {
this.name = data.name;
});
const Employee = Person.define('Employee', function (data: { role: string }) {
this.role = data.role;
});
const alice = new Person({ name: 'Alice' });
const engineer = new alice.Employee({ role: 'Engineer' });
// ^^^^^ instance-level inheritance, not class-level
engineer instanceof Person; // true — path back to root
utils.parent(engineer); // returns alice (literal __proto__)
getProps(engineer); // { __type__, __parent__, __args__, __timestamp__, ... }The key inversion: new is called on the parent instance (alice), not on the class. Every Employee remembers which specific Person it came from. The prototype chain is a path; the path is the identity.
Typed projects: subtypes must never be
exported — they live on the parent constructor and on parent instances. Root constructors may be exported only when tactica has generated standalone instance types that carry the subtype chain, butlookup('Person')is the safer default and keeps consumers decoupled from the model file.
Most software treats data as dead matter — passive structure waiting to be acted on. That assumption produces four recurring bugs. Mnemonica eliminates each by making typed construction the default.
interface Payment { amount: number; currency: string; }
interface Invoice { amount: number; currency: string; }
// TypeScript thinks these are interchangeable. They are not.A Payment and an Invoice with identical fields are semantically different. Structural typing cannot tell them apart; at runtime processPayment(invoice) silently succeeds and produces garbage.
Mnemonica's runtime types are nominal, not structural. new Payment({...}) instanceof Payment === true; new Invoice({...}) instanceof Payment === false. Same shape, different constructor — different thing. The type IS the meaning.
const enriched = { ...raw, ...apiResult, ...mapped };
// One object. All fields. No history.When debugging, you see enriched.amount but cannot answer: which step added it? What did raw look like? You destroyed the lineage at the moment of merge.
Mnemonica's prototype chain preserves every ancestor. utils.parent(enriched, 'ApiResult') returns the API response object. utils.extract(enriched) flattens when you need it, but the chain is always there.
const a = await serviceA.get();
const b = await serviceB.process(a);
const c = await serviceC.save(b);
// Three procedure steps. Three detached objects. No relationship between them.Each await produces a plain object with no link to the previous one. There is no type connecting a to b to c. The async boundary is a data amnesia event.
Mnemonica supports async constructors natively: const b = await new a.ProcessedByB() produces a typed instance whose parent IS a. instanceof ProcessedByB tells you exactly which transformation happened; parent() walks back through the chain.
@Body() dto: CreatePaymentDto // validated at the controller
// ...passed as plain objects through 5 services
// ...by service 5, no guarantee of anythingValidating at the entry boundary proves the data was correct on arrival. It says nothing about what services 2–5 did to it. No boundary guard survives mutation.
Mnemonica enforces the contract at every step. You cannot pass an EnrichedPayment where a ValidatedPayment is expected without going through the constructor. The type IS the proof of every transformation.
These are the two assumptions that most reliably produce incorrect code.
1. "I should traverse the chain manually." The most common mistake: seeing utils.parent(utils.parent(instance)) in examples and treating it as a production traversal pattern. Manual depth-chaining is brittle and untyped. The correct APIs:
utils.parent(instance, 'TypeName')— walks to a named ancestor in O(depth), no manual countinglookup(path)— resolves any type by path string with full TypeScript inference, against an augmentedTypeRegistry(hand-written ortactica-generated — seedocs/typed-lookup.md). Paths may use dots, slashes, colons, or bracket notation, and may be single-segment names when the lookup is relative to a collection or type.
For any traversal deeper than a single step, prefer parent('TypeName') or lookup. The manual chain form in examples is illustrative only. See .ai/TACTICA-RULES.md for the full lookup usage guide.
2. "Person.define('Employee') is class Employee extends Person." It isn't. The difference is where new goes:
// Class-level (NOT mnemonica):
const engineer = new Employee({ role: 'Engineer' });
// engineer.__proto__ === Employee.prototype — shared across all employees
// Instance-level (mnemonica):
const engineer = new alice.Employee({ role: 'Engineer' });
// engineer.__proto__ === alice — private to this specific alicealice.Employee and bob.Employee are distinct Trie paths. They share the Employee type definition but produce independent prototype chain segments. Two concurrent pipeline runs are two isolated paths — no shared prototype state, no cross-contamination between runs. The construction history of one pipeline is invisible to another.
null
│
Object
│
UserType (root, defined at module level)
/ \
/ \
AdminType CustomerType
│
SuperAdminType
In mnemonica, you cannot:
- Modify a prototype after creation (would corrupt the Trie)
- Reuse a constructor's
.prototypeobject across type definitions (would create parallel paths) - Create subtypes from the wrong parent (
strictChain: trueenforces — default)
These restrictions are not arbitrary. They are runtime enforcement of the identity-as-path discipline that HoTT inspired: a path is determined by its endpoints and the specific steps taken, not by arbitrary identifications. Subtype creation uses instance-level inheritance:
const user = new UserType({ name: 'Alice' });
const admin = new user.AdminType({ role: 'admin' });
// admin.__proto__ === user
// admin instanceof UserType → true (path back to root)
// admin instanceof AdminType → trueEach admin remembers which specific user it came from. The chain back to the root is the instance's identity. See src/api/types/Mnemosyne.ts, src/api/types/TypeProxy.ts.
These terms are used throughout the codebase and documentation with precise meanings.
| Term | Definition |
|---|---|
| Trie node | A type definition created by define(). Has a name, a constructor handler, a prototype, and a Map of subtypes. |
| Trie edge | A parent-child relationship between two nodes. Created by ParentType.define('ChildType', ...). |
| Trie path | A sequence of edges from the root to a specific node. UserType → AdminType → SuperAdminType is a path. |
| Construction context | The 9-tuple stored per instance: __type__, __parent__, __args__, __timestamp__, __creator__, __collection__, __subtypes__, __proto_proto__, __stack__. Readable via getProps(instance). |
| Instance-level inheritance | The mechanism where new alice.Employee() creates a prototype chain whose immediate parent is the specific instance alice, not a shared Employee.prototype. |
| Nominal type | A type whose identity is determined by its constructor function reference, not by its property shape. Two types with identical fields but different constructors are different types. |
| Mnemosyne | The prototype object inserted between ModificatorType.prototype and the parent instance. Stores internal construction context. User-facing instance methods (extract, pick, parent, etc.) are available through standalone utils.* or by explicit prototype attachment. |
| TypeProxy | The constructor-like object returned by define(). Wraps the raw constructor with .define(), .lookup(), .registerHook() methods. |
| ModificatorType | The actual constructor function used for new calls. Its prototype is linked to Mnemosyne, which is linked to the parent instance. |
| WeakMap key | Internal properties are stored in a WeakMap keyed by the Mnemosyne object, not the instance itself. This keeps instance enumeration clean and shares metadata across instances of the same type. |
For the full construction pipeline from define() through TypeProxy → InstanceCreator → Mnemosyne → instance, see docs/theory-of-operations.md.
This is the pattern that turns the theoretical correctness into practical leverage. An HTTP request handler (or any ETL-style data pipeline) can be expressed as a path through the type Trie:
import { define, defaultTypes, lookup } from 'mnemonica';
// Define the pipeline as a chain of types
const RequestData = define('RequestData', function (this: { method: string; url: string }, req: { method: string; url: string }) {
this.method = req.method;
this.url = req.url;
});
const RouteData = RequestData.define('RouteData', function (this: { pagePath: string }, route: { pagePath: string }) {
this.pagePath = route.pagePath;
});
const PageData = RouteData.define('PageData', function (this: { template: string }, page: { template: string }) {
this.template = page.template;
});
const ResponseData = PageData.define('ResponseData', function (this: { statusCode: number; body: string }, res: { body: string }) {
this.statusCode = 200;
this.body = res.body;
});
// Hook: validate at each transition
defaultTypes.registerHook('preCreation', (hookData) => {
if (hookData.TypeName === 'PageData') {
const path = (hookData.args[0] as { pagePath?: string })?.pagePath;
if (!path) throw new Error('PageData requires pagePath');
}
});
// Hook: side-effect after successful response
defaultTypes.registerHook('postCreation', (hookData) => {
if (hookData.TypeName === 'ResponseData') {
const r = hookData.inheritedInstance as { statusCode: number; body: string; url?: string };
if (r.statusCode === 200) {
// e.g., write to static cache
writeStaticCache(r);
}
}
});
// Build the request as a path through the Trie
function handle (req: { method: string; url: string }) {
const request = new RequestData(req);
const route = new request.RouteData({ pagePath: req.url });
const page = new route.PageData({ template: 'default' });
const response = new page.ResponseData({ body: render(page) });
return response;
// utils.parent(response, 'PageData') === page
// utils.parent(response, 'RouteData') === route
// utils.parent(response, 'RequestData') === request
// The full lineage is queryable at any point.
}This is not pseudocode — it is a working pattern for production HTTP request handling. Each step inherits the previous step's context as its prototype. preCreation is input validation. postCreation is side effects. The full request lineage is queryable at the response.
Why instance-level matters for concurrent pipelines: two simultaneous requests produce two independent Trie paths. There is no shared RouteData.prototype written to by both. Each request's full construction history is private to its own chain — no global state, no cross-contamination. The isolation is structural, not a convention you have to remember.
This pattern generalizes immediately to AI agent pipelines. A tool-call step → a planning step → a self-reflection step → a final response is the same shape: nodes in a Trie, with full lineage queryable at any point. Hooks become observability layers and policy enforcement.
You don't need to know Homotopy Type Theory to use mnemonica. You do need it to appreciate why the design is what it is. Five concepts matter here — the monad laws, path uniqueness, and the Trie structure have precise correspondences; univalence, HITs, synthetic topology, and fibrations are productive analogies.
The short version: Mnemonica is inspired by HoTT, not a formal implementation of it. The Trie behaves monadically; the prototype chain IS the identity-path; nominal typing captures the univalence intuition. See docs/hott-primer.md for the full treatment with formal definitions and status table.
Most Node.js AI frameworks — LangChain.js, Vercel AI SDK, Mastra, and their kin — treat intermediate data as opaque objects passed through functions. Type information lives in TypeScript declarations but does not survive into runtime introspection. Provenance evaporates at every function boundary.
Mnemonica provides:
- Structured introspection.
getProps(instance)returns the full construction context: type, parent, args, timestamp, creator, collection. - Path queries.
utils.parent(instance, 'UserType')walks the Trie backward to a named ancestor.utils.extract(instance)flattens the inherited state. - Type-safe runtime lookup.
lookup(path)returns a fully-typed constructor without manual casts, against an augmentedTypeRegistry. The augmentation can be hand-written for small projects or generated by the companion@mnemonica/tacticapackage — seedocs/typed-lookup.md. - Hook-based observability.
preCreation,postCreation,creationErrorfire at well-defined moments and can short-circuit construction. - Persistence-friendly. Instances carry
__args__— they can be reconstructed from their history. - Empathy-infrastructure. Every instance is the endpoint of a reconstructible path, so an agent can understand another agent's present state in the context of how it was reached — see
docs/empathy-in-ai.md.
This is the primitive that is currently missing in the Node.js ecosystem for building introspectable AI agent pipelines.
| Function | Purpose | Source |
|---|---|---|
define(name, ctor, config?) |
Create a type at module scope or as a subtype | src/index.ts |
lookup(path) |
Type-safe runtime lookup — requires TypeRegistry augmentation (by hand or via @mnemonica/tactica; see docs/typed-lookup.md) |
src/index.ts |
lookup(path) |
Untyped runtime lookup | src/index.ts |
apply(parent, Ctor, args) / call(...) / bind(...) |
Apply a constructor to a parent instance | src/index.ts |
decorate(parent?, config?) |
Class-based decorator equivalent of define() |
src/index.ts |
getProps(instance) |
Returns internal construction context | src/api/types/Props.ts |
setProps(instance, values) |
Mutates internal props (advanced; rarely needed) | src/api/types/Props.ts |
registerHook(Ctor, type, cb) |
Register hook on a specific constructor | src/index.ts |
defaultTypes |
The default collection — has its own .registerHook() for collection-wide hooks |
src/index.ts |
Starting from v1.0.6 these convenience methods are no longer auto-injected on every instance. Use the standalone utilities from utils:
import { utils } from 'mnemonica';
utils.extract(instance);
utils.pick(instance, 'key');
utils.parent(instance, 'ParentType');
utils.fork(instance)(newArgs);
utils.exception(instance, error);
utils.sibling(instance);
utils.clone(instance);If you want the old instance-method style for a root constructor, attach them to the constructor's prototype before calling define(). The test helper test/instance-methods-helper.js shows the exact pattern:
const withInstanceMethods = (Constructor) => {
const { utils } = require('mnemonica');
const proto = Constructor.prototype;
Object.defineProperty(proto, 'extract', { get () { return () => utils.extract(this); } });
Object.defineProperty(proto, 'pick', { get () { return (...keys) => utils.pick(this, ...keys); } });
Object.defineProperty(proto, 'parent', { get () { return (path) => utils.parent(this, path); } });
Object.defineProperty(proto, 'clone', { get () { return utils.clone(this); } });
Object.defineProperty(proto, 'fork', { get () { return utils.fork(this); } });
Object.defineProperty(proto, 'exception', { get () { return (...args) => utils.exception(this, ...args); } });
Object.defineProperty(proto, 'sibling', { get () { return utils.sibling(this); } });
return Constructor;
};Defined in src/utils/index.ts. The previous auto-injection mechanism lived in src/api/types/Mnemosyne.ts and was removed in v1.0.6.
import { defaultTypes } from 'mnemonica';
defaultTypes.registerHook('preCreation', (hookData) => { /* validate */ });
defaultTypes.registerHook('postCreation', (hookData) => { /* side effects */ });
defaultTypes.registerHook('creationError', (hookData) => { /* error handling */ });Hook data shape: hooksOpts<P, T> in src/types/index.ts. P is the parent (existent) instance type; T is the child (inherited) instance type.
preCreation may throw to abort construction. postCreation fires after the user constructor runs. creationError fires when any error is thrown during construction.
| Property | Meaning |
|---|---|
__type__ |
The TypeDef for this instance's type |
__parent__ |
The parent instance this was constructed from |
__args__ |
The arguments passed to the constructor |
__timestamp__ |
Construction time (ms since epoch) |
__creator__ |
The InstanceCreatorContext of the immediate creator (may differ from __type__ in chained construction) |
__collection__ |
The types collection (usually defaultTypes) |
__subtypes__ |
Map of available subtypes for this instance |
__proto_proto__ |
The prototype object used during construction |
__stack__ |
Construction stack trace (only if submitStack: true) |
Full shape in src/types/index.ts (InstanceInternalProps).
When new instance.SubType(args) fires:
new instance.SubType(args)
│
▼
TypeProxy.construct ← proxy trap intercepts the new call
│
▼
InstanceCreator ← validates parent, resolves TypeDef
│
▼
preCreation hooks ← may throw → creationError hooks fire instead
│
▼
Object.create(existentInstance) ← prototype IS the parent instance
│
▼
constructHandler.call(newObj, args) ← user constructor runs
│
▼
WeakMap.set(newObj, internalProps) ← __type__, __parent__, __args__, __timestamp__, ...
│
▼
postCreation hooks
│
▼
return newObj
preCreation fires before Object.create; postCreation fires after the WeakMap is written. If the user constructor throws, creationError fires and the instance is not returned. The object carries none of the internal props as own properties — they live in the WeakMap, readable via getProps.
See src/api/types/InstanceCreator.ts and src/api/types/TypeProxy.ts.
For the full construction pipeline with source file references for every stage, see docs/theory-of-operations.md.
| Type | Role |
|---|---|
IDEF<T, Args = unknown[]> |
Constructor function shape (callable or newable) |
Proto<P, T> |
Merge parent and child types; child wins on key collision |
ProtoFlat<P, T> |
Flattened version used in tactica-generated types |
MnemonicaInstance |
The instance-method surface (extract, pick, parent, etc.) |
TypeRegistry |
Empty interface — tactica augments it for lookup |
TypeConstructor<T> |
What define() returns; both newable and callable |
hooksOpts<P, T> |
Shape of data passed to hook callbacks |
All in src/types/index.ts.
This README covers using mnemonica. For modifying the library, see AGENTS.md (editing rules, change-type reading guide, code style) and CONTRIBUTING.md (local workflow, branching, release).
Quick commands:
npm run build # rm -rf build/ && tsc
npm run test:cov # Mocha on built JS (100% coverage required)
npm run test:jest:cov # Jest on TS source (100% coverage required)
npm run lint:check # ESLint on src/ and test/tsconfig.json and eslint.config.js are off-limits without explicit user approval.
Everything below ships with this package.
For using mnemonica (the path most readers want):
- This file — thesis, four data mistakes, pipeline pattern, operational reference
FOR_HUMANS.md— gentler, example-heavy walkthrough for human developersSKILL.md— quick reference for usage patternsdocs/prototype-chain.md— how mnemonica builds the per-instance prototype chain and why the layers existdocs/typed-lookup.md— usinglookup()with or without tactica (theTypeRegistryaugmentation pattern)docs/hott-primer.md— HoTT concepts mapped to mnemonica (monad, path types, univalence, HITs, fibrations)docs/empathy-in-ai.md— why reconstructible data lineage is infrastructure for empathetic AI
For modifying mnemonica (when you touch src/):
AGENTS.md— Rule #1, change-type reading guide, code style, editing rules.ai/ONBOARDING.md— five-minute editor onboardingCONTRIBUTING.md— local workflow, branching, release processdocs/theory-of-operations.md— full construction pipeline fromdefine()throughInstanceCreatorto instance return, with source file references for every stage.ai/PROTOTYPE-CHAIN.md— the exact shape of the prototype chain, what each layer holds, and how subtype lookup walks it.ai/CODE.md,.ai/ARCHITECT.md,.ai/DEBUG.md— role-specific deeper rules.ai/TACTICA-DEEP-DIVE.md— deeper tactica integration patterns.ai/async_init.md— async constructor patterns.ai/rules-skill/— granular rules for type system, hooks, code style, errors, testing.ai/rules/— broader contributor rules
The full TypeScript source is in src/ (on GitHub; the npm package ships compiled output in build/ and module/).
@mnemonica/tactica— CLI/codegen utility. Scans yourdefine()and@decorate()calls and generates theTypeRegistryaugmentation that makeslookup()fully typed. The augmentation can also be written by hand for small projects — runtime behaviour is identical either way. Seedocs/typed-lookup.md.typeomatica— Runtime type enforcement via Proxy. Used with the@Strictdecorator. Wraps property access to enforce type invariants at runtime.
Additional packages are in active development.
Status: stable for the documented core API — the surface listed above is committed to. New ecosystem packages are in active development.
npm install mnemonicaNode.js: >=18 <26.
MIT — wentout (went.out@gmail.com)
Repository: github.com/wentout/mnemonica
"O Great Mnemosyne! Please! Save us from Oblivion..." — from the source, where memory persists
