Skip to main content

Topic keys

A topic_key is an optional, stable identifier for an evolving topic. Save with the same key twice and the second call upserts in place — bumping revision_count, refreshing updated_at, preserving created_at, and overwriting session_id with the current session.

Why topic keys exist

Without a key, every mem_save call creates a brand new observation (modulo the hash-dedup safety net). That is the right default for one-off facts:

mem.mem_save(
user_id="alice",
type="discovery",
title="FTS5 ignores leading punctuation",
content="Found out FTS5 strips '.', '/', '-' from the prefix...",
)

But evolving topics — your app's auth model, an architectural decision being refined, a user's slowly-changing preferences — should be one row that grows over time, not N rows that you have to reconcile later.

# Initial decision
mem.mem_save(
user_id="alice",
type="decision",
title="Auth model",
content="Decided to use JWT with 1-hour TTL.",
topic_key="architecture/auth",
)

# Later in the same conversation — refines the decision
mem.mem_save(
user_id="alice",
type="decision",
title="Auth model",
content="Decided to use JWT with 1-hour TTL plus refresh tokens (15-day TTL).",
topic_key="architecture/auth",
)

The second call returns outcome='updated' — same id, revision_count is now 2, and the timeline shows the evolution.

Format convention

Topic keys are strings. There is no schema enforcement beyond "non-empty string". The convention this project uses (and recommends) is <area>/<sub-area>:

PatternExample
architecture/<area>architecture/auth, architecture/storage
bugfix/<area>bugfix/search-fts5, bugfix/session-timeout
convention/<area>convention/naming, convention/error-handling
user/<id>/<topic>user/alice/greeting, user/alice/locale
tip

The convention is purely advisory — pick whatever you and your team understand. The library only checks that the key is non-empty.

Anti-patterns

warning

Different topics must use different keys. Reusing the same key for unrelated facts will overwrite the previous observation. There is no warning, no merge, no conflict resolution — the second save wins (outcome='updated'). This is a foot-gun if you treat keys as casual labels.

Don't: same key, two different topics
# Bad — overwrites the first one
mem.mem_save(..., title="Auth model", topic_key="architecture")
mem.mem_save(..., title="Storage layer", topic_key="architecture")
# Result: only the storage one survives, with revision_count=2.
Don't: keys with timestamps or session IDs
# Bad — defeats the upsert. Each call gets a unique key → always 'created'.
topic_key=f"decision-{datetime.now().isoformat()}"

If you want unique observations, just omit topic_key entirely.

Hash-dedup as a safety net

Even without topic_key, the library protects you against accidental duplicate saves. The pipeline:

  1. Strip <private> regions from content.
  2. Compute SHA-256 of the post-strip content (normalized_hash).
  3. Look for a recent observation by the same user with the same hash inside the dedup window (default: 60 seconds).
  4. If found → outcome='deduped', no write, return existing id.

This catches the common "agent retried the same call twice" scenario without inflating storage.

r1 = mem.mem_save(user_id="alice", type="discovery", title="X", content="Same body")
r2 = mem.mem_save(user_id="alice", type="discovery", title="X", content="Same body")

assert r1.id == r2.id
assert r2.outcome == "deduped"
note

Hash-dedup runs only when topic_key is None. If you provide a key, the upsert path takes precedence — same content + same key returns deduped (no write); different content + same key returns updated (overwrite).

Inspecting evolution: mem_timeline

mem_timeline(user_id, observation_id) returns observations temporally adjacent to an anchor, useful for showing the local history around a topic in a UI.

timeline = mem.mem_timeline(user_id="alice", observation_id=auth_obs_id)

for obs in timeline:
print(obs.created_at, obs.title, obs.revision_count)

Next