Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

everalgo-user-memory

User-side memory products for EverAlgo — four LLM-backed extractors (EpisodeExtractor, ForesightExtractor, AtomicFactExtractor, ProfileExtractor), an EpisodeReflector that merges several episodes into one narrative, plus a BoundaryDetector class facade that wraps everalgo-boundary.

See the umbrella project: EverAlgo monorepo and the architecture document at docs/concepts/architecture.md.

Install

pip install everalgo-user-memory
# Auto-pulls: everalgo-core, everalgo-boundary

Quick start

All extractors are stateless classes; pass llm= at construction time. The sender_id argument is always required and is not inferred from the conversation.

import asyncio
import json

from everalgo.llm.types import ChatResponse
from everalgo.testing.fake_llm import FakeLLMClient
from everalgo.types import ChatMessage, MemCell
from everalgo.user_memory import (
    BoundaryDetector,
    EpisodeExtractor,
    ForesightExtractor,
    AtomicFactExtractor,
    ProfileExtractor,
)

_BOUNDARY_JSON = json.dumps({"reasoning": "single topic", "boundaries": [], "should_wait": False})
_EPISODE_JSON  = json.dumps({"title": "Alice asks about async retries", "content": "Alice explored async retry patterns.", "summary": "Alice explored async retry patterns."})
_FORE_JSON     = json.dumps({"foresights": [{"content": "Alice will read the follow-up doc", "evidence": "assistant promised a doc", "start_time": "2023-11-14", "end_time": "2023-11-21", "duration_days": 7}]})
_FACT_JSON     = json.dumps({"atomic_facts": {"time": "Nov 14 2023", "atomic_fact": ["Alice is learning Python async."]}})
_PROFILE_JSON  = json.dumps({"explicit_info": [], "implicit_traits": [{"trait": "Pragmatic", "description": "Prefers minimal-ceremony tooling."}]})


async def main() -> None:
    messages = [
        ChatMessage(id="m1", role="user",      content="I want to learn Python async retry patterns.", timestamp=1_700_000_000_000, sender_id="u_alice", sender_name="Alice"),
        ChatMessage(id="m2", role="assistant",  content="Sure — I'll send a follow-up doc next week.", timestamp=1_700_000_001_000, sender_id="assistant"),
    ]

    fake = FakeLLMClient(responses=[
        ChatResponse(content=_BOUNDARY_JSON, model="fake"),
        ChatResponse(content=_EPISODE_JSON,  model="fake"),
        ChatResponse(content=_FORE_JSON,     model="fake"),
        ChatResponse(content=_FACT_JSON,     model="fake"),
        ChatResponse(content=_PROFILE_JSON,  model="fake"),
    ])

    # Step 1: boundary detection → MemCell
    result = await BoundaryDetector(llm=fake).adetect(messages, is_final=True)
    mc = result.cells[0]

    # Step 2–4: user-memory extractors
    episode   = await EpisodeExtractor(llm=fake).aextract(mc, sender_id="u_alice")
    foresights = await ForesightExtractor(llm=fake).aextract(mc, sender_id="u_alice")
    facts      = await AtomicFactExtractor(llm=fake).aextract(mc, sender_id="u_alice")

    # Step 5: Profile takes a chronological Sequence[MemCell]; last is most recent
    profile = await ProfileExtractor(llm=fake).aextract([mc], sender_id="u_alice")

    print(episode.subject, profile.summary)


asyncio.run(main())

See examples/06_full_user_memory_pipeline.py for the complete end-to-end example including geometry clustering.

Choosing the output language

Every LLM-backed method takes an output_language. Name one and the model writes in it; leave it out and the model works the language out for itself, which is measurably less reliable:

from everalgo.user_memory import EpisodeExtractor, OutputLanguage

# Caller decides. Zero wrong-language output over the regression corpus: seven languages, five models,
# every interference pattern it holds.
episode = await EpisodeExtractor(llm=client).aextract(
    mc, sender_id="u_alice", output_language=OutputLanguage.CHINESE
)

# Model decides. Roughly one extraction in nine comes back in the wrong language, and which cases fail
# depends on the model — one of the five measured never drifted, another drifted on a quarter of them.
episode = await EpisodeExtractor(llm=client).aextract(mc, sender_id="u_alice")

