Saltar al contenido principal

Architecture

iLAB Memory está construido alrededor de dos decisiones estructurales y seis invariantes cross-feature (los "Braess points"). Si solo vas a leer una página de Concepts para integrar la librería sin romper nada, esta es.

Hexagonal — Store ABC + fachada

La librería sigue un patrón hexagonal: una clase abstract base Store define las operaciones de persistencia; un único SQLiteStore las implementa; el orchestrator ILabMemory es el único módulo que importa Store.

┌──────────────────────────────────────────┐
│ Tu app ──▶ ILabMemory (fachada) │
│ │ │
│ ▼ │
│ Store (ABC) │
│ ▲ │
│ │ │
│ SQLiteStore (impl) │
│ │ │
│ ▼ │
│ Archivo SQLite + FTS5 │
└──────────────────────────────────────────┘

Por qué te importa:

  • Importás ILabMemory. Nada más. Los 8 métodos públicos (mem_session_start, mem_session_end, mem_session_summary, mem_save, mem_search, mem_get_observation, mem_timeline, mem_stats) cubren toda operación.
  • from_path("...") es el modo fácil. Es la excepción documentada one-off que te cablea SQLiteStore automáticamente.
  • Los servidores HTTP y MCP mapean 1:1 a estos métodos y serializan los mismos modelos Pydantic. Hay una sola fuente de verdad.

Los 6 Braess points

El product map identifica seis zonas de interferencia cross-feature — fronteras donde se encuentran dos features y un assumption equivocado rompe ambas. La librería codifica cada una como invariante duro; los tests las custodian; y deberías saber que existen antes de extender el código.

Braess #1 — privacy-tags ↔ upsert-dedup

Invariante: el normalized_hash se calcula sobre el content post-strip, nunca sobre el input crudo.

Por qué importa: si hashearas el content crudo, dos saves cuya única diferencia fuera un bloque <private> producirían hashes distintos — anulando el dedup y filtrando la existencia del secreto a través de colisiones de hash.

Dónde: upsert.py::upsert_observation llama primero a strip_private, después a compute_normalized_hash. Tests a nivel AST aseguran que ningún otro path llega a la función de hash.

Ver Privacy para el detalle completo.

Braess #2 — session-lifecycle ↔ memory-context

Invariante: cada session summary lleva un boolean is_auto_generated. Los summaries sintéticos del auto-close están flagueados True; los humanos de mem_session_end están flagueados False.

Por qué importa: el summary auto es una lista de fragmentos [type] title — útil para hidratar pero no narrativo. Las UIs y el código de hidratación al LLM tienen que distinguir los dos antes de mostrarlo a un usuario.

Dónde: Session.is_auto_generated y SessionSummaryCompact.is_auto_generated son required, no defaulted.

Ver Sessions para el flujo de auto-close.

Braess #3 — scoring search ↔ scoring context

Invariante: recency_score y revision_score están definidas una sola vez en scoring.py y son llamadas por las dos fórmulas composite. Agregar un signal nuevo a un composite requiere actualizar el otro en el mismo PR.

Por qué importa: helpers divergentes sesgarían silenciosamente un ranking respecto del otro, haciendo los cambios imposibles de razonar.

Dónde: scoring.py exporta los helpers. search_score y context_score son los únicos composites que los usan, con las tuplas explícitas SEARCH_WEIGHTS y CONTEXT_WEIGHTS.

Ver Scoring.

Braess #4 — upsert ↔ session-lifecycle

Invariante: cuando un upsert hace UPDATE de una observation existente, el session_id persistido se sobreescribe con la sesión actual. La librería nunca lee el session_id de la fila existente.

Por qué importa: una observation vive en la sesión de su última actualización material (CREATE o UPDATE), no en la de su primera aparición. Eso hace de "mostrame todas las memorias de la sesión X" una query limpia y evita que session links viejos contaminen el contexto.

Nota: los paths DEDUPED no tocan session_id (no hay write). La sesión igual se toca a nivel del orchestrator (D8: mem_save siempre extiende la sesión activa).

Dónde: upsert.py::upsert_observation paso 4b — model_copy(update={..., "session_id": new_obs.session_id, ...}).

Braess #5 — memory-context ↔ search

Invariante: SearchScore y ContextScore son clases Pydantic frozen distintas. mypy se rehúsa a asignar una a la otra; los isinstance checks en runtime fallan en ambas direcciones.

Por qué importa: sus valores cubren el mismo rango [0, 1] pero significan cosas distintas. Un sort naive que los mezcle produce rankings sin sentido.

Dónde: scoring.py declara las dos clases; el campo ObservationCompact.score: float lleva una u otra según qué API la produjo. El docstring del modelo lo advierte explícitamente.

Ver Scoring para la regla "no comparar".

Braess #6 — api-http ↔ api-mcp

Invariante: ambas superficies de API serializan desde los mismos modelos Pydantic (SaveResult, SessionStartResponse, ObservationCompact, ObservationPublic, SessionSummaryCompact). No hay un shape JSON paralelo armado a mano.

Por qué importa: el server HTTP REST y el server MCP stdio son dos transportes para la misma librería. Si divergieran, los callers verían campos distintos, defaults distintos, errores de validación distintos según el transporte.

Dónde: api/http.py y api/mcp.py ambos importan de core/models.py. ObservationPublic existe específicamente para darle a las superficies wire un modelo que excluya normalized_hash (solo interno).

Ver Observations para el split Compact / Public.

Implicaciones para integradores

Si tratás iLAB Memory como caja negra y solo llamás los 8 métodos de la fachada, no necesitás pensar en los Braess points día a día — están protegidos por tests. Lo que sí necesitás tener presente:

  • Los scores de mem_search y memories[] no son comparables. Sortear una lista mezclada es undefined behavior a nivel semántico.
  • Envolvé secretos en <private> proactivamente. El strip es regex preciso, pero no puede adivinar qué cuenta como sensible.
  • Los topic keys son namespaces. Reusar uno entre temas no relacionados sobreescribe silenciosamente.
  • Las sesiones se reutilizan a demanda. Llamar mem_session_start es barato e idempotente — llamalo en cada evento "el usuario apareció".

Siguiente