MemorySync

Conversation History and Framework State, Kept Apart From Memory

New /v1/history and /v1/state endpoints keep transcripts and framework state exactly as written, in stores of their own, so they never show up in memory search.

Published on 5 min read

Conversation History and Framework State, Kept Apart From Memory
On this page

A chat framework that replays history needs every turn back exactly as it was written, including the assistant replying Noted. A memory store does the opposite job: every write goes through extraction, and only the durable facts it finds are stored. Ask one store to do both and it does one of them badly.

Today we are adding two stores beside memory: conversation history at /v1/history/* and framework state at /v1/state/*. The Python SDK supports both from memorysync 1.10.0, and the TypeScript SDK has the same methods in camelCase.

Three stores, three jobs

The running example is Acme's travel assistant and one of its users, user_42, chatting in a session called thread-42.

StoreWhat it holdsHow you read it
MemoriesDurable facts extracted from what you sendSearch ranked by meaning, with query and recall
Conversation historyThe turns of a session, exactly as givenlist_history, oldest first
Framework stateNamespaced key/value values a framework keeps between runsget_state, list_state and search_state

History and state are never memories. Nothing written to them appears in memory search, memory lists or recall, so a replayed transcript cannot crowd the facts out of a prompt, and a framework's saved record cannot be mistaken for something the user said.

The stores still connect where it helps. User turns appended to history can be sent to extraction, and the facts found become ordinary memories of the same user.

Conversation history

Append turns to a session, read them back in order, and delete them when the conversation is gone. This is the example from the SDK README:

Python
ms.append_history(
tenant_id="acme",
user_id="user_42",
session_id="thread-42",
source="my-app",
turns=[
{"role": "user", "content": "I prefer window seats.", "seq": 0},
{"role": "assistant", "content": "Noted.", "seq": 1},
],
)
history = ms.list_history(tenant_id="acme", user_id="user_42", session_id="thread-42")
for turn in history["items"]: # oldest first
print(turn["role"], turn["content"])
ms.delete_history(tenant_id="acme", user_id="user_42", session_id="thread-42")

How it behaves:

  • Turns come back exactly as given, oldest first. When any turn in the session has a seq, the turns are ordered by it. One call appends up to 100 turns.
  • User turns go to extraction by default. With extract=True, the default, the user turns are also sent to fact extraction, and the facts found become memories of the same user, carrying the session_id. I prefer window seats. is kept word for word in history, and the preference it states also becomes a memory of user_42. Pass extract=False to keep the transcript only.
  • The assistant turn stays in history only. Noted. is part of the transcript the framework replays, and it never becomes a memory.

Framework state

Some frameworks keep records between runs, such as a store item or a saved record about the user. Framework state holds those as namespaced key/value pairs. Again from the README:

Python
ns = ["memories", "user_42"]
ms.put_state(tenant_id="acme", user_id="user_42", namespace=ns, key="diet",
value={"diet": "vegan"}, source="my-app")
item = ms.get_state(tenant_id="acme", user_id="user_42", namespace=ns,
key="diet", source="my-app")["item"]
hits = ms.search_state(tenant_id="acme", user_id="user_42", query="what does the user eat",
namespace_prefix=["memories"], source="my-app")
ms.delete_state(tenant_id="acme", user_id="user_42", namespace=ns, keys=["diet"],
source="my-app")

How it behaves:

  • A put replaces the key's value. Putting the identical value again returns created: False, so a retried put is safe.
  • `namespace` is a list of 1 to 20 strings, and `value` must be a JSON object. get_state returns item: None when the key has no value.
  • `search_state` ranks values against a query. It uses cosine similarity when the value was embedded, which is the default, and token overlap when you passed index=False.
  • State is not extracted unless you ask. extract=False is the default for a put. Set it to True to also extract durable facts from the value's text.
  • Pass the same `source` on every call. Rows are scoped by source, and the state reads default to "api", so a value put with source="my-app" is invisible to a read that leaves source out.

Retries and deletes

Both stores are built to be called from code that retries:

  • A history turn retried with the same seq, role and content, or the same idempotency_key, is not stored twice. The stored turn comes back with created: False. A turn with neither is appended every time, so give your turns a seq or a key.
  • delete_history takes exactly one of session_id or ids. Passing both, or neither, is refused before any request is sent.
  • delete_history(..., delete_derived_facts=True) also deletes the memories extracted from those turns, and their ids come back in deleted_fact_ids. Without it, the facts stay after the transcript is gone.
  • Omitting keys on delete_state deletes the whole namespace, and an empty list deletes nothing. delete_state takes delete_derived_facts=True as well.
Python
# Remove the session and everything extracted from it
result = ms.delete_history(
tenant_id="acme",
user_id="user_42",
session_id="thread-42",
delete_derived_facts=True,
)
print(result["deleted_fact_ids"])

Decide which behaviour you want before a user asks to clear a conversation. Clearing the transcript and forgetting what the user said in it are two different requests, and the flag is how you tell them apart.

What each call costs

  • An append counts one add, however many turns it carries, and a put counts one add. A retry of something already stored is not counted again.
  • Reads count one retrieval each, whatever they return.
  • History and state deletes are free.
  • Over the plan limit the calls return normally and store nothing. Writes answer status: "skipped" and reads come back empty.

So a framework that appends a whole exchange in one call pays one add for it, while one that appends turn by turn pays per call. Batch turns where the framework allows it.

The same calls in TypeScript

The TypeScript SDK takes camelCase requests and returns the server's JSON as it is, so response fields stay snake_case:

TypeScript
await ms.appendHistory({
tenantId: "acme",
userId: "user_42",
sessionId: "thread-42",
source: "my-app",
turns: [
{ role: "user", content: "I prefer window seats.", seq: 0 },
{ role: "assistant", content: "Noted.", seq: 1 },
],
});
const history = await ms.listHistory({ tenantId: "acme", userId: "user_42", sessionId: "thread-42" });
for (const turn of history.items) console.log(turn.role, turn.content); // oldest first

Framework adapters are the natural users of both stores, and Memory for Every Agent Stack lists the official ones. If you write your own adapter, put the transcript in history, put framework records in state, and let extraction turn the user's words into memories.

Read the reference

Every field, limit and response shape for both stores is in Conversation History & State.

Share article

Blog

Try it on your own workload

The free tier stores real memories and returns real rankings. One write and one retrieval to start.

Start free. No credit card required.