Plain strings work too, in any casing ("chinese", "German") — convenient when the value comes from config. An unrecognised name raises ValueError rather than reaching the prompt.

Decide the language once, upstream, and pass the same value to every call. The alternative — deriving one extractor's language from another's output — inherits that extractor's error rate, and for profiles the consequence compounds: a profile updated without a named language inherits whatever language it already says, so one wrong INIT persists through every later update. Passing the language on the update is the way back out. Note also that category and trait labels are model-authored, so they follow the argument along with the descriptions (Location versus 居住地) — worth knowing if you group or filter on them.

What "leave it out" means depends on the operator. The four reading a raw conversation judge the language from what the participants write, which is the 10.2% path above. The three reading already-extracted memory — AtomicFactExtractor.aextract_from_text, ProfileExtractor.aextract_from_episodes, and EpisodeReflector.areflect — instead inherit the language of their input, which is a much easier call for a single-language narrative but not free. The Profile Episode-text path and areflect can take several episodes, so inputs that disagree on language leave the model to pick one; an update inherits the existing Profile or narrative language. Name a language when inputs may disagree, or to move an existing result that is already in the wrong one.

Customising prompts

Each extractor accepts a prompt= override per call, or the module-level constant can be monkey-patched at startup for a global override:

# Per-call override. A replacement keeping the {language_rule} placeholder keeps output-language control;
# one that drops it opts out.
episode = await EpisodeExtractor(llm=client).aextract(mc, sender_id="u_alice", prompt=my_custom_prompt)

# Global: replace the default prompt at startup
import everalgo.user_memory.prompts.en.foresight as _fs
_fs.FORESIGHT_GENERATION_PROMPT = my_custom_prompt

Prompts ship in English only. A parallel prompts/zh/ tree used to carry translations and was removed: prompt language turned out to dictate output language almost entirely, so the translations were an implicit language switch maintained by hand. output_language does that job from one prompt tree, for languages nobody has to translate a prompt into.

API surface

class BoundaryDetector:
    def __init__(self, *, llm: LLMClient) -> None: ...
    async def adetect(
        self, messages: list[ChatMessage], *, is_final: bool = False, prompt: str | None = None
    ) -> DetectionResult: ...

class EpisodeExtractor:
    def __init__(self, *, llm: LLMClient) -> None: ...
    async def aextract(
        self, memcell: MemCell, *,
        sender_id: str | None,           # None → generic whole-memcell episode (cheaper)
        prompt: str | None = None,
        custom_instructions: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → the model infers it
    ) -> Episode: ...

class ForesightExtractor:
    def __init__(self, *, llm: LLMClient) -> None: ...
    async def aextract(
        self, memcell: MemCell, *,
        sender_id: str,
        prompt: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → the model infers it
    ) -> list[Foresight]: ...

class AtomicFactExtractor:
    def __init__(self, *, llm: LLMClient) -> None: ...
    async def aextract(
        self, memcell: MemCell, *,
        sender_id: str | None,           # None → generic facts not bound to any user
        prompt: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → the model infers it
    ) -> list[AtomicFact]: ...

    async def aextract_from_text(
        self, text: str, *,
        timestamp: int,                  # anchors relative dates the text mentions
        prompt: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → inherited from the input
    ) -> list[AtomicFact]: ...

class ProfileExtractor:
    def __init__(self, *, llm: LLMClient) -> None: ...
    async def aextract(
        self, memcells: Sequence[MemCell], *,
        sender_id: str,
        old_profile: Profile | None = None,   # None → INIT mode; present → UPDATE mode
        prompt: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → the model infers it
    ) -> Profile: ...

    async def aextract_from_episodes(
        self, episodes: Sequence[Episode], *,   # dated narratives, any order; only the ones new to the profile
        owner_id: str,
        owner_name: str | None = None,     # non-blank name targets the owner; otherwise owner_id
        old_profile: Profile | None = None,
        categories: Sequence[str] | None = None,   # complete current explicit_info category snapshot
        prompt: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → inherited from Episode text/profile
    ) -> Profile: ...

class EpisodeReflector:
    def __init__(self, *, llm: LLMClient) -> None: ...
    async def areflect(
        self, episodes: Sequence[Episode], *,
        old_episode: Episode | None = None,   # None → INIT merge; present → UPDATE
        prompt: str | None = None,
        output_language: OutputLanguage | str | None = None,   # None → inherited from the input
    ) -> Episode: ...

