Skip to content

Memory backend

Built and verified

MemoryBackend (in coniti.memory, also exported from coniti) and InMemoryBackend (in coniti.impl.memory) are built and pass the conformance suite, including the portable memory file (dump / load). They are the Container’s default memory piece. The ContinuityEngine that uses memory is built (see engine); “What the engine does with it” describes its behaviour.

Every read and write of memory goes through one abstract interface, MemoryBackend. The built-in implementation is InMemoryBackend, which lives in process memory and is gone when the process ends. Later it can be a database or a separate service, swapped in without touching the engine or any plug-in.

This follows the split LangGraph makes between its checkpointer interface and an in-memory saver meant for local work and testing. See research. coniti stores notes and vectors rather than graph checkpoints, so its operations are record-oriented, but the shape is the same: one ABC, request and result contracts, sync plus async, and an in-memory implementation.

The record

A backend stores MemoryRecord(note, vector, embedding_id=None, namespace=()). record.id is the note’s id. The Note is described in layers. A Namespace is a tuple of non-empty strings, () by default. It is addressing (for example per user), not a session.

Operations

Each operation takes a request contract and returns a result contract. Implementations may extend those models but never replace them. A wrong request type raises ContractError.

Operation Request → result Purpose
put PutRequest(records) → PutResult(written) Write records
get GetRequest(ids, namespace) → GetResult(records, missing) Read records by id
delete DeleteRequest(ids, namespace) → DeleteResult(deleted, missing) Remove records by id
scan ScanRequest(time_range, namespace, limit, cursor) → ScanResult(records, next_cursor) Read in timestamp order, optionally in a time range, paginated
count CountRequest(time_range, namespace) → CountResult(count) Count records
get_meta / set_meta MetaGetRequest(key, namespace) / MetaSetRequest(key, value, namespace) → MetaResult(value) Small key-value metadata (values are JSON-serialisable, finite, copied)
clear ClearRequest(namespace) → ClearResult(deleted) Remove everything in a namespace
dump / load file or stream, namespace → record count Write or read the portable memory file, see below

iter_all(time_range=None, namespace=(), page_size=500) is a helper that follows cursors page by page.

Async counterparts (aput, aget, adelete, ascan, acount, aget_meta, aset_meta, aclear) run the sync call in a worker thread by default; a backend may implement them natively.

Errors all derive from MemoryBackendError (a RuntimeError): DuplicateRecord, InvalidCursor and MemoryFileError (for the memory file).

Example

import numpy as np

from coniti import Note, TimeRange
from coniti.impl.memory import InMemoryBackend
from coniti.memory import MemoryRecord, PutRequest, ScanRequest


def rec(text: str, t: float) -> MemoryRecord:
    return MemoryRecord(
        note=Note.create(text, timestamp=t),
        vector=np.array([1.0, 0.0, 0.0]),
    )


memory = InMemoryBackend()
memory.put(PutRequest(records=[rec("first", 10.0), rec("second", 20.0), rec("third", 30.0)]))

# Half-open range [10, 30): "first" and "second".
window = TimeRange(start=10.0, end=30.0)
page = memory.scan(ScanRequest(time_range=window, limit=1))
print([r.note.text for r in page.records])      # ['first']

# Resume exactly where the page stopped.
page = memory.scan(ScanRequest(time_range=window, limit=1, cursor=page.next_cursor))
print([r.note.text for r in page.records])      # ['second']
print(page.next_cursor)                         # None: the end of the range

Rules every backend follows

These are checked by the conformance suite (see memory-backends).

  • put is all-or-nothing per call. An existing id, or an id repeated in the call, raises DuplicateRecord and nothing is written. The same id in different namespaces is allowed.
  • get and delete report unknown ids in missing and never raise for them. Repeated ids count once, in first-occurrence order. get keeps request order.
  • scan orders by timestamp then write order, uses half-open time ranges, and pages exactly at every page size. A cursor resumes correctly even if records were deleted or inserted between pages.
  • Cursors are opaque. A malformed cursor, the empty string, a cursor from another backend instance, or a cursor from another namespace raises InvalidCursor.
  • Namespaces are isolated for scan, get, delete, count, meta and clear.
  • Records are copied in and out: mutating what you passed or received never changes memory.
  • Async results match sync results.

InMemoryBackend

Kind in_memory. Per namespace it keeps records in a list sorted by (timestamp, write order) and finds ranges by binary search. Cursors are opaque and tied to the instance and the namespace. Nothing is persisted.

What the engine does with it

  • The engine keeps no copy of notes or vectors. Structures are rebuilt from scan.
  • Records carry the id of the embedding spec that produced them. The engine refuses to open a backend whose stored embedding id differs from its own.
  • snapshot() is produced from scan and loaded through put, so a snapshot moves memory between any two backends.

