animus-sdk
v2.1.3
Published
Persistent affective state engine for AI characters and LLM agents. Physics-based emotional continuity via homeostasis, Ornstein-Uhlenbeck noise, circadian rhythms, and real elapsed-time simulation.
Maintainers
Readme
animus-sdk
The affective state engine for AI characters. Memory tells your agent what it knows — Animus tells it how it is.
▶ Live playground — no install needed · runs the same engine.js that ships in Node
"You're steady; low-energy, quiet; interested; fond; on task. It's late evening, a low-energy stretch of your day." — compiled at 2:49 AM from the circadian engine reading the real clock
The category gap
The agent-memory market is solved and crowded — Mem0, Letta, Zep, Graphiti all give your agent facts: who the user is, what was said, what changed. Plug one in and your agent remembers everything.
And it still feels dead.
Because memory is not the missing layer. Affect is. A character with perfect recall and no internal state is a database with a voice. What makes a companion, an NPC, or an assistant feel alive isn't what it knows — it's that it has a now: a mood that today's events pushed off baseline, energy that follows a daily rhythm, a state that was different yesterday and will drift again tomorrow.
No memory layer models that. Animus is the layer that does.
What it knows → memory layer (Mem0, Letta, Zep — keep yours)
How it is right now → ANIMUS (this package)
How it speaks → the LLM (any vendor, swappable)Animus is not a memory product and doesn't compete with one. It composes with all of them.
The Pattern
The state engine (soul) and the LLM (mouth) are different systems, connected by one narrow compiled interface.
State Engine (local, persistent, offline)
├── continuous state variables (mood, energy, curiosity, affection, focus)
├── homeostasis + coupling equations
├── event kick system
├── circadian rhythm
└── episodic memory
↓ compile ↓
mood-line paragraph
"You're feeling bright and curious. It's midday, your most engaged time.
You've been thinking about the auth module lately."
↓
LLM call — conditions on mood-line, speaks, returns response
↓
parse events → feed back into state engineThe LLM never touches state directly. The mood-line is the only interface. That one constraint buys you everything below:
- Vendor-portable personality. The state lives in your files, not a vendor's system prompt. Swap Claude for Gemini for a local model mid-week — the character wakes up the same character.
- Tunable by physics, not prose. "Make her moods last longer" is one parameter (λ), not a prompt-engineering session.
- Offline-capable aliveness. State updates need zero network calls. The character's inner life keeps running when the cloud doesn't.
- Deterministic and inspectable. Every state change traces to an event or an equation. No "the model decided to be sad today."
Who this is for
1. AI companion products. Your retention problem is a flatness problem: users churn when the character feels like the same vending machine every session. Animus gives each user's companion a persistent, drifting, circadian inner life — per-user, on your infrastructure, portable across model upgrades.
2. Game NPCs. Emotional arcs across play sessions without scripting them. An NPC who is still rattled tomorrow by what the player did today — driven by a 5-variable dynamical system, not a dialogue tree.
3. AI pair programmers / desktop assistants. Time-of-day energy, session momentum, focus state. The difference between a tool and a colleague.
The qualifying complaint is always the same: "it feels dead, and better prompting hasn't fixed it." Correct — prompting can't fix it, because it's an architecture problem.
Quickstart
npm install animus-sdk
npx animus init # scaffold animus/agent.schema.json + AGENTS.md snippetimport { Animus } from 'animus-sdk';
const agent = new Animus({
schema: './animus/agent.schema.json', // a path (as scaffolded) or a schema object
infer: true, // read emotion from raw LLM/user text (see note below)
// state persists to .animus/<id>.json by default — pass memoryPath or store to override
});
// Before your LLM call — inject compiled state
const moodLine = agent.compile();
const messages = [
{ role: 'system', content: `${baseSystemPrompt}\n\n${moodLine}` },
...conversationHistory
];
// Your existing LLM call — unchanged
const response = await anthropic.messages.create({ messages, model: 'claude-opus-4-8', max_tokens: 1024 });
// After — feed events back into the state engine.
// With infer:true, plain text moves the state automatically.
// [[event:intensity]] tags are always parsed, with or without infer.
agent.apply(response.content[0].text);Works with Anthropic SDK, OpenAI SDK, Google Gemini, Ollama, or any HTTP LLM endpoint. Three lines around the LLM call you already have.
On
infer. It defaults tofalsefor predictability — by defaultapply(text)only reacts to explicit[[delight]]/[[fatigue:0.6]]tags, so an agent's state never moves on its own. Setinfer: true(as above) for zero-config behavior: a keyword model reads sentiment straight from the text. The tradeoff is that inference is keyword-based and context-blind — it will fire on text that discusses or quotes emotional words (a pasted sad article reads as distress), can emit several events from one message, and is English-only. For production agents where you want full control over what moves the state, keep it off and emit tags yourself.
State Schema
Define your agent's state as a JSON file:
{
"name": "my-agent",
"variables": ["mood", "energy", "curiosity", "affection", "focus"],
"baselines": {
"mood": 0.65, "energy": 0.70, "curiosity": 0.75, "affection": 0.50, "focus": 0.60
},
"homeostasis_rate": 0.08,
"coupling": {
"energy": { "mood": 0.30, "curiosity": 0.25, "focus": 0.20 }
},
"circadian": {
"peaks": ["09:00", "14:00"],
"floor": 0.15
},
"noise": { "magnitude": 0.02, "autocorrelation": 0.7 }
}Update Equation
x(t+1) = clamp01(
x(t)
+ λ · (x₀_eff − x(t)) # homeostasis: return toward baseline
+ Σ κ_xj · (xj(t) − xj*) # coupling: other variables pull on x
+ event_kick(t) # event spike: what just happened
+ ε(t) # bounded autocorrelated noise
)λ ≈ 0.08 is the master feel knob. Higher = moods recover faster. Lower = states linger longer.
Event System
// Built-in event types
agent.apply([
{ type: 'delight', intensity: 0.8 }, // +mood, +energy
{ type: 'confusion', intensity: 0.5 }, // -curiosity, slight -mood
{ type: 'reunion', intensity: 1.0 }, // +affection, +mood, +energy
{ type: 'fatigue', intensity: 0.6 }, // -energy (drags all via coupling)
]);
// Custom event types in schema
"events": {
"breakthrough": { "mood": 0.25, "energy": 0.15, "curiosity": -0.10 },
"blocked": { "mood": -0.15, "focus": -0.20 }
}Mood-Line Compiler
The compiler converts live state into a natural-language paragraph before each LLM call:
agent.compile();
// → "You're feeling bright and bouncy; energy is high. You're fascinated and full of questions.
// It's midday, your most energetic time. You've been thinking about the auth module lately."Customize band labels and vocabulary per agent in the schema (flat per-variable keys are canonical; a nested bands object is also accepted). Set "memory_injection": false to keep episodic memory out of the mood-line:
"compiler": {
"mood": { "low": "a bit flat", "mid": "steady", "high": "bright and joyful" },
"energy": { "low": "low-energy", "mid": "focused", "high": "bouncy and energized" }
}Animus alongside a memory layer
| Layer | Question it answers | Examples | |---|---|---| | Memory | "What do I know about this user/world?" | Mem0, Letta, Zep, Graphiti | | Animus | "What state am I in right now?" | this package | | LLM | "What do I say?" | any vendor |
const messages = [
{ role: 'system', content: [
baseSystemPrompt,
agent.compile(), // Animus: how it is
await mem0.getRelevant(userInput), // memory layer: what it knows
].join('\n\n') },
...history
];What You Get
| Without Animus | With Animus | |---|---| | Persona in system prompt | Persona in state engine — truly persistent | | Identical across sessions (dead) | State-driven across sessions (alive) | | Vendor-locked | Swap LLMs freely — state is yours | | Falls silent offline | Degrades gracefully offline | | Tuned by prompting | Tuned by physical parameters | | Personality is rented | Personality is owned |
Persistence & deployment
State is persisted automatically. By default it lands in .animus/<id>.json (the same place npx animus status reads), written off the request hot path: a turn's several state changes (compile → apply → gist → remember) coalesce into a single atomic, non-blocking write, and a process-wide exit hook flushes anything still pending on shutdown.
// Make the current turn durable before you respond (drains the write-behind):
agent.apply(replyText);
await agent.flush();Save modes — new Animus({ schema, save }):
| save | behavior |
|---|---|
| 'async' (default) | coalesced write-behind, off the hot path; flushed on exit |
| 'sync' | write synchronously on every change (simplest durability) |
| false | never auto-save — you persist via flush() or export() |
Bring your own backend. For a multi-user companion you usually want one agent per user with state in Redis/Postgres instead of local files. Implement the tiny store contract (load(key), save(key, db), optional saveSync/targetKey) and pass it as store. Because external stores load asynchronously, construct with await Animus.open(...):
const { Animus } = require('animus-sdk');
const store = new RedisStore(redis); // see examples/redis-store.js
const schema = generatePersona(seedForUser(userId));
schema.id = `user:${userId}`; // keys the state per user
const agent = await Animus.open({ schema, store }); // async store → open(), not new
const moodLine = agent.compile();
// ... your LLM call ...
agent.apply(replyText);
await agent.flush(); // durable before respondingBuilt-in stores: FileStore (default — atomic unique-tmp writes, compact JSON, and a corrupt file is preserved as <file>.corrupt-<ts> rather than discarded) and MemoryStore (ephemeral, for tests). A working Redis adapter and a Postgres sketch are in examples/redis-store.js.
Multi-writer safety. State carries a monotonic rev, and stores that implement compare-and-set (FileStore, MemoryStore, and the Redis/Postgres examples) use optimistic concurrency: if two writers hold the same key and one saves, the other's flush() detects it instead of silently clobbering. Pick a policy with onConflict:
| onConflict | on a conflicting flush() |
|---|---|
| 'throw' (default) | rejects with AnimusConflictError (carries key, expectedRev, currentRev) — you reload and retry |
| 'reload' | adopts the remote state and drops the local turn (warns) |
| (local, remote) => merged | your merge runs, then it retries once at the remote revision |
try {
agent.apply(replyText);
await agent.flush();
} catch (e) {
if (e instanceof AnimusConflictError) { /* reload this user and retry the turn */ }
}For genuine multi-process writers, back state with a store that has atomic CAS (Redis/Postgres) — a plain filesystem has a small read→write window. The simplest path is still one writer per key (route a user to one worker); the rev guard is the safety net when that slips.
CLI
npx animus init # scaffold animus/ in current project
npx animus simulate # build animus/simulator.html — the live engine on your schema
npx animus status # show schema, λ, and persisted statesimulate generates one self-contained HTML file with the shipping engine (same engine.js that runs in Node) inlined against your schema — open it in any browser, kick events, drag λ, and watch the traces and the compiled mood-line respond. No server, no build step.
Closing the loop: event tags
Tell your LLM (in its system prompt) to annotate emotionally significant moments with [[event]] or [[event:intensity]] tags. parseEvents extracts only event names defined in your schema or the built-ins — raw LLM text can never invent a state change — and cleanText strips the tags before you show the reply:
const reply = response.content[0].text; // "You fixed it! [[delight:0.9]]"
agent.apply(agent.parseEvents(reply)); // state engine absorbs the moment
display(agent.cleanText(reply)); // "You fixed it!"Roadmap
- Python binding (
AnimusMemoryfor LangChain / LlamaIndex) - Streaming event parser for token-by-token responses
- Multi-agent shared-world coupling
Provenance
Animus extracts the "Living Engine" built for Prism, a learning environment for young children whose design bar was unusually high: a four-year-old must believe its characters are alive on day 40, on a device that must keep its inner life running offline, with every influence on the child inspectable by a parent. The soul/mouth separation, the update equation, and the mood-line compiler all came out of meeting that bar. The pattern is domain-general.
Capps Consulting Company LLC