Every episode carries three model-written fields: subject (the title), episode (the full narrative) and summary (a display preview of the narrative — faithful to it, readable without it, under 50 words). All three are required; if the model omits summary or returns it blank, aextract raises rather than substituting a value. Up to 0.4 the field was a blind episode[:200] slice, because the prompts never asked for it — a truncation cut mid-word in English, and in Chinese a verbatim copy of most of the body.

EpisodeReflector produces the same three fields, so a merged episode has a preview of the merged narrative.

EpisodeExtractor has two modes: pass sender_id=str to extract a user-focused episode (uses USER_EPISODE_GENERATION_PROMPT); pass sender_id=None for a generic whole-memcell episode (uses EPISODE_GENERATION_PROMPT).

ProfileExtractor accepts either chronological MemCell objects through aextract or dated generic/reflected Episode objects, in any order, through aextract_from_episodes. The Episode path resolves one target from non-blank owner_name or falls back to owner_id, skips narratives that do not contain that target, and raises before the first LLM call only when none contain it. Its optional categories argument is the complete current category snapshot for explicit_info: all four processing stages receive the same normalized list, select the most semantically accurate listed match, and may create a concise category when none fits; the list does not constrain implicit_traits.trait. Both paths use old_profile=None for INIT and an existing Profile for UPDATE; transparent compact and regroup maintenance is shared.

The merge is time-aware. Every profile item carries observed_at, the observation date of the narrative that last established it, and Profile.timestamp never moves backwards. An Episode observed before an item was established may add facts and evidence but cannot rewrite or delete that item — the rule is stated in the UPDATE prompt and enforced in code — so a backfilled older Episode arriving after a newer one cannot turn the profile back in time. Callers pass only the Episodes new to the profile; a batch that straddles the stored Profile.timestamp is split into a historical pass and a current pass. See the Episode integration contract.

All class methods have a sync bridge: extractor.extract(...) is async_to_sync(aextract), and the Episode Profile method is exposed as extract_from_episodes(...) — only for non-event-loop callers (CLI scripts, plain unit tests).

Testing

from everalgo.testing import FakeLLMClient, assert_episode_shape

fake = FakeLLMClient(responses=[ChatResponse(content=_EPISODE_JSON, model="fake")])
episode = await EpisodeExtractor(llm=fake).aextract(mc, sender_id="u_alice")
assert_episode_shape(episode)

See the integration test pattern in tests/integration/.

Related distributions

  • everalgo-boundary — detect_boundaries primitive used by BoundaryDetector
  • everalgo-clustering — geometry / LLM clustering for grouping MemCells before ProfileExtractor
  • everalgo-rank — ranks Episode, AtomicFact, Profile candidates at read time

Metadata

Release files for everalgo-user-memory 0.8.0rc8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for everalgo-user-memory 0.8.0rc8
File Size Uploaded
everalgo_user_memory-0.8.0rc8.tar.gz 136.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for everalgo-user-memory 0.8.0rc8
File Interpreter ABI Platform
everalgo_user_memory-0.8.0rc8-py3-none-any.whl Python 3 none any Details

Total release size: 216.1 kB

Release files / everalgo_user_memory-0.8.0rc8.tar.gz

Download URL everalgo_user_memory-0.8.0rc8.tar.gz
Size 136.1 kB
Tags Source
SHA-256 checksum
How to use checksums
d692ac8f1dffbdde7d8b97017c71146a419d57a01360858dc5b7d07678298288
BLAKE2b-256 checksum
How to use checksums
5d33340ca91d980173c739124780c3959f319a5f63c76913da5bb9c02a47f306
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.12.12

Release files / everalgo_user_memory-0.8.0rc8-py3-none-any.whl

Download URL everalgo_user_memory-0.8.0rc8-py3-none-any.whl
Size 80.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bff36284ce62bac2bbbb4f95fff896f0a2be15fbb60f11eb65dd620a50e47fab
BLAKE2b-256 checksum
How to use checksums
93ef720942048a1228541a41818c8c7cf0b15878ea6cb2544907ccad9964ea25
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.12.12
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page