Architecture
iLAB Memory is built around two structural decisions and six cross-feature invariants (the "Braess points"). If you only read one Concepts page to integrate the library safely, this is it.
Hexagonal — Store ABC + facade
The library follows a hexagonal pattern: a Store abstract base class defines persistence operations; a single SQLiteStore implements them; the ILabMemory orchestrator is the only module that imports Store.
┌──────────────────────────────────────────┐
│ Your app ──▶ ILabMemory (facade) │
│ │ │
│ ▼ │
│ Store (ABC) │
│ ▲ │
│ │ │
│ SQLiteStore (impl) │
│ │ │
│ ▼ │
│ SQLite + FTS5 file │
└──────────────────────────────────────────┘
Why this matters for you:
- You import
ILabMemory. Nothing else. The 8 public methods (mem_session_start,mem_session_end,mem_session_summary,mem_save,mem_search,mem_get_observation,mem_timeline,mem_stats) cover every operation. from_path("...")is the easy mode. It is the documented one-off exception that wires upSQLiteStorefor you.- The HTTP and MCP servers map 1:1 to these methods and serialize the same Pydantic models. There is one source of truth.
The 6 Braess points
The product map identifies six zones of cross-feature interference — fronteras where two features meet and a wrong assumption breaks both. The library encodes each one as a hard invariant; tests guard them; and you should know they exist before extending the code.
Braess #1 — privacy-tags ↔ upsert-dedup
Invariant: the normalized_hash is computed on the post-strip content, never on the raw input.
Why it matters: if you hashed the raw content, two saves whose only difference was a <private> block would produce different hashes — defeating dedup and leaking the existence of the secret through hash collisions.
Where: upsert.py::upsert_observation calls strip_private first, then compute_normalized_hash. AST-level tests assert no other code path reaches the hash function.
See Privacy for full details.
Braess #2 — session-lifecycle ↔ memory-context
Invariant: every session summary carries an is_auto_generated boolean. Auto-close synthetic summaries are flagged True; human-written summaries from mem_session_end are flagged False.
Why it matters: the auto summary is a list of [type] title fragments — useful for hydration but not narrative. UIs and LLM hydration code must distinguish the two before showing them to a user.
Where: Session.is_auto_generated and SessionSummaryCompact.is_auto_generated are required fields, not defaulted.
See Sessions for the auto-close flow.
Braess #3 — scoring search ↔ scoring context
Invariant: recency_score and revision_score are defined once in scoring.py and called by both composite formulas. Adding a new signal to one composite requires updating the other in the same PR.
Why it matters: divergent helpers would silently bias one ranking against the other, making changes impossible to reason about.
Where: scoring.py exports the helpers. search_score and context_score are the only composites that use them, with explicit SEARCH_WEIGHTS and CONTEXT_WEIGHTS tuples.
See Scoring.
Braess #4 — upsert ↔ session-lifecycle
Invariant: when an upsert UPDATEs an existing observation, the persisted session_id is overwritten with the current session. The library never reads the existing row's session_id.
Why it matters: an observation lives in the session of its last material update (CREATE or UPDATE), not its first appearance. This makes "show all memories from session X" a clean query and prevents stale session links from polluting context.
Note: DEDUPED paths do not touch session_id (no write occurs). The session is still touched at the orchestrator level (D8: mem_save always extends the active session).
Where: upsert.py::upsert_observation step 4b — model_copy(update={..., "session_id": new_obs.session_id, ...}).
Braess #5 — memory-context ↔ search
Invariant: SearchScore and ContextScore are distinct frozen Pydantic classes. mypy refuses to assign one to the other; runtime isinstance checks fail in both directions.
Why it matters: their values cover the same [0, 1] range but mean different things. A naive sort that mixes them produces meaningless rankings.
Where: scoring.py declares both classes; the ObservationCompact.score: float field carries one or the other depending on which API produced it. The model docstring warns explicitly.
See Scoring for the "don't compare" rule.
Braess #6 — api-http ↔ api-mcp
Invariant: both API surfaces serialize from the same Pydantic models (SaveResult, SessionStartResponse, ObservationCompact, ObservationPublic, SessionSummaryCompact). There is no parallel hand-rolled JSON shape.
Why it matters: the HTTP REST server and the MCP stdio server are two transports for the same library. If they drifted, callers would see different fields, different defaults, different validation errors depending on transport.
Where: api/http.py and api/mcp.py both import from core/models.py. ObservationPublic exists specifically to give wire surfaces a model that excludes normalized_hash (internal only).
See Observations for the Compact / Public split.
Implications for integrators
If you treat iLAB Memory as a black box and only call the 8 facade methods, you do not need to think about Braess points day to day — they are protected by tests. You do need to be aware that:
- Scores from
mem_searchandmemories[]are not comparable. Sorting a mixed list is undefined behavior at the semantic layer. - Wrap secrets in
<private>proactively. The strip is regex-based and precise, but it cannot guess what counts as sensitive. - Topic keys are namespaces. Reusing one across unrelated topics overwrites silently.
- Sessions are reused on demand. Calling
mem_session_startis cheap and idempotent — call it on every "user shows up" event.
Next
- Observations, Sessions, Topic keys, Scoring, Privacy.
- API Reference (Fase 4 — coming) for the full Pydantic schemas, OpenAPI, and MCP tool list.