Skip to content

NPC Memory System

The NPC Memory System gives NPCs persistent memory of interactions, relationship tracking, and a knowledge graph. Together, these subsystems enable NPCs to remember players, form opinions, and reference past events in conversation.

Overview

The memory system consists of three interconnected subsystems:

Subsystem Purpose Storage
MemoryService Stores episodic and semantic memories per NPC-player pair npc_memories collection
RelationshipManager Tracks trust, respect, and friendliness between NPCs and players npc_relationships collection
KnowledgeManager Subject-predicate-object knowledge graph with confidence scores npc_knowledge collection

These feed into the EnrichedPromptBuilder, which injects relevant context into NPC dialogue prompts so AI responses reflect what the NPC "knows."

graph LR
    A[Player Conversation] --> B[ExtractionJobQueue]
    B --> C[MemoryExtractor]
    C --> D[MemoryService]
    C --> E[RelationshipManager]
    F[World Events] --> G[KnowledgeObservationSystem]
    G --> H[KnowledgeManager]
    H --> I[GossipSystem]
    D --> J[EnrichedPromptBuilder]
    E --> J
    H --> J
    J --> K[NPC Dialogue Response]

Configuration

Enable the memory system with the MAID_MEMORY_ environment variables:

# Enable/disable the entire memory system
MAID_MEMORY_ENABLED=true

# Extraction pipeline
MAID_MEMORY_EXTRACTION_QUEUE_SIZE=100
MAID_MEMORY_EXTRACTION_TIMEOUT_MS=30000
MAID_MEMORY_EXTRACTION_RPM_PER_NPC=10
MAID_MEMORY_EXTRACTION_DAILY_TOKENS_PER_NPC=50000

# Memory bounds
MAID_MEMORY_MEMORY_BOUNDS_EPISODIC=200
MAID_MEMORY_CACHE_MAX_ENTRIES=1000
MAID_MEMORY_CACHE_TTL_SECONDS=300

# Context injection
MAID_MEMORY_CONTEXT_PROVIDER_TIMEOUT_MS=40
MAID_MEMORY_TOTAL_CONTEXT_BUDGET_TOKENS=2000

# Decay and consolidation
MAID_MEMORY_DECAY_CYCLE_TICKS=50
MAID_MEMORY_DECAY_RATE=0.01
MAID_MEMORY_CONSOLIDATION_CYCLE_TICKS=100

# Gossip (knowledge spreading)
MAID_MEMORY_GOSSIP_BUDGET_PER_TICK=3
MAID_MEMORY_GOSSIP_COOLDOWN_TICKS=10

Note

The memory system is disabled by default. Set MAID_MEMORY_ENABLED=true to activate it. When the Classic RPG content pack loads, it checks whether engine.context_orchestrator is None. If so, it creates a minimal ContextOrchestrator with only the KnowledgeContextProvider registered, so knowledge graph entries (populated by KnowledgeObservationSystem and gossip) still appear in NPC prompts.

Warning

MAID_MEMORY_DECAY_CYCLE_TICKS and MAID_MEMORY_DECAY_RATE are defined in settings but MemoryDecaySystem uses its own constructor defaults (run_every_ticks=50, internal decay rates per MemoryType). The startup code does not currently pass these settings to the decay system. Similarly, MAID_MEMORY_GOSSIP_* settings are not passed to GossipSystemClassicRPGContentPack instantiates it with constructor defaults only.

Memory Creation: The Extraction Pipeline

Memories are created automatically when players converse with NPCs:

  1. Conversation ends — Player uses endconversation or times out.
  2. ExtractionJobQueue picks up the conversation transcript.
  3. MemoryExtractor (LLM-powered) analyzes the conversation and produces episodic memories only — specific events ("Player asked about the dragon"). It returns relationship_updates=[]; relationship deltas are not computed here.
  4. ExtractionPipeline stores results and computes downstream updates:
    • Memories → MemoryService.store()
    • Relationship deltas → computed via RelationshipManager.compute_deltas_from_memories(), then applied with RelationshipManager.update()
    • Cache invalidation → MemoryCache.invalidate() called directly by the pipeline
# Example of what the extractor produces
from maid_stdlib.memory.extraction import ExtractionResult
from maid_stdlib.memory.models import EpisodicMemory, MemoryImportance

