Give an LLM agent a mood it can carry forward—and memories that can age, change, die, and find each other.
Mechanism: persistent affect is estimated by a component separate from the reply model, while structured memory is managed through explicit storage, recall, provenance, ageing, and deletion tools. This is a prompt/interface and state-management design — not a claim about consciousness, subjective experience, genuine emotion, or human-like memory.
pip install feltstate
# core is pure standard library — then jump to the Quickstart below.
# (working from source: git clone https://github.com/Morephine/feltstate && pip install -e feltstate)feltstate is a character engine for AI agents — a small, opinionated reference library that gives a long-running agent a persistent, tunable character. It is built as three systems that interlock, plus a web that ties memory together:
- A memory production line. Verbatim transcript at the bottom, never edited; distillation into compact 5W1H facts whose salience decays, strengthens with repetition, and lasts longer when recalled; an auditable birth-to-death lifecycle (fingerprints, fusion lineage, tombstone-first deletion, a hash-linked audit chain); and recall as explicit doors the agent opens, never a hidden injection.
- An affect engine. Appraised outside the reply model — the model cannot author its own persisted feelings — and integrated across timescales: fast mood, releasing pressure bars, slow temperament, permanent imprints, and a ~180-day plasticity layer. It keeps ticking when nobody is talking.
- Time discipline. Timestamps come from the program, never from a model's prose; facts carry bi-temporal stamps (when a thing happened versus when it was believed or revised), and queries can ask what was believed as-of a past moment.
- The key web. Word keys imprinted on each fact at birth and kinship edges judged one pair at a time turn the flat ledger into a web. A query enters by colliding words with keys, gathers kin along judged edges, and reads the chain's tail as the present — no vector index, no invalidation machine (see the key web).
One stance up front: the dynamics constants that drive all of this — decay rates, pressure thresholds, the plasticity curve — are character and resilience parameters, deliberately plastic. Retuning them is how you write a different character. They are not scientific measurements, and no such claim is made (see Where the numbers come from).
Around the engine sits an integration handbook — sixteen chapters on how the pieces assemble into a companion that speaks, shows a face, works, fails, and stays itself — with every quoted transcript reproducible from a runnable example.
It draws on affective computing, agent memory, appraisal, and selective forgetting, but the architecture is its own. Several of its parts are uncommon in agent libraries — memory that can die with a traceable lineage, a hard ownership boundary on persisted affect, a judged (not embedded) relation web, and off-path zero-LLM dreaming — and they are built that way on purpose, not reassembled from off-the-shelf parts.
Distilled and rewritten as a clean, general library, adapted from mechanisms used in a private companion prototype. None of that prototype's private data, trained models, or persona is included here — only the implementation and design choices represented in this repository.
Most agent-memory libraries focus on storing and retrieving text, while many agent-affect demos reduce mood to a value placed in a prompt. feltstate explores a narrower but deeper question: how persistent affect, memory, capability, and proactive behaviour can change together over the lifetime of a long-running companion.
Its main design choices are:
- Memory has a lifecycle, not just a search index.
Canonstores compact 5W1H facts whose salience can decay, strengthen through repetition, and last longer when recalled. Optional lifecycle tools add birth fingerprints, fusion lineage, per-kind ageing clocks, pure death plans, tombstone-first deletion, snapshot cleanup, and a hash-linked audit trail for detecting unexplained mutation or disappearance. - Memory is a web, not only a ledger. Word keys are imprinted on each
fact at birth; a digest gives every new fact one collision pass against the
whole ledger and a judge decides kinship, writing
relatesedges on both rows with the why. The query leg (Canon.reach) enters by key collision, walks judged edges, and answers with an event-time chain whose tail is the present — recency wins by standing last, not by an invalidation flag. - Time is disciplined, not decorative. Timestamps are program-stamped;
facts are bi-temporal (
valid_atversus record time,invalid_atwhen a belief ends), history can be queried as-of a past moment, and the key web prices candidacy by the gap between when things happened. - Affect is estimated separately, not self-reported. A configured
AffectSourceproduces the affect signal, so the reply model cannot freely author its own persisted state. - State changes across several timescales. Fast mood, slow temperament,
relationship, pressure, aftertaste, anticipation, and optional imprints are
integrated with configurable human-inspired asymmetric dynamics — every
rate a personality dial rather than a fitted constant (see
"Where the numbers come from"). The slowest
layer is plasticity: per-bar sensitivity carved by micro hits from
lived charge, amplifying future inflow, healing toward baseline at a
safety-paced daily percentage — character change on a ~180-day scale
(
examples/plasticity.py). - Capability is judged separately from mood. An optional skill-memory region uses human 1/2/3 ratings, promotion, retirement, and bounded exploration rather than letting the reply model declare its own competence.
- Proactive behaviour is gated, not merely scheduled. The reference scheduler combines idle time, presence, cooldowns, daily quotas, pending topics, time windows, dreams, introspection, and diary behaviours through a propose/dispatch/commit flow.
- Persistent state is context, never a command. The reply model receives a compact first-person description rather than numeric controls or an instruction such as “respond sadly.”
- The injection path is cache-aware. Static persona text can remain a stable prefix while dynamic state rides on the latest user message.
- Dreaming is an optional state experiment. Zero-LLM recombination of affect-tagged fragments can leave a temporary mood residue without supplying the reply model with an explicit causal narrative.
Its contribution is a concrete, inspectable, tested architecture with a specific ownership boundary between appraisal, persisted state, memory lifecycle, and reply generation — the parts most companions blur together are here kept deliberately, and visibly, apart. Each choice above is unpacked, with working code and real transcripts, in the integration handbook.
The dynamics constants — EWMA rates, decay curves, pressure thresholds, the
asymmetries — are personality parameters, not psychological measurements.
They were hand-tuned against a long-running private deployment the way game
designers tune a movement curve: for a character that stays coherent over
months, not to match a published constant. Every one lives in config.py
with a one-line rationale, per-character values ride PersonaDials, and the
defaults amount to one reference temperament. Retuning them is not breaking
the model — it is how you write a different character: quicker to forgive,
slower to warm, harder to tire. Personality being tunable is the design, not
a disclaimer. Taken together, config.py + PersonaDials are the character
configurator — the dials are how you write a character, and the engine is
what keeps that character running. What the numbers are not is fitted to human
data; no such claim is made, and none is needed for the job they do.
The lifecycle package uses SHA-256 fingerprints and a hash-linked ledger for provenance and tamper evidence. This is not encryption, a digital signature, or protection against an attacker who can rewrite every file and recompute every hash. Its purpose is to make ordinary lineage, mutation, and deletion auditable when the ledger is kept as a trusted record.
from feltstate import Engine, KeywordSource
# KeywordSource is a zero-dependency, rule-based reference source — good enough
# to see the loop work. Swap in LLMSource (any OpenAI-compatible endpoint) or
# your own fine-tuned classifier for real use.
eng = Engine(
source=KeywordSource(), state_path="state.json", persona="a dry-humoured, loyal friend"
)
eng.tick([{"role": "user", "content": "I finally shipped it!! couldn't have done it without you"}])
print(eng.render())
# [how I feel right now]
# close · trusted · mostly safe · no friction
# curious, content | warm, mild energy
# pressure low, joy bright | building
# ...
# Feed it back to your reply model — cache-safely — as first-person context:
prompt = eng.inject("so what should we build next?")
# -> your static system prompt stays untouched (and cached); the felt block
# rides along on the newest user message.Run the full demo:
python examples/quickstart.py # pure stdlib, no install neededWant the full desktop-companion assembly — face, voice, heartbeat, memory tools? Start at docs/INTEGRATION.md.
The default Canon is a flat-file, structured memory store rather than a vector
database. Facts are represented as compact 5W1H records and can be reinforced,
corrected, retracted, queried as-of a past time, recalled through a pluggable
scorer, and expanded back to the transcript context that produced them. (The
default scorer is whitespace-tokenized lexical overlap — for Chinese and other
non-space-delimited languages, plug a tokenizing scorer into that seam.)
The optional feltstate.memory.lifecycle package models a longer path:
source evidence → zero-LLM consistency gate → sealed distilled memory
→ ageing / fusion / lineage → drill back to source context
→ death plan → tombstone → managed-store deletion → audit chain
The collector refuses to delete records it cannot trace, living distilled memories can protect the facts they depend on, and the reaper can remove a dead record from the live store and explicitly supplied snapshots through a replayable pending transaction. Source-material rows outside those managed stores are only marked for deletion; the library does not claim cryptographic erasure.
The new trace path is explicit rather than magical:
check_consistency()is a configurable lexical guardrail for a summary made from source rows. It catches unsupported numbers, negation drift, actor drift, spliced clauses, inflation, and hollow text without another model call. It is not a semantic proof; non-space-delimited languages should provide their own tokenizer and language tables.smelt()combines that gate with born salience and a provenance fingerprint. It rejects unsealed output by default; callers may explicitly opt into an unsealed fallback.drill()walkssrcand fusionlineageback through caller-owned memory storage.leaf_pointers()keeps raw evidence even when only part of the genealogy survives.trace_contexts()resolves each pointer's fullt0–t1range into transcript turns and can optionally verify exact source text.trace_memory()joins the tree, leaf evidence, transcript ranges, affect trail, lost-branch count, and optional source-hash verification into one report.verify_source_ptr()checks exact source text against the pointer hash. It does not restore deleted text, locate files automatically, or turn a hash into encryption.
from feltstate.memory.lifecycle import trace_memory
report = trace_memory(
memory_fp,
fingerprint_store.get,
transcript_loader,
before=3,
after=3,
load_source_text=exact_source_text_loader, # optional hash verification
)Canon, lifecycle fingerprints, and transcript storage remain composable
pieces rather than a hidden automatic pipeline. A memory is only fully
traceable when the application keeps its fingerprint, the referenced source
archive, and a resolver/loader for those stores.
python examples/memory_lifecycle.pyThe lifecycle above is memory's vertical axis — one fact's whole life, from sealed birth to audited death. The key web is the horizontal one: how facts scattered along the timeline find each other, without a vector database.
Two small facts live on the row itself, never in a sidecar index:
keys— a handful of single words naming what the fact is about. A key exists to collide: two facts that share a word have met. Phrases are rejected mechanically (a phrase never matches anything else), andkey_vocabshows your extractor which words the ledger already speaks, so new facts prefer old words and actually meet.relates— edges to other facts, each carrying the why a judge bound them. Shared words only make candidates; an edge is written when a judge (an LLM pass, a rules engine, a human — any callable) says the two are kin, and it lands on both rows. Candidacy is arithmetic; kinship is an opinion; the library does not fake opinions.
Admission into a newcomer's candidate pool is earned, not aged out: a candidate's birth intensity, multiplied by a relevance factor that can at most double it, must clear a floor that rises with the age gap. Every older fact is a candidate forever — an old memory is not less of a candidate for being old, it just has to have mattered.
That "candidates forever" bill is priced, and it is small: candidacy is paid once per newborn fact, at digest time — roughly a dozen facts a day, each making one pass over the ledger, off the hot path. Even a pathological ledger that never forgets — seventy years at that rate is ~400k rows — is seconds of nightly batch; and with decay, compaction, and real deletion running (as they do by default), the live set plateaus instead of growing linearly. Nothing on the conversational path ever pays this cost.
Reading is the same web backwards. Canon.reach enters by colliding query
words with keys, gathers kin along judged edges, and orders everything by
event time — the chain's tail is the present. No invalidation flags, no
judgement machine: a newer first-hand fact outranks an older one by standing
after it, and every fact in the answer carries how it entered. From
python examples/key_web.py (offline, deterministic):
== reach("rent") — the chain, oldest to newest ==
[2026-03-02] the rent went up <- key hit (rent)
[2026-03-09] dispute opened with the landlord <- kin via edge: shared keys: money
[2026-03-28] landlord agreed to freeze the rent <- key hit (rent)
current (the tail): landlord agreed to freeze the rent
— nothing was marked invalid; the newer fact simply stands last.
Pair the chain with lifecycle drilling and any fact in it can descend to the transcript context that produced it.
python examples/key_web.pyAnd the web is watchable: a single-file, read-only viewer serves your store as a walkable force graph — judged edges with their why on hover, per-fact side cards, key search that lights up the chain, and your engine's persisted state as bars.
python -m feltstate.dashboard --canon canon.jsonl --state state.json ┌─────────────┐ independently estimated (not self-report)
messages → │ AffectSource │ ──────────────► AffectDelta (this turn's estimate)
└─────────────┘ │
▼
┌──────────────────── Engine.tick() integrates over time ───────────────────┐
│ traits asymmetric EWMA — good moods fade fast, bad ones linger │
│ mood felt valence/arousal, pulled toward what traits imply │
│ pressure 5 bars (sadness/anger/anxiety/boundary/joy) fill, cross a │
│ threshold, *release*, then settle — they don't stay maxed │
│ imprint optional: deep moments leave permanent marks (symmetric: │
│ both wounds and warmth, so the agent doesn't only scar) │
└────────────────────────────────────────────────────────────────────────────┘
│ persisted AffectState
▼
┌─────────────┐ render_felt_block + time sense (fuzzy "how long
reply ◄── │ render/inject│ since we talked", precise "what time is it now")
model └─────────────┘ → first-person block, injected cache-safely
The reply model uses the felt block as additional context when generating a reply. The library never writes "be sad now" into the prompt — it only supplies state.
KeywordSource and LLMSource are the two example sources shipped with the
core. An optional third source, feltstate.sources.vheart.VheartSource, loads a
LoRA adapter from the Hub. Two small experimental adapters are referenced:
kaishuiji/vheart-affect-v8
on a 1.5B base and
kaishuiji/vheart-affect-v9
on a 4B base.
Treat these adapters as interface demos — closer to research toys than production classifiers. Their training data is not released, there is no public reproducible benchmark in this repository, and no accuracy or suitability claim is made. They are useful for trying the integration path, not as a model recommendation.
pip install "feltstate[vheart]"from feltstate import Engine
from feltstate.sources.vheart import VheartSource
eng = Engine(source=VheartSource("kaishuiji/vheart-affect-v9"))Constructing VheartSource downloads the base model and the adapter and
loads both onto the GPU (or CPU), which is several gigabytes and a
visible startup pause. Download, load and network failures during
construction propagate. After construction, read() itself never raises
— tokenizer, generation and parse failures collapse to a low-confidence
neutral reading.
Off this per-turn path, the agent dreams: Engine.maybe_dream() fires only
when a single sleep-pressure accumulator (driven by arousal, not the clock) says
it's tired enough — then recombines stored, affect-tagged fragments into a short,
illogical dream that leaves a faint mood residue whose causal thread is not
surfaced to the reply model as an explicit cause, which decays over the next
hours like any feeling. See §5 of
PHILOSOPHY.md.
| Module | What it is |
|---|---|
feltstate/state.py |
The schemas: AffectState, AffectDelta, Mood, Traits, Relationship, PressureState. Plain dataclasses, JSON round-trip. |
feltstate/config.py |
Every tunable in one place (EWMA rates, decay, pressure thresholds, label maps) + PersonaDials. |
feltstate/sources/ |
AffectSource interface + KeywordSource (rules, zero-dep) + LLMSource (any OpenAI-compatible endpoint). The pluggable affect-estimation seam. |
feltstate/affect/ |
The dynamics: pressure (multi-bar release), traits (asymmetric adaptation), imprint (permanent marks), relationship (the bond evolving), tide (mood's rise & fall), smooth (label hysteresis). |
feltstate/memory/ |
Canon — a decaying 5W1H fact store; feeling — optional evidence-weighted affect per fact; recall and bi-temporal history; extract and context; skill — a human-rated capability region; lifecycle — optional provenance fingerprints, lineage, ageing clocks, death planning, tombstone-first deletion, snapshot cleanup, and a hash-linked audit ledger; keyweb — word keys and judged edges born on the row, the one-pass collision digest, and the chain query leg behind Canon.reach; ladder — the crystallisation ladder (day → week → month → year): casting, key-clustered melts, climb bonus, absorption derived from lineage rather than marked. |
feltstate/dream.py |
Off-path, zero-LLM: recombines the agent's charged material (Fragments) into an illogical dream that leaves a faint mood residue not surfaced to the reply model as an explicit cause. Swap the Phrasebook for another language. |
feltstate/sleep.py |
The single sleep-pressure accumulator (Tiredness) that decides when to dream: rises with arousal, gated by threshold + idle + a hard refractory floor, discharged by a dream. Homeostatic, not clock-driven. |
feltstate/timeawareness/ |
Fuzzy "how long since we last talked" + precise "now". |
feltstate/render/ |
render_felt_block (state → first-person block) + build_injection (cache-safe). |
feltstate/engine.py |
Engine — the façade that ties it together: tick(), render(), inject(), dream(), maybe_dream(). |
feltstate/dashboard.py |
A read-only browser window onto a Canon: the key web as a walkable force graph, per-fact cards with kin, key search, optional persisted-state bars. python -m feltstate.dashboard --canon …. |
feltstate/companion/ |
A reference orchestration layer: LLMBackend / FrontendAdapter / VoiceAdapter / UserPresenceAdapter seams, companion_turn for one estimate→reply→express→speak round, and CompanionScheduler for optional proactive behaviours. |
The core engine presents an agent with a persistent affective state in
first-person form; feltstate.companion provides a reference companion
skeleton. Implement two adapters — a FrontendAdapter (your avatar/skin) and
a VoiceAdapter (your TTS) — bring an AffectSource, a reply LLMBackend,
and a persona, and Companion wires the rest: a foreground say() turn (feel →
reply → express → speak) and a CompanionScheduler heartbeat that checks configured timing and gates for
optional proactive speech, introspection, dreams, or diary writes —
all the timing and gating adapted from mechanisms used in a private companion
prototype, with the endpoints and prompts left to you.
python examples/companion.py # runnable stub companion — no deps, no network
python examples/companion_live.py # interactive: a real heartbeat, proactive
# topic raises, memory that survives restartThe engine is the smaller half of building a companion; the larger half is
integration — what goes in the prompt and in what order, how a reply becomes a
face and a voice, what the user sees while the agent works, fails, or thinks.
The docs/ chapters cover that half as concrete patterns over this library's
actual seams. They describe one coherent way to assemble a companion — the
shape used by the private reference implementation this library was distilled
from — not the only way. Transcripts quoted in them are real output of the
runnable examples.
| chapter | what it covers |
|---|---|
| INTEGRATION | the assembly manual: wiring diagram, prompt partition and its cache economics, heartbeat duties, the proactive path, adapter swaps, the shell / bridge / soul layering, the privacy boundary |
| PROMPT_STACK | the static/dynamic partition, sandwich ordering, and the forget probe — persona upkeep that costs nothing until it's needed |
| PROMPT_SHAPES | one neutral persona, three prepared moments as full message arrays; the variant master table from state bands to phrases |
| STYLE_SPECTRUM | optional delivery notes — how a feeling holds a pen: form, never content |
| OUTPUT_CHAIN | reply → face and voice: two signal channels, first-sentence TTS streaming, renderer portability down to a hotkey bridge |
| AGENT_WORK_UX | narrating long agent work without breaking character: canned voicebanks, the narration throttle, cross-turn work |
| FAILURE_IN_CHARACTER | two audiences, two truths: felt failure kinds, the watchdog case, recovery etiquette |
| BRIDGE_ETIQUETTE | being a person over a chat platform: receipts, typing, attachments, and the emergency command lane |
| INTERRUPTION | being cut off gracefully: no-headphone barge-in, the stop chain, the recovery posture |
| PERCEPTION | images and screens as input: persist → perceive → reply, and the pull eye |
| INNER_LIFE | the silent thinking channel, the face that moves between turns, and the self-correction round |
| MULTI_PERSON | one soul, many people: per-speaker relationship keying and the zero-pollution rule |
| GAME_SHELL | the game as a third surface: minute-scale director intents vs second-scale engines, the sight gate, and driving It Takes Two |
| PROMPTS | contracts for the model seats — namer, judge, summariser (plus retuning the shipped extractor and estimator): words are yours, numbers and time are never |
| MEMORY_TOOLS | Canon as five function-calling tools, with a real bi-temporal trace |
| PHILOSOPHY | why persisted state is described to the model and never commanded |
The matching runnable examples, all deterministic or offline:
python examples/prompt_shapes.py # the three moments, full message arrays
python examples/memory_tools.py # the five tools + dispatcher, end to end
python examples/key_web.py # keys collide, edges judged, the tail is the present
python examples/nightly.py # one whole day distilled, keyed, judged, aged — the loop
python examples/emergence.py # RES / EMG / SPK: memory that surfaces uninvited
python examples/ladder.py # days melt to weeks melt to months — the lifetime stays scannable
python examples/agent_narration.py # voicebank pools, throttle, failure lines
python examples/style_spectrum.py # state bands → delivery notes
python examples/companion_live.py # the interactive loop (FELTSTATE_LIVE_FAST=1 to hurry it)
python examples/game_director.py # the game shell: intents, sight gate, one running mouth
python examples/maze_game/play.py # the director shape as a playable maze (WASD; offline stub,
# or any OpenAI-compatible model via MAZE_LLM=1)- Is: a clean, runnable reference implementation of the ideas, dependency-
free at the core. Bring your own
AffectSource, persona text, and a place to store state. - Isn't: a finished product. There is no bundled personality, no trained
affect classifier, and no conversational data, avatar, or TTS. The
feltstate.companionpackage is a reference orchestration skeleton, not a complete pet application. Seeexamples/companion.pyfor a stubbed demo andexamples/companion_live.pyfor the interactive one. - The default
KeywordSourceis intentionally crude.LLMSourceis still an estimate produced by another model call, and the optional Vheart adapters are experimental demos rather than validated classifiers. - The default
Canonreloads its flat file per operation (O(n), lexical scoring) — right-sized for one companion's thousands of distilled facts, where an auditable flat file beats an opaque database and decay/compaction keep the live set small. Fleet-scale corpora or semantic search need a real store behind the same interface.
pip install -e . # core is pure standard library
pip install -e ".[dev]" # + pytest, ruff, mypyRequires Python 3.10+.
ruff check . # lint
ruff format . # format
mypy feltstate # type check
pytest -q # testsAll four run in CI (.github/workflows/ci.yml) on Python 3.10–3.13. See
CONTRIBUTING.md before opening a pull request.
MIT — see LICENSE.