Serialisation and the memory file

Built and verified

Requirement 15, task 4D. dump and load are concrete methods on MemoryBackend, written only with the backend’s public operations, so every backend inherits them and a file written by one backend loads into any other. The conformance suite checks them for every new backend.

The goal is that external applications and libraries can integrate with little thought, and that a memory can be backed up, recovered and moved between machines or backends using standard formats. Every contract model and spec also has to_dict, from_dict, to_json, from_json and json_schema, see contracts. For a how-to, see backup-restore.

dump and load

n = memory.dump("backup.jsonl")                    # returns the number of records written
n = memory.dump("backup.jsonl.gz")                 # gzip, chosen by the name
n = memory.dump("alice.jsonl", namespace=("alice",))

n = other.load("backup.jsonl.gz")                  # returns the number of records loaded
n = other.load("backup.jsonl", namespace=("bob",)) # re-home every record into ("bob",)
  • dump(target, namespace=()) writes one namespace. target is a path (str or Path) or an open text stream.
  • load(source, namespace=None) reads a path or a text stream. namespace=None keeps each record’s own namespace; a namespace re-homes every record into it.
  • The path form is the one that picks gzip, from the name ending in .gz. With a stream you pass already-decoded text, so compress it yourself.
  • dump streams the records page by page. load reads and validates the whole file before it writes anything, so it holds the records in memory while it does.

The format

JSON Lines, UTF-8: one JSON object per line, \n line endings.

{"format": "coniti-memory", "version": "1.0.0", "embedding_id": "emb-74006f08273e", "namespace": [], "created_at": "2026-10-30T09:24:42Z", "count": 3}
{"note": {"id": "n1", "text": "...", "timestamp": 100.0, "metadata": {}}, "vector": [0.447, 0.0, ...], "embedding_id": "emb-74006f08273e", "namespace": []}
...
Header field Meaning
format Always "coniti-memory".
version The format version, semantic versioning (currently 1.0.0). A reader accepts files of the same major version.
embedding_id The embedding spec id the stored vectors belong to, or null. Taken from the embedding_id entry in the backend’s metadata for the dumped namespace.
namespace The namespace that was dumped (a list of strings).
created_at When the file was written, RFC 3339 with a Z offset.
count The number of record lines that follow.

Each following line is one MemoryRecord as to_dict() gives it: note, vector, embedding_id and namespace. Timestamps stay numeric so they round-trip exactly; vectors are written as full-precision floats and load back identical.

Where the header’s embedding_id comes from

dump reads the backend’s embedding_id metadata; it does not infer it from the records. If nothing has set it, the header says null and load skips the embedding check. The engine sets it on the first write; if you fill a backend by hand and want the check, set it once: memory.set_meta(memory.meta_set_request(key="embedding_id", value=spec_id)) with the spec id from Container.resolved()["embedding"].spec_id.

Guarantees

dump never leaves a half-written file. For a path, it writes <name>.partial beside the target and renames it over the target only when every record was written. On any failure the partial file is removed and the previous file (if any) is untouched. If memory changes while dumping, so that the number of records written differs from the header’s count, dump fails with MemoryFileError instead of writing a file that disagrees with itself. Dumping to a stream cannot be undone: write to a path if you need the guarantee.

load validates everything first and writes nothing on error. It reads the whole file and checks, in this order of concern:

  • the format name and a semantic version with the supported major version;
  • the header fields, count, namespace, embedding_id and created_at;
  • no blank lines, and every record valid (as if constructed, so no NaN or infinity);
  • no duplicate id within a namespace in the file;
  • the record count equals the header’s count (this catches truncated or edited files);
  • the embedding id: if both the file and the target have one and they differ, it is refused;
  • no record id already exists in the target’s namespace.

Any failure raises MemoryFileError with the line number where there is one, and the target is unchanged. Corrupt gzip and I/O errors are also MemoryFileError.

Failure while writing. After validation, load writes in batches of 500. If a write fails midway it deletes what it wrote and raises MemoryFileError (“load failed and was rolled back”). The rollback is best-effort: it uses the backend’s own delete, so a backend that is failing may not be able to undo it.

The embedding id travels with the memory. If the file has an embedding_id and the target has none, load sets it in the target (in the header’s namespace, or the one you re-homed into), so later loads and the engine see it.

try:
    other.load("backup.jsonl")                       # records n1.. already exist
except MemoryFileError as exc:
    print(exc)    # record id 'n1' already exists in namespace (); nothing was loaded

Known limit

The file stores the base vector of each record. Extra fields of an extended embedder output are not part of a memory record, so they are not in the file (see embedding). There are no async dump / load yet.