ExtractionResult(
    memories=[
        EpisodicMemory(
            npc_id="npc_elder",
            player_id="player_123",
            content="Player revealed they are searching for the lost amulet",
            importance=MemoryImportance.HIGH,
            tags=["quest", "amulet", "revelation"],
            emotional_valence=0.3,
        ),
    ],
    relationship_updates=[],    # RelationshipDelta objects
    extraction_time_ms=142.5,
    tokens_used=320,
)

Note

The extractor only returns memories directly. Relationship deltas are computed downstream by the ExtractionPipeline, which maps memory tags and valence to relationship dimension updates via RelationshipManager.compute_deltas_from_memories().

Memory Bounds and Eviction

Each NPC-player pair has limits:

  • Episodic memories: Up to 200 (configurable via MAID_MEMORY_MEMORY_BOUNDS_EPISODIC)
  • Semantic memories: Up to 50 (constructor default in MemoryService, not in settings)

When limits are exceeded, MemoryService.enforce_bounds() evicts memories using a composite score of importance, decay, and age.

Relationship Dimensions

Relationships are tracked along three dimensions:

Dimension Range Description
Trust -100.0 to 100.0 How much the NPC trusts the player
Respect -100.0 to 100.0 How much the NPC respects the player
Friendliness -100.0 to 100.0 How warmly the NPC regards the player

These combine into a disposition score that determines the relationship tier:

  • HOSTILE — NPC actively dislikes the player
  • UNFRIENDLY — NPC is cold or dismissive
  • NEUTRAL — Default starting point
  • FRIENDLY — NPC is warm and helpful
  • ALLIED — NPC considers the player a close friend
  • DEVOTED — NPC is deeply loyal to the player

How Relationships Change

Relationships update through the extraction pipeline. The RelationshipManager.compute_deltas_from_memories() method maps memory tags and valence scores to relationship deltas:

# A memory tagged "hostile" with negative valence
# → decreases trust (-8) and friendliness (-5)

# A memory tagged "deceptive" with negative valence
# → decreases trust (-10)

# A memory tagged "helpful" with positive valence
# → increases trust slightly
# → increases friendliness slightly

Dramatic tier shifts (e.g., FRIENDLY → HOSTILE) are signaled via RelationshipTierChangedEvent. The MemoryEventBridge._on_relationship_tier_changed handler listens for these events and emits a StorySignalEvent for dramatic transitions, which can trigger quest generation.

Knowledge Graph

The knowledge graph stores facts as subject-predicate-object triples:

KnowledgeEntry(
    subject="dragon",
    predicate="lives_in",
    object="mountain cave",
    confidence=0.9,
    source_npc_id="npc_elder",
    source_type="observed",
    ttl_seconds=7200,       # Expires after 2 hours
    propagation_count=0,    # How many times this was gossiped
)

Key properties:

  • Confidence (0.0–1.0): Decreases with each gossip propagation
  • TTL: Knowledge expires after a set time (stale knowledge is cleaned up)
  • Source tracking: Knows which NPC originally learned this fact
  • Deduplication: Duplicate entries are merged, keeping higher confidence

Knowledge is created by:

  1. KnowledgeObservationSystem — Observes world events (combat, movement, item interactions). Available in maid_stdlib.knowledge.observation but not registered by default — content packs must register it explicitly.
  2. Admin commands@knowledge add
  3. GossipSystem — Propagates knowledge between NPCs (see Gossip System Guide)

Admin Commands

@npcmemory — View and Manage Memories

@npcmemory <npc_id>                          # List all memories for NPC
@npcmemory <npc_id> <player_id>              # List memories for NPC-player pair
@npcmemory clear <npc_id>                    # Clear all NPC memories
@npcmemory clear <npc_id> <player_id>        # Clear memories for specific pair
@npcmemory add <npc_id> <player_id> episodic "Met at the tavern"
@npcmemory add <npc_id> <player_id> semantic "Player is a blacksmith"

Note

The type argument accepts any string. "semantic" creates a semantic memory; any other value (including "episodic") creates an episodic memory.

@relationship — View and Set Relationships

@relationship <npc_id> <player_id>           # View relationship details
@relationship set <npc_id> <player_id> trust 50
@relationship set <npc_id> <player_id> respect -30
@relationship set <npc_id> <player_id> friendliness 80

Values are absolute on the -100 to 100 scale (clamped internally).

@knowledge — View and Add Knowledge

