Long-running session memory¶
SessionMemory is the durable, two-layer memory subsystem that lets a Bernstein agent remember what it did across days, across context-window compactions, and across machine reboots. It replicates on local disk what paid persistent-agent vendors sell as a hosted service.
The store has two layers:
| Layer | What it holds | Where it lives |
|---|---|---|
| Episodic | Verbatim turn-by-turn log, append-only JSONL, content-addressed | .sdd/memory/episodic/<task_id>/<session_id>.jsonl |
| Semantic | SQLite FTS5 index over the episodic content, tags, role | .sdd/memory/semantic.sqlite |
The episodic layer is the source of truth; the semantic layer is a secondary index optimised for BM25 recall.
Contract¶
from pathlib import Path
from bernstein.core.memory.session_memory import SessionMemory, Turn
mem = SessionMemory(
root=Path(".sdd/memory"),
task_id="t-7",
session_id="s-1",
)
# Record a turn
mem.append_turn(
Turn(role="user", content="design the API", tags=["api"]),
)
# Free-text recall, BM25 ranked, newest-first within rank
for hit in mem.recall("API", k=3):
print(hit.role, hit.content, hit.ts_ns)
Turn shape¶
A Turn has the following fields. Validation happens on append_turn.
| Field | Type | Notes |
|---|---|---|
role | str | One of user, assistant, system, tool. |
content | str | Non-empty. Stored verbatim. |
tags | list[str] | Free-form. No commas. Used as an optional recall filter. |
ts_ns | int | Wall-clock nanoseconds. Defaults to time.time_ns(). |
The append also stores task_id, session_id, and a sha256:<hex> content hash so a downstream auditor can correlate a turn with the lineage entry of any artefact it produced.
Recall¶
queryis tokenised by FTS5porter unicode61. Empty queries return an empty list. FTS5 operator characters are stripped so a copy-pasted string with colons or parentheses does not break the parser.kis the maximum number of hits. Must be positive.tagis an optional exact-tag filter applied after the MATCH narrows the candidate set.task_idscopes the search to one task. By default recall spans every task in the shared semantic index, which is what you want when an agent is looking for context from sibling tasks.
Hits are ordered by BM25 rank (best first), ties broken by newest ts_ns first.
Auto-load at agent spawn¶
The orchestrator wires load_recent_turns(root, task_id=..., k=...) into the agent-spawn path for tasks with prior sessions. The function returns the most recent N turns for that task_id, newest first, so they can be joined into the system prompt without a free-text query.
from bernstein.core.memory.session_memory import load_recent_turns
prior = load_recent_turns(
Path(".sdd/memory"),
task_id="t-7",
k=10,
)
Prune¶
Prune operates at two different scopes, on purpose:
- Episodic JSONL pruning is scoped to this instance's
(task_id, session_id)and drops every turn whosets_nsis strictly less than the cutoff. The JSONL file is rewritten in place via a sibling temp file plus atomic rename, so a crash mid-prune leaves either the old log or the new log intact. - Semantic index deletion is scoped to
task_idonly, so a parallel task sharing the same.sdd/memory/root is never touched and stale embeddings for other sessions of the same task are cleared in one pass.
On-disk layout¶
.sdd/memory/
├── episodic/
│ ├── task-a/
│ │ ├── session-1.jsonl
│ │ └── session-2.jsonl
│ └── task-b/
│ └── session-1.jsonl
└── semantic.sqlite
The semantic database opens in WAL mode so a concurrent reader does not block the writer.
Concurrency¶
- Episodic appends rely on POSIX append-write semantics. Two processes appending to the same
(task_id, session_id)file at the same time may interleave bytes within a single record. Bernstein assigns one session per agent process so this collision does not occur in practice. - The semantic SQLite database uses its own file-locking. WAL mode means readers and writers do not block each other.
- The episodic and semantic layers are written from the same code path; if the process crashes between the two writes the episodic layer is the source of truth and the next read on the same instance will silently rebuild the missing semantic row on the next
append_turnof the same hash. (Reindex utilities are a v2 task.)
What v1 does not do¶
| Capability | Status | Notes |
|---|---|---|
| Vector / embedding recall | Out of scope | FTS5 BM25 is the baseline. |
| Cross-machine sync | Out of scope | Single host only. |
| Memory edit / forget | Out of scope beyond prune | Operator-driven forgetting is a v2 follow-up. |
| Git-versioned episodic log | Deferred | The JSONL files are intentionally append-only on disk. |
Related modules¶
src/bernstein/core/memory/sqlite_store.py-- tag-indexed knowledge base.src/bernstein/core/memory/jsonl_log.py-- flat per-key JSONL log.src/bernstein/core/memory/cross_task_kb.py-- explicit publish/subscribe.src/bernstein/core/knowledge/rag.py-- codebase FTS5 index. The_sanitize_queryhelper here matches the one used there.