symfonic.memory -- Hierarchical Memory System¶
symfonic.memory is an async-first, protocol-driven memory library that can be
used standalone or wired into a SymfonicAgent through symfonic.agent. All
five layers persist to Postgres by default.
The Pentad Model¶
The library organizes memory into five cognitive layers, collectively called the Hierarchical Memory System (HMS) or Pentad. Each layer answers a different question about stored information.
| Layer | Enum value | Cognitive role | Key question | Backend |
|---|---|---|---|---|
| Semantic | MemoryLayer.SEMANTIC |
Permanent facts and graph entities | What is true? | Postgres Graph (+ optional MongoDB mirror) |
| Episodic | MemoryLayer.EPISODIC |
Narrative events with timestamps | When/where did this happen? | Postgres Vector |
| Working | MemoryLayer.WORKING |
Active conversation context | What is happening now? | Postgres Graph (24h TTL) |
| Procedural | MemoryLayer.PROCEDURAL |
Skills, code snippets, workflows | How is this done? | Postgres Graph + SkillRegistry |
| Prospective | MemoryLayer.PROSPECTIVE |
Commitments, reminders, tasks | What should happen next? | Postgres Graph |
All five layers share the BaseMemoryStore protocol with a uniform
write / retrieve / delete interface. The differences between layers are in
how they index, score, and decay entries -- not in how callers interact with
them.
Semantic Layer¶
The semantic layer stores permanent facts, entities, and relationships as graph
nodes. Each node has a label, importance score, properties dict, and tenant-
scoped identity. Relationships between nodes are stored as directed edges with
typed relationship labels. This layer is the primary target for
upsert_node extraction operations.
When MongoDB is configured as a secondary backend, a dual-write observer mirrors semantic nodes to MongoDB for graph visualization (vis.js). Postgres remains the source of truth.
Episodic Layer¶
Stores timestamped interaction history. Each entry records a turn summary
(User: ... | Agent: ...) with low importance (3.0) and metadata like
source=auto_turn. Uses vector similarity for retrieval, allowing the agent
to recall past conversations by semantic relevance rather than just recency.
Working Layer¶
Session-scoped FIFO buffer backed by a Postgres graph store. Entries are
short-lived reasoning context -- including thinking nodes from Anthropic
extended thinking (written with ttl_hours=24 and source=thinking). The
working layer is excluded from query-time hydration (it is session context, not
knowledge) but is written to during consolidation.
Procedural Layer¶
Stores learned skills, workflows, and tool routing rules. Two specialized helpers sit on top:
- ToolCatalog: Named skills with steps and active/inactive flags.
- CapabilityRouter: Given a query, returns the subset of registered tools
relevant to that query. Used by
SymfonicAgentfor lazy tool routing.
Prospective Layer¶
Stores future-oriented information: commitments, reminders, scheduled tasks,
and pending intentions. Populated by set_trigger extraction operations when
the user mentions something they need to do later.
Hydration¶
Hydration is the process of injecting memory context into the LLM prompt before
execution. The _hydrate method in SymfonicAgent.engine:
- Iterates all enabled non-working layers.
- Calls
layer.retrieve(scope, query, top_k=N)on each. - Collects all returned
MemoryEntryobjects. - Runs spreading activation (if enabled) to expand hits with first-degree graph neighbors.
- Formats entries into a rich context string:
[layer_name] content (key=val, key=val). - Appends linked context from spreading activation under an
--- Associated Context ---header. - Returns a dict with
context,entries_count,graph_edges, andactivation_log.
Hydration failures on individual layers are logged and suppressed -- a failing layer does not block the query.
Spreading Activation¶
After initial retrieval, spreading activation expands the result set by fetching first-degree neighbors from the knowledge graph. This surfaces related facts that were not directly matched by the query.
How It Works¶
The SpreadingActivation class in symfonic.memory.retrieval.spreading:
- Takes the list of
MemoryNodehit nodes from semantic retrieval. - For each hit, calls
graph_store.get_neighbors(scope, node_id). - Filters neighbors by
min_importancethreshold. - Deduplicates against already-seen IDs.
- Sorts by importance descending.
- Caps at 10 neighbors total to avoid token explosion.
Activation Log¶
The result is returned in AgentResponse.activation_log with three fields:
| Field | Type | Description |
|---|---|---|
activated_nodes |
list[{name, score}] |
All activated nodes (seed hits + neighbors), deduplicated, sorted by score descending |
inference_paths |
list[[source, target]] |
Pairs of node labels showing how neighbors were reached |
pending_connections |
list[{source, relationship, target, status}] |
Inferred (non-explicit) relationships tagged PENDING CONSOLIDATION |
Pending connections are relationships inferred via generic associations
(associated, linked_semantic, linked_episodic) that have not been
confirmed. They can be passed to the Deep Sleep consolidation endpoint to be
written as real edges.
Deep Sleep Consolidation¶
The SleepConsolidator (in symfonic.core.learning.consolidation) is an
offline engine designed to run as a nightly Celery beat task or via the
POST /memory/consolidate API endpoint.
The 7 Phases¶
| Phase | Name | What it does |
|---|---|---|
| 1 | Strengthen | Boosts importance for recently recurring nodes (nodes updated within the lookback window) |
| 1.5 | Co-occurrence edges | Creates CO_OCCURRED edges between nodes updated within the same 1-hour window that have no direct edge |
| 2 | Tag risk | Marks nodes whose content contains negative-sentiment markers (risk, danger, failure, etc.) |
| 2.5 | Pending edge consolidation | Writes inferred pending_connections (from spreading activation) as real graph edges |
| 3 | Prune orphans | Deletes stale low-confidence orphan nodes (<=1 edge, older than 7 days, importance < 0.4) |
| 4 | Generate meta-nodes | Clusters nodes by label prefix and creates summary meta-nodes for groups of 3+ |
| 5 | SOUL corrections | Applies user corrections to the domain SOUL schema from recent node data |
ConsolidationReport¶
Each run returns a ConsolidationReport dataclass:
| Field | Type | Description |
|---|---|---|
tenant_id |
str |
Tenant that was consolidated |
started_at |
datetime |
Run start time |
finished_at |
datetime |
Run end time |
nodes_strengthened |
int |
Nodes whose importance was boosted |
nodes_pruned |
int |
Stale orphan nodes deleted |
risk_nodes_tagged |
int |
Nodes flagged with risk markers |
meta_nodes_created |
int |
Summary meta-nodes generated |
soul_updates |
int |
SOUL schema corrections applied |
edges_created |
int |
New edges (co-occurrence + pending) |
errors |
list[str] |
Non-fatal errors encountered during the run |
Configuration¶
consolidator = SleepConsolidator(
graph_store=graph_store,
lookback_hours=24, # How far back to look for recent nodes
llm_summariser=None, # Optional LLM for meta-node summaries
)
report = await consolidator.run(
scope,
soul_schema=domain_template.soul_schema,
pending_connections=activation_log["pending_connections"],
)
Query Capabilities¶
label_prefix (v5.4.0)¶
GraphMemoryStore.query_nodes() accepts a label_prefix parameter for prefix
matching on node labels. This is important when labels follow the
"PREFIX: detail" convention (e.g. "SOUL: Joy", "PRODUCT: SKU-123").
# Returns all nodes whose label starts with "SOUL"
nodes = await graph_store.query_nodes(scope, label_prefix="SOUL")
All three backends support this parameter:
- Postgres: LIKE $N || '%' (positional parameter, injection-safe)
- InMemory: str.startswith()
- MongoDB: anchored regex ^PREFIX with re.escape()
label (exact match) and label_prefix can be used together with AND logic.
Tenant Isolation¶
Every operation in the library requires a TenantScope. No data crosses
tenant boundaries.
from symfonic.core.scope import TenantScope, ScopeLevel
# v8.0 canonical (hierarchical) construction via the blessed factories:
scope = (
TenantScope.root("org", "acme")
.child("brand", "myhalos")
.child("conversation", "c-123")
)
scope.tenant_id # "acme" (computed: path root id — backward-compatible)
scope.scope_depth # 3
# Legacy bare construction still works (deprecation warning):
scoped = TenantScope(tenant_id="acme", namespace="engineering")
Hierarchical N-level scope (v8.0)¶
As of v8.0, TenantScope is an arbitrary-depth, root-first path of
ScopeLevel(kind, id) values (e.g. org → brand → conversation). The flat
tenant_id / sub_tenant_id fields are retained and computed off the path,
so existing call sites keep working. Three behaviours flow from the path:
- Prefix isolation (ALWAYS-ON security boundary). A query at path P sees a
memory at path Q iff Q is a prefix of P — enforced by exact-IN over the
query's ancestor prefixes (never a
LIKE 'prefix%'scan). A conversation sees its own conversation + brand + org, never a sibling conversation or sibling brand. This closes the cross-session-bleed class. - Materialised
scope_path. Each level is serialised into a US-delimited (\x1f), root-first string stored on every memory row, soacmeandacme-corpproduce distinct, non-overlapping strings. - Scope-distance blend (opt-in). With
FrameworkConfig.scope_blend_mode = "blend", ancestor-level memories are blended into a descendant query, down-weighted by hierarchy distance via a multiplicative gate on the composite score (tip ×1.0, parent ×0.5, grandparent ×0.25).ScoringWeightsstill sums to 1.0; the four-signal formula below is untouched.
Per-layer: Semantic + Episodic honour the blend; Procedural keeps
shadow-override re-expressed on the path; Working + Prospective stay tip-only.
See hierarchical-tenant-scope.md for the
full model, the migration of pre-v8.0 rows, and promotion/provenance.
Backends¶
Two backend protocols define where data is physically stored:
| Protocol | Purpose | Implementations |
|---|---|---|
GraphBackend |
Entity/relation graph (nodes + edges) | PostgresGraphBackend, MongoGraphBackend, InMemoryGraphBackend |
VectorBackend |
Dense vector similarity search | PostgresVectorBackend, InMemoryVectorBackend, ChromaVectorBackend |
The in-memory implementations hold data in Python dicts and lists with no
external dependencies -- suitable for development, testing, and embedded
deployments. Production deployments use PostgresGraphBackend and
PostgresVectorBackend for durability.
The MongoDB backend (MongoGraphBackend) is used as a secondary for graph
visualization. It is not a primary store.
Retrieval Engine and Scoring¶
Retrieval from episodic and semantic layers uses a four-signal scoring formula:
score = w_semantic * cosine_sim
+ w_recency * exp_decay(last_access)
+ w_frequency * log_norm(access_count)
+ w_graph * inv_path_distance
Default weights: semantic 40%, recency 20%, frequency 20%, graph 20%.
from symfonic.memory import ScoringWeights, OrchestratorConfig
weights = ScoringWeights(
semantic_similarity=0.5,
recency=0.2,
frequency=0.1,
graph_proximity=0.2,
)
config = OrchestratorConfig(scoring_weights=weights)
MemoryOrchestrator¶
MemoryOrchestrator is the top-level facade. It holds a reference to each
enabled layer and exposes get_layer() and all_layers() accessors.
from symfonic.memory import MemoryOrchestrator, OrchestratorConfig, MemoryLayer
config = OrchestratorConfig(
default_top_k=5,
compaction_threshold=50,
enabled_layers=frozenset({MemoryLayer.WORKING, MemoryLayer.EPISODIC}),
)
orchestrator = MemoryOrchestrator(config=config)
working = orchestrator.get_layer(MemoryLayer.WORKING)
The recommended way to construct a fully wired orchestrator is via HMSFactory
(exported from symfonic.agent).
Public API Reference¶
Full docstring-based API documentation is available at
docs/api/symfonic/memory/.
Key exported symbols:
| Symbol | Description |
|---|---|
MemoryOrchestrator |
Top-level HMS facade |
OrchestratorConfig |
Central configuration (frozen Pydantic) |
TenantScope |
Tenant isolation scope |
MemoryLayer |
Enum: WORKING, EPISODIC, SEMANTIC, PROCEDURAL, PROSPECTIVE |
ScoringWeights |
Retrieval scoring formula weights |
MemoryEntry |
Single memory record |
MemoryNode / MemoryEdge |
Graph primitives |
GraphMemoryStore |
Graph node/edge store (supports label_prefix queries since v5.4.0) |
CapabilityRouter |
Lazy tool routing via procedural layer |
ToolCatalog |
Skill registry for procedural layer |
BaseMemoryStore |
Layer protocol |
GraphBackend |
Graph persistence protocol |
VectorBackend |
Vector search protocol |
EmbeddingProvider |
Text-to-vector protocol |