@knowledge <npc_id>                          # View NPC's knowledge graph
@knowledge add <npc_id> dragon lives_in "mountain cave"

Integration with NPC Dialogue

When a player talks to an NPC, the EnrichedPromptBuilder gathers context from all three subsystems:

┌─────────────────────────────────────────────┐
│           EnrichedPromptBuilder              │
├─────────────────────────────────────────────┤
│  1. Base prompt (personality, instructions)  │
│  2. Memory context (recent interactions)     │
│  3. Relationship context (tier, disposition) │
│  4. Knowledge context (relevant facts)       │
│  5. {reputation} placeholder replacement     │
│     (injected by EnrichedPromptBuilder)      │
└─────────────────────────────────────────────┘

The ContextOrchestrator queries each provider with a token budget and timeout:

  • MemoryContextProvider — Retrieves recent and important memories for the NPC-player pair
  • RelationshipContextProvider — Provides the relationship tier and dimension scores
  • KnowledgeContextProvider — Retrieves relevant knowledge entries

The total context budget (default 2000 tokens) is split across providers. If a provider times out, the prompt proceeds without that section.

Example Enriched Prompt

You are Elena, the village elder. You are wise and cautious.

ADDITIONAL CONTEXT:
[MEMORY] You remember that this player helped defend the village last week.
They mentioned searching for a cure for their sister's illness.

[RELATIONSHIP] Current relationship tier: FRIENDLY. Trust 70/100, respect 60/100,
friendliness 80/100. Interactions: 12.

[KNOWLEDGE] You know that: the healer moved to the eastern forest;
a rare herb grows near the old ruins; bandits have been seen on the north road.

Memory Consolidation

Over time, episodic memories are consolidated into semantic memories by the MemoryConsolidationSystem:

  1. Periodic scan — Runs every MAID_MEMORY_CONSOLIDATION_CYCLE_TICKS ticks
  2. Identifies candidates — Episodic memories with high access frequency (above the access threshold)
  3. LLM summarization — Groups related episodic memories and produces a semantic summary
  4. Storage — Creates a new semantic memory, marks episodic originals as consolidated

This mimics how human memory works: specific episodes fade into general impressions over time.

Memory Decay

The MemoryDecaySystem gradually reduces memory decay_factor:

  • Runs every 50 ticks by default (constructor default run_every_ticks=50)
  • Reduces decay_factor based on per-MemoryType decay rates (constructor defaults)
  • Frequently accessed memories decay slower (access count is a factor)
  • Below-threshold memories become eviction candidates

Note

The decay system uses its own constructor defaults, not the MAID_MEMORY_DECAY_* settings values. The startup code instantiates it without passing settings overrides.

Cache Architecture

For performance, the memory system uses a multi-layer cache:

  1. MemoryCache — In-memory LRU cache with TTL (configurable size and expiry)
  2. CacheRefreshSystem — Periodic refresh every 60 seconds for active NPCs (refreshes memory and relationship adapters, not the MemoryCache directly)
  3. Event-driven invalidationExtractionPipeline invalidates the MemoryCache directly after storing new memories (MemoryEventBridge only emits events such as StorySignalEvent; it does not touch the cache)

This ensures that NPC dialogue doesn't block on database queries while still reflecting recent interactions.

Startup and Wiring

The memory system is initialized by maid_stdlib.memory.startup:

# Simplified startup flow (see maid_stdlib.memory.startup for full version)
memory_service = MemoryService(document_store)
relationship_manager = RelationshipManager(document_store)
knowledge_manager = KnowledgeManager(document_store)  # event_bus is optional

memory_cache = MemoryCache(max_entries=1000, ttl_seconds=300)

orchestrator = ContextOrchestrator(provider_timeout_ms=40)
orchestrator.register_provider(
    name="memory", provider=MemoryContextProvider(memory_service, cache=memory_cache),
    priority=1, default_budget_pct=0.5,
)
orchestrator.register_provider(
    name="relationship", provider=RelationshipContextProvider(relationship_manager),
    priority=2, default_budget_pct=0.2,
)
orchestrator.register_provider(
    name="knowledge", provider=KnowledgeContextProvider(knowledge_manager),
    priority=3, default_budget_pct=0.3,
)

engine.context_orchestrator = orchestrator

The Classic RPG content pack then wires the EnrichedPromptBuilder to use this orchestrator for all NPC dialogue.

See Also