Skip to content

Repository files navigation

feltstate

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.

CI  Python  License: MIT  English | 中文

feltstate architecture — memory pipeline, affect engine, time discipline, and the key web

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.


Why this exists

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:

  1. Memory has a lifecycle, not just a search index. Canon stores 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.
  2. 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 relates edges 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.
  3. Time is disciplined, not decorative. Timestamps are program-stamped; facts are bi-temporal (valid_at versus record time, invalid_at when 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.
  4. Affect is estimated separately, not self-reported. A configured AffectSource produces the affect signal, so the reply model cannot freely author its own persisted state.
  5. 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).
  6. 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.
  7. 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.
  8. 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.”
  9. The injection path is cache-aware. Static persona text can remain a stable prefix while dynamic state rides on the latest user message.
  10. 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.

Where the numbers come from

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.

A note on memory fingerprints

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.


Quickstart

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 needed

Want the full desktop-companion assembly — face, voice, heartbeat, memory tools? Start at docs/INTEGRATION.md.


Memory beyond retrieval

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() walks src and fusion lineage back through caller-owned memory storage. leaf_pointers() keeps raw evidence even when only part of the genealogy survives. trace_contexts() resolves each pointer's full t0t1 range 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.py

The key web: memory's horizontal dimension

The 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), and key_vocab shows 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.py

And 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

How affect works

            ┌─────────────┐   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.

A fine-tuned source

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.


Layout

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 companion loop

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 restart

The integration handbook

The 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)

Scope — what this is and isn't

  • 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.companion package is a reference orchestration skeleton, not a complete pet application. See examples/companion.py for a stubbed demo and examples/companion_live.py for the interactive one.
  • The default KeywordSource is intentionally crude. LLMSource is still an estimate produced by another model call, and the optional Vheart adapters are experimental demos rather than validated classifiers.
  • The default Canon reloads 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.

Install

pip install -e .          # core is pure standard library
pip install -e ".[dev]"   # + pytest, ruff, mypy

Requires Python 3.10+.

Development

ruff check .          # lint
ruff format .         # format
mypy feltstate        # type check
pytest -q             # tests

All four run in CI (.github/workflows/ci.yml) on Python 3.10–3.13. See CONTRIBUTING.md before opening a pull request.

License

MIT — see LICENSE.

About

A character engine for AI agents — persistent affect, provenance-aware memory, gated proactivity, and an auditable forgetting lifecycle. Reference design.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

9 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages