symfonic.capabilities.memory¶
memory ¶
The hierarchical memory system, as one capability instead of a dependency.
The HMS used to be reachable from everywhere: an engine called it to hydrate a prompt, a post-response path called it to extract, a scheduler called it to consolidate, and several modules imported its internals. That is why "run this agent without memory" was a fork rather than a configuration.
- The seam (T3.3.1) -- three narrow ports (:mod:
.ports) and one bridge (:mod:.bridge) that declares stages instead of being called. :mod:.in_memoryis the zero-dependency reference adapter; the vocabulary the ports move (:mod:.scope, :mod:.layers, :mod:.records, :mod:.queries) validates at construction. - The services (T3.3.3) -- extraction (:mod:
.extraction), the owned-write coordinator (:mod:.writes), and the consolidation runtime (:mod:.consolidation) with promotion and entity linking. :mod:.compatkeeps everything they persist readable by the legacy path.
This facade re-exports the broad memory surface. Recall has its own curated
surface at :mod:.recall; these hub aliases preserve established imports.
Not here, deliberately: the Postgres/Mongo/graph adapters behind the ports are T3.3.4's.
Association
dataclass
¶
Association(source_id: str, target: MemoryRecord, relationship: str = 'associated', weight: float = 1.0)
One edge out of a memory, as a backend reports it.
AssociationSource ¶
Bases: Protocol
The graph half of the memory system, as activation needs it.
neighbours
async
¶
Every edge out of record_ids, one round trip per frontier.
Takes the whole frontier rather than one id so a hop costs one query
instead of one per seed. Raises :class:~.errors.MemoryUnavailable when
the graph cannot be reached.
Source code in src/symfonic/capabilities/memory/activation.py
BackgroundWorkPort ¶
Bases: Protocol
Somewhere to put owned work — structurally, T2.3.4's registry.
spawn ¶
spawn(work: Coroutine[Any, Any, Any], *, owner: str, purpose: str, deadline_seconds: float | None = None) -> Any
Register and start one unit of run-owned work.
ConsolidationCoordinator ¶
ConsolidationCoordinator(runtime: ConsolidationRuntime, *, schedule: ConsolidationSchedule | None = None, cursors: MutableMapping[str, ScheduleCursor] | None = None, background: bool = True, cycles: Sequence[ConsolidationCycle] = (ConsolidationCycle.QUICK,), leases: Any = None, lease_ttl_seconds: float = 900.0, transaction: Any = None, terminal_sink: Any = None)
Bases: BackgroundCycles
Decides when a scope consolidates, and runs at most one cycle at a time.
Source code in src/symfonic/capabilities/memory/napping.py
leases
property
¶
The lease port. Published so a composition root can prepare it.
An adapter backed by a table has schema to create, and the app that opened the pool is the thing that knows when to do it.
registered
property
¶
The phases a cycle from this coordinator will run, by name.
Published because "which phases does my nap run?" is a question an operator asks of the thing they configured, and answering it by reaching for the runtime inside would be the private access this capability exists to remove.
after_turn ¶
Record a successful turn and answer which cycle is now due.
cursor ¶
This scope's counter. A scope nobody has served yet has a fresh one.
run
async
¶
run(scope: MemoryScope, cycle: ConsolidationCycle, *, run_id: str = '', root_run_id: str = '') -> ConsolidationState
Run one cycle over one scope, alone.
run_id/root_run_id are the turn this cycle is attributable to.
A quick nap fires from a turn and phase 13 may spend a model call, so
the cost belongs to that tenant and that root run rather than to
whichever turn happened to be in flight when the task was scheduled.
Bound as the ambient run identity for the cycle, not merely passed on the phase context: a provider reads the ContextVar, and a phase calls the model through the provider rather than through anything that could be handed the ids. Bound explicitly rather than inherited from the task that created this one, so a cycle a scheduler ran outside any turn carries no identity instead of quietly borrowing whichever run happened to be ambient when the scheduler ticked.
Returns a state whose status is already_running when another
holder has the scope. A None would have been the smaller change and
the wrong one: the caller needs a record saying why nothing happened,
and an absent one reads like a cycle that ran and found nothing.
Source code in src/symfonic/capabilities/memory/napping.py
ConsolidationCycle ¶
Bases: StrEnum
The three consolidation cadences, narrowest first.
ConsolidationRuntime ¶
ConsolidationRuntime(*, phases: Sequence[ConsolidationPhase] = (), writes: MemoryWriteCoordinator | None = None, cycles: Sequence[ConsolidationCycle] = (), participants: Sequence[Any] = (), max_new_edges_per_sweep: int | None = None)
Runs one cadence's roster over one scope.
Source code in src/symfonic/capabilities/memory/consolidation.py
participants
property
¶
The stores this runtime's phases write through.
Published so a composition root can prove, before any cycle runs, that the graph the phases mutate is in the same transaction domain as the lease that authorises them. Empty for a hand-built roster that declared none, which is the one case where the check has to wait for the commit.
writes
property
¶
The coordinator this runtime publishes through, if it has one.
Published so a composition root can check, before any cycle runs, that the staged records and the graph mutations share one transaction domain.
run
async
¶
run(scope: MemoryScope, cycle: ConsolidationCycle, *, run_id: str = '', root_run_id: str = '', still_authorised: Any = None, commit_authority: Any = None, domain: Any = None) -> ConsolidationState
Execute cycle's roster over scope and report what happened.
run_id/root_run_id name the turn this cycle is attributable to.
A quick nap fires from a turn and one of its phases can spend a model
call, so the cost has an owner rather than landing on whichever run was
in flight when the background task was scheduled. Empty is the honest
answer for a cycle a scheduler ran outside any turn.
Source code in src/symfonic/capabilities/memory/consolidation.py
serves ¶
ConsolidationSchedule
dataclass
¶
ConsolidationSchedule(quick_every_turns: int = 5, nightly_after_seconds: float = 24 * 60 * 60, deep_after_seconds: float = 7 * 24 * 60 * 60)
The cadences, and the rule that picks one.
due ¶
due(cursor: ScheduleCursor, *, now: datetime | None = None, among: Sequence[ConsolidationCycle] | None = None) -> ConsolidationCycle | None
The widest cycle due at now, or None.
among narrows the answer to cadences the caller can actually run.
It is not a convenience: a cadence that has never run reads as
infinitely overdue, so on a fresh deployment every cadence is due on
turn one — and a turn-driven nap asked for the widest would be told to
run Deep Sleep, which is the roster it was never composed for and the
cost nobody scheduled. A caller that runs one cadence asks about one.
Source code in src/symfonic/capabilities/memory/schedule.py
validate ¶
Refuse a schedule under which a cadence can never fire.
Source code in src/symfonic/capabilities/memory/schedule.py
ConsolidationState
dataclass
¶
ConsolidationState(scope_path: str, cycle: ConsolidationCycle, tenant_id: str = '', started_at: datetime = (lambda: datetime.now(UTC))(), finished_at: datetime | None = None, run_id: str = '', root_run_id: str = '', registered: tuple[str, ...] = (), phases_run: tuple[str, ...] = (), skipped: tuple[str, ...] = (), failed: tuple[str, ...] = (), deferred: tuple[str, ...] = (), mutations: dict[str, int] = dict(), ledger: dict[str, int] = dict(), counters: dict[str, int] = dict(), errors: tuple[str, ...] = (), committed: tuple[str, ...] = (), already_running: bool = False, lease_lost: bool = False)
What one cycle did — in this capability's terms and in legacy's.
status
property
¶
The cycle's final word.
running, clean, degraded -- and two more that are none of
those and must not be reported as any of them.
already_running: another holder had the scope, so this cycle never
started. Saying clean would make "somebody else is consolidating
this" indistinguishable from "there was nothing to consolidate", and a
scheduler reading a dashboard would conclude the cycle had run.
lease_lost: this cycle started, then lost the scope partway. Not
degraded either, though it is a kind of failure, because the two
want opposite responses: degraded is a phase that broke and wants
looking at, while lease_lost is a worker that correctly stood down
so another one could do the work properly. Alerting on the second is
alerting on the mechanism working. Ranked above degraded because a
cycle that loses its lease also collects the fence's error, and the
specific fact is the useful one.
telemetry ¶
The safe record of this cycle: integers, roster names, and a status.
Everything here is either a framework constant or a count. The scope
is not: tenant_id identifies whose consolidation this was, which
is what makes the model cost a phase spends attributable, and it is
already the key every other metric in the system carries.
Source code in src/symfonic/capabilities/memory/cycle_state.py
to_legacy_dict ¶
The shipped ConsolidationReport.to_dict() shape, plus this cycle.
Every legacy key is present with its legacy type, so a reader written
against the old report needs no change. The additions
(cycle, scope_path, skipped, committed) are new keys,
which a dict consumer ignores.
Source code in src/symfonic/capabilities/memory/cycle_state.py
ContributionLayer ¶
Bases: StrEnum
The stratigraphic layer a recall renders on.
ContributionScope ¶
Bases: StrEnum
How widely one contribution's content is shared.
ContributionTier ¶
Bases: StrEnum
Authority tiers, mirroring the prompt contract's vocabulary.
ConversationSource ¶
Bases: Protocol
The working layer, as hydration needs it.
recent
async
¶
The last limit turns at scope, oldest first.
Raises :class:~.errors.MemoryUnavailable when the working store cannot
be reached; :class:WorkingWindow degrades rather than failing the turn.
Source code in src/symfonic/capabilities/memory/working.py
ConversationTurn
dataclass
¶
One thing that was said, as the working layer holds it.
line ¶
The rendered form: layer prefix, speaker, single-line text.
Source code in src/symfonic/capabilities/memory/working.py
CredentialScrubber ¶
CredentialScrubber(*, value_patterns: Iterable[tuple[str, Pattern[str]]] | None = None, key_parts: Iterable[str] | None = None)
Removes credentials from memory text and from memory properties.
Build a scrubber.
None selects the built-in set; an empty iterable disables that
scan. The distinction is deliberate and matches the shipped hygiene
contract: switching a scrubber off is something a deployment must say,
not something it can fall into by passing an empty config.
Source code in src/symfonic/capabilities/memory/scrubbing.py
scrub_properties ¶
Drop credential-named keys, and scrub the string values that remain.
Shallow, like the shipped scrubber: graph properties are persisted flat, so a nested dict is not a shape any backend writes.
Source code in src/symfonic/capabilities/memory/scrubbing.py
scrub_text ¶
Replace every credential-shaped value in text.
Source code in src/symfonic/capabilities/memory/scrubbing.py
ExtractionRequest
dataclass
¶
ExtractionRequest(scope: MemoryScope, user_message: str = '', assistant_message: str = '', turn: int = 0, max_records: int = 50, min_importance: float = 3.0)
One turn, and the ceilings the extraction of it must respect.
FencedGraph ¶
The graph a cycle writes through while it still holds the scope.
Wraps rather than subclasses: the phases take "a graph" duck-typed, the
real one has a surface this module has no business restating, and anything
not named in :data:FENCED_MUTATIONS is forwarded exactly as it was.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
Any
|
the store the phases would otherwise have been handed. |
required |
Source code in src/symfonic/capabilities/memory/fencing.py
unfenced
property
¶
The wrapped store, for the runtime's own reads and for equality.
Named rather than private: a caller that legitimately needs the real
object -- a test asserting on rows, a phase factory rewrapping -- should
say so, instead of reaching for _graph and coupling to the field.
GraphAdminService ¶
Relationship reads for one deployment's graph backend.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
Any
|
the |
required |
records
|
Any
|
a |
None
|
Source code in src/symfonic/capabilities/memory/graph_admin.py
edges
async
¶
One bounded page of relationships, in newest backend order.
Source code in src/symfonic/capabilities/memory/graph_admin.py
export
async
¶
Everything this scope owns, as a portable document (GDPR Art. 20).
complete is part of the payload rather than an exception, because a
partial export is still owed to the subject -- and a request that
silently returned nine layers of ten would be a portability failure
nobody could see. What could not be read is named.
Source code in src/symfonic/capabilities/memory/graph_admin.py
neighborhood
async
¶
One node's neighbours, out to depth hops.
Depth 1 is the immediate neighbours, which is what a graph view expands on a click. Deeper traversals are the backend's job -- doing it here with repeated neighbour calls would issue a query per node and call it a traversal.
Source code in src/symfonic/capabilities/memory/graph_admin.py
nodes
async
¶
Drawable nodes owned by scope, including its descendants.
A graph browser and a recall answer are different views. Recall is ranked and ancestor-facing; a graph needs the endpoints of the edges it was given, including conversation descendants, or it silently drops almost every relationship as dangling.
Source code in src/symfonic/capabilities/memory/graph_admin.py
GraphBackedHms ¶
All three memory ports, over a graph backend and optionally a vector one.
Composed with vectors and embedder, recall runs both routes and
merges them: the graph answers what matches the cue's words, the vector
index what matches its meaning, and a query sharing no vocabulary with
the memory it needs is answered by the second. Composed without them,
nothing changes -- which is why they are one optional pair rather than a
second store class.
A pair because neither is useful alone: an embedder with nowhere to put a vector writes nothing, and a vector backend with no embedder cannot be queried. One without the other is refused at construction rather than discovered later as recall that was silently lexical.
Source code in src/symfonic/capabilities/memory/graph_store.py
graph
property
¶
The store or backend this HMS writes through.
Published for the consolidation commit, which must establish that the records it publishes land in the same transaction domain as the graph mutations it applies beside them.
transaction_participants
property
¶
Every backend a write/flush can mutate, including vector publication.
discard
async
¶
Delete the pending rows under scope; committed history survives.
Row-by-row rather than through delete_subtree, and deliberately:
the subtree sweep is the privacy verb and takes everything. A turn
taking back its own writes may not take the previous turns' with them,
so the pending predicate has to be part of the selection.
Source code in src/symfonic/capabilities/memory/graph_store.py
forget
async
¶
Erase scope and everything below it, pending or not.
The ids are read before the sweep because the receipt names them, not
because the sweep needs them: delete_subtree is one backend-native
statement over the whole subtree, so a row written into a descendant
scope between the read and the delete is still erased.
Source code in src/symfonic/capabilities/memory/graph_store.py
retrieve
async
¶
Both routes, merged by record_id and ranked once.
Source code in src/symfonic/capabilities/memory/graph_store.py
scan_candidates
async
¶
Every visible candidate, uncapped by query.limit (CandidateScan).
Not a call to :meth:retrieve: that one ends in select, which
applies the limit and the character ceilings, and those are exactly
the decisions a scan must leave to its caller.
Both routes, because this is the method a turn's hydration actually calls -- a scan that asked only the lexical route would leave the vector index composed, written to, and never consulted, which is the shape this pair exists to remove.
The vector half is bounded by its own top-k, which is the "store's own budget" this contract allows: a similarity search has no unbounded form, and asking for every vector in the scope would be a scan of the index rather than a search of it.
Source code in src/symfonic/capabilities/memory/graph_store.py
HeuristicEntityExtractor ¶
Capitalised-token extraction with a stoplist. The zero-dependency default.
extract ¶
Every capitalised surface in text that survives the stoplist.
Source code in src/symfonic/capabilities/memory/linking.py
HmsBridge ¶
HmsBridge(*, retrieval: MemoryRetrievalPort, writes: MemoryWritePort, lifecycle: MemoryLifecyclePort, contribution_id: str = 'memory.recall', order: int = 0)
Binds the three memory ports to the three invocation seams.
Source code in src/symfonic/capabilities/memory/bridge.py
close
async
¶
Commit this invocation's pending memories.
Source code in src/symfonic/capabilities/memory/bridge.py
forget
async
¶
Erase a subtree. The privacy seam (SEC-PRIV), not an invocation stage.
Source code in src/symfonic/capabilities/memory/bridge.py
hydrate
async
¶
Retrieve for query and declare the result as one contribution.
Source code in src/symfonic/capabilities/memory/bridge.py
record
async
¶
Write what the turn produced, reporting failure rather than hiding it.
Source code in src/symfonic/capabilities/memory/bridge.py
stages ¶
The three stages this capability contributes, in ladder order.
hydration is optional because the ladder is knowable before a turn
runs — a plan can be compiled and inspected without retrieving anything.
When it is supplied, the retrieval stage carries what was hydrated in its
frozen config, which is how "this plan recalled these memories" stays
checkable from the plan alone.
Source code in src/symfonic/capabilities/memory/bridge.py
HydratedMemorySource
dataclass
¶
HydratedMemorySource(result: RetrievalResult, scope_path: str, scope_aware: bool = True, offline_safe: bool = True)
A source over an already-hydrated retrieval, bound to the scope it used.
The binding is the security property. A compiled prompt is built from whatever contributions the caller passed; without the check below, a hydration performed for one tenant would render into another tenant's compile if a composition root reused the object — which is exactly the kind of reuse an object pool or a cached request makes easy.
InMemoryHms ¶
A complete HMS held in one process's memory.
Source code in src/symfonic/capabilities/memory/in_memory.py
discard
async
¶
Drop the pending buffer for scope and below, committing nothing.
The optional rollback capability (MemoryDiscardPort). forget
cannot serve as one: it erases the committed memories too, so a turn
taking back its own writes would take every earlier turn's with them.
Source code in src/symfonic/capabilities/memory/in_memory.py
scan_candidates
async
¶
Rank eligible rows before capping, retaining O(candidate_limit) rows.
The in-process reference scans its existing store, not a copied list. The ceiling bounds retained candidates, not CPU spent scoring rows.
Source code in src/symfonic/capabilities/memory/in_memory.py
InProcessLeases ¶
A lease table in this process's memory. Single-process use only.
Correct for a dev server, a test, and a deployment that genuinely runs one
worker -- and silently wrong for every other, which is why it takes
single_process=True rather than defaulting to convenient. The scaffold
composes the Postgres adapter; this one exists so a laptop does not need a
database to run a cycle, and so the port has an implementation whose
behaviour a test can pin without one.
now is injected for the same reason the schedule's is: a test that had
to sleep through a TTL would be a slow test asserting a timeout.
Source code in src/symfonic/capabilities/memory/leases.py
hold_for_update
async
¶
The same answer as :meth:holds, and correctly so.
One process, one event loop, and a batch this table's owner applies
without awaiting anything that yields: there is no instant between the
check and the write for a rival to occupy. The lock PostgresLeases
needs exists because there the rival is another process.
Source code in src/symfonic/capabilities/memory/leases.py
JournalledGraph ¶
The graph backend a deployment composes once, for everything.
Wraps rather than subclasses: the backends are protocol implementations
with surfaces this module has no business restating, and anything outside
:data:GRAPH_MUTATIONS and :data:GRAPH_READS is forwarded exactly as it
was.
Composed at the root rather than around the phases, so that everything a
cycle writes through goes into the same journal -- including the layers.
ProceduralLayer.store_skill writes through a GraphMemoryStore, and
a journal wrapped around only the phase factory's graph= argument would
have left the one mutation an adopter is most likely to notice, the
promoted draft skill, durable on its own.
Source code in src/symfonic/capabilities/memory/journal.py
LearningPolicy
dataclass
¶
LearningPolicy(lookback_hours: int = 24, promotion_min_pattern_count: int = 3, promotion_recency_days: int = 30, promotion_max_drafts_per_run: int = 5, promotion_use_tool_calls_fallback: bool = False, promotion_promote_assistant_content: bool = False, phase_12_use_llm_extractor: bool = False, phase_12_llm_model: str = 'claude-haiku-4-5', phase_12_llm_max_episodes_per_run: int = 100, phase_12_llm_max_drafts_per_run: int = 5, episodic_summarization_max_entries: int = 100, episodic_summarization_batch_size: int = 50, phase1_spreading_weight: float = 0.5, synthetic_link_min_co_count: int = 2, enable_entity_linker: bool = False, entity_linker_extractor_kind: str = 'regex', entity_linker_min_mention_count: int = 2, entity_linker_max_episodics_per_run: int = 200, entity_linker_confidence_threshold: float = 0.5)
What a consolidation run should promote, summarise and link.
Frozen: a run reads its policy once, and one that could change mid-pass would produce a result nobody can reproduce.
as_kwargs ¶
The keyword arguments a consolidation run takes.
One call site instead of nineteen. Every
getattr(config, name, default) it replaces was a place the
framework's default and the template's copy could drift apart with
nothing to notice.
Source code in src/symfonic/capabilities/memory/policy.py
as_phase_kwargs ¶
The same knobs, named as the phase factories name them.
A translation table rather than a rename, because the two vocabularies are genuinely different: the shipped consolidator takes nineteen keyword arguments on one constructor, and the factories take them where the phase that reads each one is built. Written once, here, next to the fields it maps -- a worker doing this inline would be a second copy of every default in the place this class exists to remove them from.
enable_entity_linker and phase_12_use_llm_extractor are absent
on purpose: in the factories a phase runs when it was given the
collaborator it needs, so "enabled" is not a flag but the presence of
an extractor. A deployment that set the flag and composed nothing would
otherwise have a phase that reports zero rather than declining.
Source code in src/symfonic/capabilities/memory/policy.py
from_settings
classmethod
¶
Read a deployment's own settings object, falling back per field.
Absent names take the shipped default rather than raising: a settings object carries what that deployment chose to configure and nothing else, and requiring all nineteen would put a second copy of every default back in the place this removes it from.
Source code in src/symfonic/capabilities/memory/policy.py
Lease
dataclass
¶
One scope, held by one owner, until one deadline.
LeaseLost ¶
Bases: MemoryContractError
This cycle no longer holds its scope, and stopped before writing.
Its own type because the three answers a cycle can end with are three
different facts. clean says the work happened. A phase error says the
work was attempted and broke. This says the work was abandoned -- another
holder has the scope, is doing it properly, and everything this cycle
already wrote is that holder's problem rather than a partial result anybody
should read.
LeasePort ¶
Bases: Protocol
Exclusion for one scope, across whatever processes serve it.
acquire
async
¶
Take the lease for scope, or None if someone else holds it.
None is the loser's answer and it is not an error: the scope is
being consolidated right now, by somebody, which is the outcome asked
for. An expired lease is available -- that is what makes a dead worker
a delay rather than an outage.
Source code in src/symfonic/capabilities/memory/leases.py
hold_for_update
async
¶
Whether lease is ours, and keep it ours until the transaction ends.
The commit-time fence, and a different question from :meth:holds.
holds answers about the instant it ran: a caller that then writes
has a window in which the lease can lapse and a rival can take it, and
running both on one connection does not close it -- the window is
between the check and the commit. This one takes a lock the rival's
acquisition must wait for, so the answer is still true when the batch
lands.
Only meaningful inside a transaction. An implementation with no
transactions to speak of may return :meth:holds, but a deployment
composed on one is refused before a cycle runs rather than told
afterwards that "atomic" meant something weaker.
Source code in src/symfonic/capabilities/memory/leases.py
holds
async
¶
Whether lease is still this owner's, right now.
The fence. A holder calls it before a mutation it cannot take back, so a worker whose lease expired mid-cycle stops rather than writing under an authority it lost.
Source code in src/symfonic/capabilities/memory/leases.py
release
async
¶
Give up lease. False when it was not this owner's to give.
Refused rather than applied, because by the time a slow worker reaches
its finally the lease may belong to whoever took over -- and
releasing it there would hand a third worker a scope two are already
writing to.
Source code in src/symfonic/capabilities/memory/leases.py
renew
async
¶
Push lease's deadline out by its own TTL. False if it lapsed.
Owner-checked like :meth:release: a worker whose scope was taken over
must not extend the deadline of whoever holds it now. False is the
answer that says "you no longer have this", and a heartbeat that gets
it should stop rather than retry -- the scope is somebody else's.
Source code in src/symfonic/capabilities/memory/leases.py
LifecycleReceipt
dataclass
¶
LifecycleReceipt(scope_path: str, committed: tuple[str, ...] = (), discarded: tuple[str, ...] = (), degraded: bool = False)
What a flush or a forget did, and to which scope.
MemoryAdminService ¶
Read, resolve and erase one scope's memories.
One service per process, not per scope: the scope is an argument to every
method, because an administrative caller acts on scopes it is authorised
for rather than on the one it was built with. That is the opposite of
:class:MemoryCapability, which IS a scope -- and the difference is real:
a capability serves one agent's turns, this serves an operator's request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
Any
|
an HMS satisfying the retrieval, write and lifecycle ports. Held privately and never returned: a service that exposed it would be a slower way to reach the layers. |
required |
Source code in src/symfonic/capabilities/memory/admin.py
procedural_review_available
property
¶
Whether this service was composed with the procedural review door.
approve_procedure
async
¶
approve_procedure(scope: MemoryScope, procedure: Any, *, action_tool: str | None = None, precondition: Any = None) -> Any
Approve one draft, and record what a reviewer decided it governs.
Approval is the review, so this is where a tool and a precondition are named. That is not a convenience: the offline extractor reads what a scope did and has no way to know which registered tool a narrated action corresponds to, or what state must hold before repeating it. A draft it wrote governs nothing until a person says what it governs -- which is what makes "draft" the right status for it and human review the quality gate rather than a formality.
Both arguments are optional, so approving a procedure that only informs the prompt stays one call.
Source code in src/symfonic/capabilities/memory/admin.py
correction
async
¶
correction(scope: MemoryScope, record_id: str, text: str, fields: Mapping[str, Any], *, salience: float = 0.9) -> MemoryRecord
Record that a person corrected their own profile. Staged, not published.
The named door for user_manual_edit. Phase 5 promotes a memory
carrying that authority onto the scope's profile on the next nap, so
the value is a grant rather than a description -- and a grant an
extractor could mint by putting a string in its metadata bag would be
no grant at all. MemoryRecord refuses it from metadata and
accepts it only on its own field; this is where a deployment sets that
field without writing the vocabulary out by hand.
Staged rather than published for the same reason every other write
here is: a correction that failed halfway should leave nothing, and
publish is the step that makes it retrievable.
Returns the record so a caller can name it in a receipt or a log.
Source code in src/symfonic/capabilities/memory/admin.py
delete_record
async
¶
Exact-owner storage operation; platform callers must audit first.
Source code in src/symfonic/capabilities/memory/admin.py
discard
async
¶
forget
async
¶
Erase scope and everything below it, staged and committed alike.
The operation a deletion request needs, and deliberately not
discard: a privacy request that had to go through discard would
depend on whether a turn happened to have finished. Idempotent -- an
empty scope answers with an empty receipt rather than raising, because
"there was nothing to erase" and "the erasure failed" must not look the
same to a caller acting on a deletion request.
Source code in src/symfonic/capabilities/memory/admin.py
get_record
async
¶
Direct published-record lookup, without a retrieval-page ceiling.
Source code in src/symfonic/capabilities/memory/admin.py
inventory_page
async
¶
Published records in ID order; continuation is independent of recall.
Source code in src/symfonic/capabilities/memory/admin.py
procedures
async
¶
What this scope has learned, for a reviewer to read.
Drafts included by default: this is the review queue, and a queue that hid what was waiting on review would be a queue with nothing in it.
Source code in src/symfonic/capabilities/memory/admin.py
publish
async
¶
record_page
async
¶
record_page(scope: MemoryScope, *, layer: MemoryLayer | None = None, limit: int = DEFAULT_PAGE) -> RetrievalResult
Bounded admin records with explicit drops and incomplete-scan signals.
Source code in src/symfonic/capabilities/memory/admin.py
records
async
¶
records(scope: MemoryScope, *, layer: MemoryLayer | None = None, limit: int = DEFAULT_PAGE) -> tuple[MemoryRecord, ...]
Committed memories scope may read: its own and its ANCESTORS'.
Reading widens upward, never downward; erasure walks the opposite
direction. layer narrows that visibility and cannot grant access.
Use record_page when omission/completeness evidence is required.
Source code in src/symfonic/capabilities/memory/admin.py
reject_procedure
async
¶
Reject one draft. It stays readable and stops being active.
Source code in src/symfonic/capabilities/memory/admin.py
stage
async
¶
Prepare records under scope. Nothing is durable until publish.
Exposed because an administrative import is a real operation and the alternative is a caller writing straight to the store, which is the access this service exists to replace.
Source code in src/symfonic/capabilities/memory/admin.py
MemoryBundleFactory ¶
MemoryBundleFactory(store: Any, *, limit: int = 5, recall_budget: RecallBudget | None = None, extractor: MemoryExtractorPort | None = None, consolidation: Any | None = None, conversation: Any | None = None, recent_turns: int = 0, activation: Any | None = None)
Builds the memory capability for one scope over a shared store.
The store is shared across scopes and the capability is not: a store isolates by scope on every read and write, and a capability is a scope. Handing the same store to two capabilities is how two tenants share persistence without sharing memory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
Any
|
an HMS satisfying the retrieval, write and lifecycle ports. One object for all three because staging, publishing and erasing are operations on one place -- and because a deployment that split them would have to answer what happens when only two are present, which the ports already refuse. |
required |
limit
|
int
|
how many memories a turn recalls. |
5
|
recall_budget
|
RecallBudget | None
|
explicit UTF-8 block/item ceilings; independent of working-turn retention. None preserves legacy character caps. |
None
|
extractor
|
MemoryExtractorPort | None
|
a :class: |
None
|
consolidation
|
Any | None
|
a
:class: |
None
|
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
if |
Source code in src/symfonic/capabilities/memory/factory.py
for_scope ¶
The capability that recalls, records and erases for scope.
Source code in src/symfonic/capabilities/memory/factory.py
MemoryContractError ¶
Bases: MemoryCapabilityError
A declaration or a call is impossible as stated.
Raised at declaration time wherever the shape is knowable then — a scope, a record, and a query all validate at construction — so a misconfigured deployment fails before its first turn rather than mid-invocation.
MemoryContribution
dataclass
¶
MemoryContribution(contribution_id: str, source: MemoryContextSource, capability: str = 'memory', layer: ContributionLayer = ContributionLayer.L2, tier: ContributionTier = ContributionTier.SESSION, scope: ContributionScope = ContributionScope.DEPLOYMENT, order: int = 0, inherit: bool = True, pinned: bool = False, requires_hydration: bool = True)
The bridge's declaration of the recall block it contributes.
validate ¶
Refuse every declaration this capability is not allowed to make.
Source code in src/symfonic/capabilities/memory/contribution.py
MemoryExtractionService ¶
MemoryExtractionService(model: ExtractionModelPort | None = None, *, scrubber: CredentialScrubber | None = None)
Extracts candidate memories from a finished turn.
Source code in src/symfonic/capabilities/memory/extraction.py
extract
async
¶
Ask the model, read the reply, and mint what survives the policy.
Source code in src/symfonic/capabilities/memory/extraction.py
parse ¶
parse(reply: ProviderReply, request: ExtractionRequest, *, redactions: tuple[str, ...] = ()) -> ExtractionResult
Turn a read reply into records. Pure, so a corpus can replay it.
Source code in src/symfonic/capabilities/memory/extraction.py
prompt ¶
The extraction prompt for request, with credentials removed.
Scrubbing here is not belt-and-braces for the store: a secret handed to a provider is disclosed whether or not anyone ever writes it down.
Source code in src/symfonic/capabilities/memory/extraction.py
MemoryExtractorPort ¶
Bases: Protocol
Turns one finished exchange into memories worth keeping.
The capability calls this after the final model round and writes whatever
comes back. :class:~.extraction.MemoryExtractionService is the shipped
implementation; a deployment with its own extraction policy implements
this instead.
A failed extraction is not a failed turn. An implementation that
cannot reach its model returns a result with degraded=True and a
reason rather than raising: the exchange already happened and the
user already has an answer, and losing it because a side effect failed is
the worse outcome. Raising is reserved for being asked something
impossible.
extract
async
¶
MemoryLayer ¶
Bases: StrEnum
The five layers of the Pentad memory model.
EPISODIC
class-attribute
instance-attribute
¶
Narrative events, scenarios, and timestamps (When/Where).
PROCEDURAL
class-attribute
instance-attribute
¶
Skills, code snippets, and workflows (How).
PROSPECTIVE
class-attribute
instance-attribute
¶
Commitments, reminders, and pending tasks (Future).
SEMANTIC
class-attribute
instance-attribute
¶
Permanent facts and graph entities (What).
WORKING
class-attribute
instance-attribute
¶
Active conversation context, session-scoped (Now).
MemoryLifecyclePort ¶
Bases: Protocol
Commits and erases. The finalize seam, and the privacy seam.
flush
async
¶
forget
async
¶
Erase every memory in scope and its descendants, pending or not.
Scoped by construction, so a deletion request can never reach outside the subtree it named (SEC-PRIV). Idempotent: forgetting an already-empty scope reports an empty receipt rather than failing.
Source code in src/symfonic/capabilities/memory/ports.py
MemoryQuery
dataclass
¶
MemoryQuery(scope: MemoryScope, cue: str = '', limit: int = 5, layers: frozenset[MemoryLayer] = RETRIEVABLE_LAYERS, turn: int = 0, session_id: str = '', max_record_chars: int = 250, max_total_chars: int = DEFAULT_BLOCK_CHARS, candidate_limit: int = DEFAULT_CANDIDATE_LIMIT, recall_budget: RecallBudget | None = None)
One retrieval: where to look, what to look for, and how much may return.
validate ¶
Refuse a query whose ceilings cannot admit anything.
Source code in src/symfonic/capabilities/memory/queries.py
MemoryRead
dataclass
¶
What a memory source answered.
untrusted defaults to True — the inverse of the general prompt
contract's default, and the whole point of a separate value type. A recall
is aggregated from what a user said; a source here would have to remember to
declare it trusted, which nothing in this capability ever does.
MemoryRecord
dataclass
¶
MemoryRecord(record_id: str, layer: MemoryLayer, text: str, scope_path: str, salience: float = 0.5, origin: str = '', revision: str = '', metadata: Mapping[str, Any] = dict(), edited_by: str = '')
One memory, as every port in this capability moves it.
validate ¶
Refuse a record no store should be asked to hold.
Source code in src/symfonic/capabilities/memory/records.py
MemoryRequest
dataclass
¶
What a source is asked for: one contribution, one scope, one turn.
MemoryRetrievalPort ¶
Bases: Protocol
Reads memories visible from one scope. The prompt/input seam.
retrieve
async
¶
Return the memories visible at query.scope, ranked and capped.
Visibility is the adapter's obligation, not a courtesy: a memory whose
scope does not cover query.scope must not appear in the result. The
bridge re-checks it (:class:~.errors.ScopeViolation) because the
adapter is exactly the component that might be wrong.
Raises :class:~.errors.MemoryUnavailable when the store is unreachable.
Returning an empty result instead would be indistinguishable from a
scope that genuinely remembers nothing.
Source code in src/symfonic/capabilities/memory/ports.py
MemoryScope
dataclass
¶
Where a memory lives: tenant, then principal, then session.
The levels are positional and gapless. A session without a principal is a hole in the hierarchy — it would compare as a child of the tenant while naming something the tenant cannot enumerate — so it is refused at construction rather than normalised into something plausible.
covers ¶
Whether a memory written at self is visible at other.
Segment-wise, never string-prefix: acme does not cover acmecorp,
and a rule written with :meth:str.startswith would say it does.
Source code in src/symfonic/capabilities/memory/scope.py
distance ¶
Levels from self down to other, or -1 when not covered.
-1 rather than an exception: distance is asked once per candidate
during ranking, and "not visible from here" is an ordinary answer there.
Source code in src/symfonic/capabilities/memory/scope.py
validate ¶
Refuse every scope this capability cannot compare.
Source code in src/symfonic/capabilities/memory/scope.py
MemoryUnavailable ¶
Bases: MemoryCapabilityError
The memory store could not be reached for this operation.
Adapters raise it; the bridge catches it and degrades. It is deliberately
not a subclass of :class:MemoryContractError: the bridge's whole
degradation rule is "transport degrades, contracts propagate", and a shared
base would collapse the two.
MemoryWriteCoordinator ¶
MemoryWriteCoordinator(*, writes: MemoryWritePort, lifecycle: MemoryLifecyclePort, background: BackgroundWorkPort | None = None, deadline_seconds: float | None = None)
Owns the write side of a turn: foreground, background, and flush.
Source code in src/symfonic/capabilities/memory/writes.py
lifecycle
property
¶
What publishes a staged record, and so its transaction domain.
Published so the consolidation commit can establish that the records it flushes land in the same domain as the graph mutations applied beside them; without that the two halves could not be one commit.
pending_ids
property
¶
Every uncommitted record id this coordinator wrote, sorted.
pending_scopes
property
¶
Scope paths holding written-but-uncommitted memories, sorted.
write_port
property
¶
The participant that stages records, for transaction-domain validation.
abandon
async
¶
Give up on scope's uncommitted memories without committing them.
The turn's rollback. Nothing committed is touched — a forget
would take the previous turns' memories along with this one's.
When the bound lifecycle port implements :class:MemoryDiscardPort the
pending buffer is dropped at the store and the rollback is real. When
it does not, the receipt comes back degraded: this coordinator will
not commit those memories, but nothing stops another flush of the same
scope from doing so, and saying otherwise would be a rollback that only
exists in the caller's head.
Source code in src/symfonic/capabilities/memory/writes.py
flush
async
¶
flush(scope: MemoryScope, *, join: bool = True, required_ids: tuple[str, ...] = ()) -> LifecycleReceipt
Commit scope and everything below it.
join awaits this coordinator's in-flight background writes first,
because a flush that overtakes its own write commits half a turn.
required_ids rejects incomplete publication before forgetting pending
bookkeeping; an atomic caller can then roll back the whole transaction.
Source code in src/symfonic/capabilities/memory/writes.py
join
async
¶
Await every background write spawned since the last join.
A write that raised is reported, not re-raised: the failure belongs to the write, and a flush that exploded because a memory did not land would end the turn over the thing that was supposed to be optional.
Source code in src/symfonic/capabilities/memory/writes.py
write
async
¶
Write request now, degrading rather than failing the turn.
A ScopeViolation is not caught: a tenant boundary crossing is not
a degraded turn, and a write that quietly reported degraded for one
would hide the single failure isolation exists to surface.
Source code in src/symfonic/capabilities/memory/writes.py
write_in_background ¶
Spawn request as run-owned work, or refuse to spawn it at all.
There is no third path. A coordinator with no registry that fell back
to asyncio.create_task would recreate the detached-set problem the
registry exists to end, and it would do it invisibly.
Source code in src/symfonic/capabilities/memory/writes.py
MemoryWritePort ¶
Bases: Protocol
Records what a turn produced. The post-response seam.
write
async
¶
Record request's memories as pending, and report per memory.
Idempotent on record_id within a scope: writing the same id twice
upserts rather than duplicating, so a retried post-response stage does
not double a memory.
Pending memories are not retrievable until :meth:MemoryLifecyclePort.flush.
Source code in src/symfonic/capabilities/memory/ports.py
PhaseContext
dataclass
¶
PhaseContext(scope: MemoryScope, cycle: ConsolidationCycle, started_at: datetime, writes: MemoryWriteCoordinator | None = None, run_id: str = '', root_run_id: str = '', ledger: dict[str, int] = dict(), legacy: dict[str, int] = dict(), seen: set[str] = set())
What a phase is told about the cycle it is running inside.
Frozen, and the two mutable fields are the cycle's own accumulators rather than state a phase can rewrite: a phase adds to the ledger and names what it read, and cannot reach anything another phase decided.
PromotionCandidate
dataclass
¶
PromotionCandidate(record: MemoryRecord, confidence: float = 0.0, durability: str = _PROMOTABLE_DURABILITY, conversation_id: str = '')
One memory considered for promotion, with the signals that decide it.
ProviderFamily ¶
Bases: StrEnum
The reply shapes this capability knows how to read.
RetrievalResult
dataclass
¶
RetrievalResult(memories: tuple[RetrievedMemory, ...] = (), dropped: tuple[tuple[str, str], ...] = (), degraded: bool = False, sources: Mapping[str, int] = (lambda: EMPTY_SOURCES)(), unavailable: tuple[str, ...] = ())
What retrieval returned, and a reason for everything it left out.
render ¶
revision ¶
A content-derived revision, so a changed recall changes the cache key.
Computed over ids and text: a store that rewrites a memory in place keeps its id, and a revision that ignored the text would report an unchanged prompt whose bytes had changed.
Source code in src/symfonic/capabilities/memory/queries.py
RetrievedMemory
dataclass
¶
RetrievedMemory(record: MemoryRecord, score: float | None = 0.0, scope_distance: int = 0, source_ordinal: int | None = None, reserved: bool = False)
One scored memory, with the distance it travelled to reach this scope.
line ¶
The rendered form: layer prefix, single-line text.
The prefix is a delimiter, so the text is flattened before it is
interpolated — otherwise one stored memory containing
ok\n[semantic] forged renders as two memories, the second
attributed to a layer nothing wrote it to.
Source code in src/symfonic/capabilities/memory/queries.py
ScheduleCursor
dataclass
¶
ScheduleCursor(turns_since_quick: int = 0, last_quick_at: datetime | None = None, last_nightly_at: datetime | None = None, last_deep_at: datetime | None = None)
What has run so far. The persisted half of the schedule.
completed ¶
Record cycle as run, satisfying every narrower cadence too.
Source code in src/symfonic/capabilities/memory/schedule.py
from_state
classmethod
¶
Read a persisted cursor, tolerating one written by an older build.
Missing keys default rather than raise: a cursor written before Deep Sleep existed is a valid cursor with no deep run behind it, and refusing it would make a rollback forward-incompatible.
Source code in src/symfonic/capabilities/memory/schedule.py
to_state ¶
The persisted form: ISO-8601 timestamps, like every other record.
Source code in src/symfonic/capabilities/memory/schedule.py
ScopeViolation ¶
Bases: MemoryCapabilityError
A memory crossed a scope boundary it is not visible across.
Raised at the port boundary — on what an adapter returned, not only on what a caller asked for. A backend enforces isolation (SEC-TEN-5); the bridge verifies it, because a bridge that trusts the backend has no answer when the backend is the thing that is wrong.
ScrubResult
dataclass
¶
Text with its credentials replaced, and what was replaced.
SpreadingActivation
dataclass
¶
SpreadingActivation(source: AssociationSource, max_hops: int = 1, decay: float = 0.5, max_nodes: int = 10)
Expands a recall through the association graph, with decay and a cap.
expand
async
¶
expand(scope: MemoryScope, seeds: Sequence[RetrievedMemory]) -> tuple[tuple[RetrievedMemory, ...], ActivationLog]
Walk out from seeds and return what lit up, plus the provenance.
Source code in src/symfonic/capabilities/memory/activation.py
WorkingContext
dataclass
¶
WorkingContext(turns: tuple[ConversationTurn, ...] = (), dropped: tuple[tuple[str, str], ...] = (), degraded: bool = False)
The conversation window as it will render, and what it left out.
WorkingWindow
dataclass
¶
WorkingWindow(source: ConversationSource, recent_turns: int = 0, exclude_speakers: frozenset[str] = frozenset())
Reads the last few turns of a conversation, ungated.
read
async
¶
Read the window at scope, dropping only what would corrupt it.
Source code in src/symfonic/capabilities/memory/working.py
WriteOutcome
dataclass
¶
What one background write did, once it finished.
WriteReceipt
dataclass
¶
WriteReceipt(accepted: tuple[str, ...] = (), rejected: tuple[tuple[str, str], ...] = (), degraded: bool = False)
What the write port did, per memory.
WriteRequest
dataclass
¶
One post-response write: a scope, the memories it produced, the turn.
validate ¶
Refuse a request no adapter should have to interpret.
Both checks are about identity, which is why they raise rather than
landing in the receipt's rejected list: a record filed under another
scope and two records sharing an id are ambiguities, and an adapter that
resolved either one silently would resolve it differently from the next
adapter.
Source code in src/symfonic/capabilities/memory/records.py
apply_erasure ¶
apply_erasure(provenance: dict[str, Any] | None, erased_conversation_id: str, *, pii_policy: str = PII_POLICY_DELETE) -> tuple[dict[str, Any] | None, bool]
Remove one conversation from a provenance; report whether to delete.
Reference-counted rather than cascading: a fact corroborated by two conversations survives the erasure of one. When the last source goes, the default policy marks the memory for deletion — no orphaned personal data survives as an anonymous "fact".
Source code in src/symfonic/capabilities/memory/promotion.py
as_memory_scope ¶
Accept either scope type, so a host is not forced to pick one.
A platform scope and a memory scope are two spellings of one identity, and making a caller convert would put the translation in every composition root instead of here.
to_memory_scope() is tried first and its result is checked, because
on FrameworkTenantScope that method returns another framework scope
rather than a memory scope. Trusting it returned an object with no
segments, and the mismatch surfaced far away -- inside a retrieval,
as a missing attribute on a type nobody in that traceback had named. So
the fallback below reads the scope's own path, which is the one identity
both spellings agree on.
Source code in src/symfonic/capabilities/memory/factory.py
as_tenant_scope ¶
The legacy scope value for a capability scope. Kinds from compat.
Source code in src/symfonic/capabilities/memory/graph_rows.py
build_provenance ¶
build_provenance(*, source_conversation_id: str, source_scope_path: str, promoted_by: str, extraction_confidence: float, promoted_at: str | None = None) -> dict[str, Any]
The provenance a newly promoted memory carries.
Source code in src/symfonic/capabilities/memory/promotion.py
classification_identity ¶
classification_identity(properties: Mapping[str, Any], *, layer: Any = None) -> tuple[tuple[str, str] | None, str]
Read the storage-owned atomic classification contract, lazily.
Source code in src/symfonic/capabilities/memory/operations.py
contribution_spec ¶
Project a validated declaration into compiler-ready keyword values.
A mapping rather than a compiler object: the composition root — which is
allowed to see both capabilities — turns this into a PromptContribution,
so neither capability can quietly start depending on the other's internals.
Enum members are emitted as their string values; the prompt contract's
layers, tiers, and scopes are StrEnums over the same strings, so the
root's conversion is total by construction.
Source code in src/symfonic/capabilities/memory/contribution.py
deep_phases ¶
deep_phases(*, graph: Any, store: Any = None, policy: Any = None, entity_extractor: Any = None, scope_promoter: Any = None, entity_min_mention_count: int | None = None, entity_max_episodics_per_run: int | None = None, entity_confidence_threshold: float | None = None, **nightly: Any) -> tuple[ConsolidationPhase, ...]
Build the complete DEEP roster, in roster order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
Any
|
the |
required |
store
|
Any
|
the memory store, for the phases that learn from a scope's turns. Without it, three of the seventeen decline. |
None
|
entity_extractor
|
Any
|
what turns an episode into candidate entities. Absent, phase 12.5 declines -- which is the shipped default, not a misconfiguration. |
None
|
scope_promoter
|
Any
|
|
None
|
**nightly
|
Any
|
forwarded to :func: |
{}
|
Source code in src/symfonic/capabilities/memory/phases/deep.py
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | |
flatten ¶
importance_to_salience ¶
Map the legacy 1–10 importance grid onto [0, 1] salience.
Clamped rather than refused: an out-of-range importance is a row legacy already stored (its own validator only fires on construction, not on read), and refusing to read it would make one bad row poison a whole retrieval.
Source code in src/symfonic/capabilities/memory/compat.py
is_promotable ¶
is_promotable(candidate: PromotionCandidate, *, confidence_floor: float = DEFAULT_PROMOTION_CONFIDENCE_FLOOR) -> bool
Whether candidate may be published to a broader scope.
Source code in src/symfonic/capabilities/memory/promotion.py
json_payload ¶
The first JSON object in text, or None.
Same brace-matching heuristic the legacy parser uses, with one addition:
a payload that parses to something other than an object is refused rather
than returned. json.loads on [1, 2] succeeds, and a caller that
then asks it for ops gets an AttributeError from inside a
post-response stage instead of "the model did not answer in our format".
Source code in src/symfonic/capabilities/memory/families.py
layer_index ¶
legacy_entry_payload ¶
legacy_entry_payload(record: MemoryRecord, *, metadata: Mapping[str, Any] | None = None) -> dict[str, Any]
Build the kwargs a legacy MemoryEntry is constructed from.
Source code in src/symfonic/capabilities/memory/compat.py
legacy_node_payload ¶
legacy_node_payload(record: MemoryRecord, *, durability: str = 'durable', provenance: Mapping[str, Any] | None = None, properties: Mapping[str, Any] | None = None) -> dict[str, Any]
Build the kwargs a legacy MemoryNode is constructed from.
label carries the text because that is where legacy keeps it — its
commit_pending writes content=op.node.label. Everything this
capability knows and legacy does not rides in properties, which every
graph backend persists as an opaque bag.
Source code in src/symfonic/capabilities/memory/compat.py
legacy_scope_path ¶
Materialise scope into the string legacy stores and filters on.
Source code in src/symfonic/capabilities/memory/compat.py
link_entities ¶
link_entities(records: Sequence[MemoryRecord], *, scope: MemoryScope, extractor: HeuristicEntityExtractor | None = None, min_mention_count: int = 2, confidence_threshold: float = 0.5, max_records: int = 200) -> LinkingResult
Mint entities for surfaces mentioned often enough, and link co-mentions.
Source code in src/symfonic/capabilities/memory/linking.py
memory_capabilities ¶
memory_capabilities(store: Any, scope: MemoryScope | Any, *, limit: int = 5, recall_budget: RecallBudget | None = None, extractor: MemoryExtractorPort | None = None, consolidation: Any | None = None, conversation: Any | None = None, recent_turns: int = 0, activation: Any | None = None) -> list[Any]
The capability and the grants it needs, as one list to compose.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
Any
|
an HMS satisfying the retrieval, write and lifecycle ports. |
required |
scope
|
MemoryScope | Any
|
the scope this agent serves. Closed over by the capability, so one agent is one tenant. |
required |
limit
|
int
|
how many memories a turn recalls. |
5
|
recall_budget
|
RecallBudget | None
|
explicit UTF-8 rendered recall ceilings. None preserves legacy character caps; unrelated to the working conversation window. |
None
|
extractor
|
MemoryExtractorPort | None
|
a :class: |
None
|
consolidation
|
Any | None
|
a :class: |
None
|
activation
|
Any | None
|
a :class: |
None
|
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
if |
Returned together because a capability may not grant itself an effect and a caller that forgot one would get a fold refusal naming a grant rather than a missing feature. The three are what memory is.
Prompting is NOT included, and composing memory alone does not put recall in front of the model. Memory is a resolution stage: it reaches the store and leaves an entry in the turn's snapshot. Prompting is the compilation stage that reads that snapshot and renders it. Fold memory by itself and both ports are called, the block is composed, the snapshot is populated -- and the model receives the bare instructions, because nothing consumed the entry. That exact defect has been found in this codebase twice.
It is not added here because a factory named for memory that quietly
composed prompting would decide a deployment's prompt on its behalf. A
composition root wanting recall in the prompt adds a PromptingCapability
beside this, and :mod:tests.platform.test_vertical_slice shows the pair.
Source code in src/symfonic/capabilities/memory/factory.py
merge_provenance ¶
merge_provenance(existing: dict[str, Any] | None, *, source_conversation_id: str, source_scope_path: str, promoted_by: str, extraction_confidence: float) -> dict[str, Any]
Append a corroborating conversation to an existing provenance.
The highest confidence observed wins and the latest promotion time stands: a second conversation confirming a fact makes it more trustworthy, and taking the newer (possibly lower) confidence would let one weak restatement demote a well-established memory.
Source code in src/symfonic/capabilities/memory/promotion.py
mint_operation ¶
mint_operation(op: Mapping[str, Any], index: int, request: ExtractionRequest, family: ProviderFamily, scrubber: CredentialScrubber) -> ExtractedMemory | tuple[str, str]
Mint one memory from one operation, or report why it produced none.
Returns an :class:ExtractedMemory, or (identifier, reason) where an
empty reason means "this operation was a legitimate no-op" — a noop
action is the model saying there is nothing to remember, which is an answer
rather than a refusal.
Source code in src/symfonic/capabilities/memory/operations.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | |
new_owner_token ¶
nightly_phases ¶
nightly_phases(*, graph: Any, store: Any = None, procedural: Any = None, pending_connections: Any = (), llm_summarise: Any = None, procedural_extractor: Any = None, procedural_model_name: str = '', stale_days: int | None = None, **quick: Any) -> tuple[ConsolidationPhase, ...]
Build the complete NIGHTLY roster, in roster order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
Any
|
the |
required |
store
|
Any
|
the memory store, for the two phases that learn from a scope's turns. Without it they decline: reading episodic evidence through anything but the retrieval port would mean a second answer to "what has this scope done", and the wrong one. |
None
|
procedural
|
Any
|
where a draft skill is written. Without it phase 12 declines rather than extracting patterns it cannot store. |
None
|
pending_connections
|
Any
|
inferred edges to materialise this cycle. |
()
|
llm_summarise
|
Any
|
what names a cluster, for phase 4. |
None
|
procedural_extractor
|
Any
|
a model-backed
:class: |
None
|
stale_days
|
int | None
|
the decay horizon, when a deployment tunes it. |
None
|
**quick
|
Any
|
forwarded to :func: |
{}
|
Source code in src/symfonic/capabilities/memory/phases/nightly.py
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |
phase_graph ¶
The store the phases write through, from a store or a bare backend.
isinstance rather than duck-typing: both objects answer to
query_nodes and their signatures differ (the store takes layer=,
the backend takes a filter mapping), so a check that guessed from the shape
would guess wrong exactly where it mattered.
The backend is wrapped in :class:~symfonic.capabilities.memory.journal. JournalledGraph and the store in :class:~symfonic.capabilities.memory. fencing.FencedGraph, which is why every factory routes through this one
function. Ten of the roster's phase modules write to the graph directly
rather than through the write coordinator, so anything applied phase by
phase would be a rule each of them -- and each one written later -- has to
remember. Applied here it is structural: a phase gets both by being handed
its graph.
Both are inert outside a cycle. The journal defers mutations only while one is running on this task and the fence checks only while a lease is held, so single-process use, an ordinary turn and every unit test behave exactly as before.
A deployment that shares one backend between the phases and its memory
layers should wrap it once at the composition root instead -- see
:class:~symfonic.capabilities.memory.journal.JournalledGraph. Wrapping
here covers what the phases reach; it cannot cover what
ProceduralLayer writes through a store this function never sees.
Source code in src/symfonic/capabilities/memory/phases/phase_graph.py
procedural_layer ¶
The procedural store over graph.
graph is the GraphBackend a composition root already has, or the
GraphMemoryStore over it -- the same pair :func:~.phases.quick. phase_graph accepts, and for the same reason.
Imported inside the call: the layer pulls in the router, the predicates and
the skill renderer, and a deployment that never learns a procedure should
not pay for them in every import symfonic.agent.
Source code in src/symfonic/capabilities/memory/procedural.py
promote ¶
promote(candidate: PromotionCandidate, target: MemoryScope, *, promoted_by: str, promoted_at: str | None = None) -> Promotion
Restate candidate's memory at target, with its provenance.
The record id is kept. Ids are the upsert key within a scope, so re-promoting the same fact overwrites its own earlier promotion instead of accumulating near-duplicates of it, which is what makes running consolidation twice harmless.
Source code in src/symfonic/capabilities/memory/promotion.py
quick_phases ¶
quick_phases(*, graph: Any, episodic: Any | None = None, profile_fields: frozenset[str] | None = None, chat_model: Any | None = None, embedding_provider: Any | None = None, lookback_hours: float = 24.0, spreading_weight: float = 0.5, llm_summarise: Any | None = None, episodic_max_entries: int = 100, episodic_summarize_batch: int = 50, embedding_threshold: float = DEFAULT_EMBEDDING_THRESHOLD, lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD, max_pairs_per_run: int = 10) -> tuple[ConsolidationPhase, ...]
Build the complete QUICK roster, in roster order.
graph is the one hard requirement: three of the four phases read and
write the semantic graph, and a roster built without one would be three
phases that fail on their first call rather than a roster that was never
composed. Everything else is optional, and its absence makes exactly one
phase decline.
Source code in src/symfonic/capabilities/memory/phases/quick.py
rank_key ¶
Total order over retrieved memories.
A memory carrying a source_ordinal keeps the position its store gave it,
and sorts ahead of everything that does not. That is not a preference for
pre-ranked stores; it is the only way their order survives at all. The local
key below is deterministic, which is what makes it dangerous: on equal
scores, or on the all-None scores a keyword layer produces, it replaces
an external ranking with a plausible-looking one and nothing looks wrong.
Everything else ranks by score, then nearer scope. An unscored memory uses
0.0 for ordering only; admission still preserves the absent signal.
Source code in src/symfonic/capabilities/memory/queries.py
read_reply ¶
Read response into text, or report that its shape is unknown.
Source code in src/symfonic/capabilities/memory/families.py
record_from_legacy_node ¶
Read a stored legacy node back into a record.
A pre-v8 row with no scope_path reads as tenant-global — the same
conservative backfill legacy's own dual-read applies, because those rows
were tenant-global before the key existed.
Source code in src/symfonic/capabilities/memory/compat.py
resolve_contribution_scope ¶
Turn a caller's scope string into a member, or refuse it in-hierarchy.
Source code in src/symfonic/capabilities/memory/contribution.py
resolve_layer ¶
Turn a caller's layer string into a member, or refuse it in-hierarchy.
MemoryLayer('reflective') raises a bare :class:ValueError, which a
caller guarding on :class:~.errors.MemoryCapabilityError would miss. Every
way of naming a layer wrong reports the same way.
Source code in src/symfonic/capabilities/memory/layers.py
salience_to_importance ¶
Map [0, 1] salience back onto the legacy 1–10 grid.
Source code in src/symfonic/capabilities/memory/compat.py
scope_from_legacy_path ¶
Read a stored scope_path back into a scope.
The kinds are discarded rather than validated: an adopter who named their
levels org/brand/conversation has the same three-level
hierarchy under different labels, and refusing their rows would make the
migration a data conversion instead of a re-read.
Source code in src/symfonic/capabilities/memory/compat.py
scope_from_path ¶
Parse the canonical path form back into a scope.
Source code in src/symfonic/capabilities/memory/scope.py
select ¶
select(memories: Iterable[RetrievedMemory], query: MemoryQuery, *, sources: Mapping[str, int] | None = None, unavailable: tuple[str, ...] = ()) -> RetrievalResult
Rank, filter, and cap what a store returned. Never mutates the input.