agharsallah Codex commited on
Commit
f487b74
Β·
1 Parent(s): 24b5ffb

feat: mem0 as a layered, derived semantic memory index

Browse files

Add a MemoryIndex abstraction and a mem0-backed implementation that upgrades the
salience relevance term with semantic search. Layered + derived: the index is
populated from ledger events (keyed by event.id, idempotent and rebuildable), so
the append-only ledger remains the single source of truth (ADR-0005 preserved).
Env-gated by MEMORY_INDEX; offline keeps the keyword path. 258 passed, 2 skipped.
ADR-0018.

Co-Authored-By: Codex <codex@openai.com>

docs/adr/0018-layered-semantic-memory-index.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0018: Semantic Memory Index as a Layered, Derived Ledger View
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Agent memory is a **filtered view over the append-only event ledger**, not a
10
+ separate store (ADR-0005). `EpisodicMemory` returns the most-recent visible
11
+ events; `SalienceMemory` (ADR-0005 consequences, `docs/architecture/memory-stack.md`)
12
+ ranks visible events by a composite score:
13
+
14
+ ```
15
+ salience(e) = w_relΒ·relevance(e, query) + w_recΒ·recency(e, turn) + w_impΒ·importance(e.kind)
16
+ ```
17
+
18
+ The `relevance` term is **keyword (Jaccard) overlap** between the event text and
19
+ the current scene. That is cheap and deterministic but lexical: it misses an
20
+ event that is *about* the same thing in different words, exactly the case where
21
+ recall matters most over a long run. The documented Phase-3 upgrade is to replace
22
+ keyword relevance with semantic similarity over an embedding model, with vectors
23
+ optionally living in the durable Postgres/pgvector store from ADR-0014.
24
+
25
+ The constraint is the same one that has held since ADR-0001/0005: the ledger is
26
+ the **single source of truth**. A vector store must not become a second, parallel
27
+ store on a write path the ledger does not own. If it is wiped, the ledger must be
28
+ able to rebuild it. And, like every other optional integration here
29
+ (ADR-0014/0015/0016/0017), the offline path must stay the default the suite
30
+ exercises β€” no network, no credentials, and no new package required to
31
+ `import src.*` or `import app`.
32
+
33
+ ## Decision
34
+
35
+ Add a **semantic retrieval index as a derived, rebuildable lens over the ledger**,
36
+ used only to compute the `relevance` term. The index changes *how* relevance is
37
+ scored, never *which* events are eligible nor the recency/importance terms.
38
+
39
+ **A small protocol.** `MemoryIndex` (`src/core/memory_index.py`) is two methods:
40
+ `index(events) -> None` derives/refreshes entries from ledger events, and
41
+ `search(query, k) -> list[Event]` returns the most relevant indexed events. It is
42
+ a `runtime_checkable` `Protocol`, so any backend β€” a vector service, a local
43
+ embedding store, or a test fake β€” supplies semantic relevance without the salience
44
+ layer knowing which.
45
+
46
+ **Derived, not authoritative.** `index()` upserts each event under its
47
+ `event.id`, so re-indexing the same ledger slice each turn is idempotent (no
48
+ duplicates) and the index is rebuildable from the ledger at any time. The salience
49
+ layer always **derives then reads**: it indexes the visible candidates first, then
50
+ queries, so the index can never report an event the ledger has not produced. The
51
+ event is reconstructed from metadata stored on the entry, so a hit needs no second
52
+ lookup. This is what keeps the index a *faster lens on the same events* (ADR-0005)
53
+ rather than a competing store.
54
+
55
+ **Layered into salience, visibility intact.** `SalienceMemory` gains an optional
56
+ `index` field. With an index attached, the relevance term is derived from the
57
+ semantic search rank (normalised to `[0,1]` by descending rank); with `index=None`
58
+ (the default) it is the keyword-Jaccard path, unchanged byte-for-byte. In both
59
+ cases the candidate set is the *same* ledger-derived visibility filter
60
+ (`actor == self.agent_name or kind in _GLOBALLY_VISIBLE`) and the recency and
61
+ importance terms are untouched β€” an agent never recalls another agent's private
62
+ thoughts, with or without the index. `_recall` in `src/agents/base.py` threads the
63
+ agent's optional `memory_index` into `SalienceMemory`; `format_for_prompt` output
64
+ shape is unchanged (`[turn][kind][sal=…] text`).
65
+
66
+ **The concrete backend.** `Mem0MemoryIndex` wraps a vector-memory library, lazily
67
+ imported inside the backend so the package is touched only when the index is
68
+ exercised. Each event is stored as one raw memory with **inference disabled** β€”
69
+ the event text is embedded verbatim, with **no model-driven fact extraction** β€” so
70
+ indexing is deterministic and the ledger, not a model, remains the source of
71
+ truth. The full event rides along in the entry metadata for reconstruction.
72
+
73
+ **Env-gated, offline by default.** `memory_index_from_env()` returns `None` unless
74
+ `MEMORY_INDEX` is truthy, in which case it builds the backend (still not importing
75
+ the library until first use). An embedding model is required when the index is
76
+ active; by default embeddings route via `OPENAI_API_KEY` β€” the same credential the
77
+ live model path already uses β€” and `MEMORY_INDEX_CONFIG` (a JSON blob forwarded to
78
+ the library's `from_config`) can pin a local embedder or persist vectors in the
79
+ project's own Postgres/pgvector (ADR-0014), so the index can live beside the ledger
80
+ it derives from. With the gate unset the system stays on the keyword path and never
81
+ imports the package.
82
+
83
+ **Dependency.** `mem0ai` is a new optional `memory` extra in `pyproject.toml`. It
84
+ is lazy-imported, so `import src.*` and `import app` work with it not installed and
85
+ the gate unset β€” the offline default the test-suite exercises.
86
+
87
+ ## Reconciliation with ADR-0005
88
+
89
+ ADR-0005 makes memory a pure function of the ledger and explicitly anticipates this
90
+ step: *"Richer retrieval (semantic search, salience scoring) can be added later as
91
+ an upgraded `EpisodicMemory` implementation without changing the agent protocol."*
92
+ This ADR honours that literally. The index is **derived** (populated from ledger
93
+ events, keyed by `event.id`), **rebuildable** (wipe it and re-index from the
94
+ ledger), and **non-authoritative** (it only re-ranks the relevance term over events
95
+ the visibility filter already admits). No event originates in the index; nothing is
96
+ written there that the ledger does not own first. The four ADR-0005 properties hold:
97
+ consistency (the index trails the ledger and is rebuilt from it), crash recovery
98
+ (reload the ledger, re-index), testability (a fake `MemoryIndex` makes the semantic
99
+ path deterministic and offline), and privacy (the candidate filter is unchanged, so
100
+ an agent still cannot recall another's private thoughts).
101
+
102
+ ## Consequences
103
+
104
+ - With `MEMORY_INDEX` unset the relevance term is keyword-Jaccard exactly as before:
105
+ `tests/test_memory.py` and `tests/test_salience_memory.py` are unchanged and green,
106
+ and the suite stays β‰₯243 green offline. With the package absent the one real-backend
107
+ test skips (`pytest.importorskip`); the fake-index tests exercise the layering,
108
+ idempotency, env gate, and `_recall` wiring with nothing installed.
109
+ - The index is a derived view, not a second source of truth: it is keyed by
110
+ `event.id` (idempotent re-index), populated from the ledger before each query, and
111
+ can be dropped and rebuilt from the ledger. It never sits on a write path the ledger
112
+ does not own.
113
+ - Only the relevance term changes. Recency, importance, the top-K cut, chronological
114
+ ordering, and the `format_for_prompt` shape are identical across both paths, so a
115
+ scenario that enables the index sees better recall without other behavioural drift.
116
+ - Persisting vectors in the ADR-0014 Postgres/pgvector store is an optional
117
+ `MEMORY_INDEX_CONFIG` path, not a requirement; the default in-process vector store
118
+ works offline-with-embedder for a single process.
119
+ - **Alternative backends behind the same protocol.** `MemoryIndex` is deliberately
120
+ two methods, so a stateful agent-memory service (e.g. a Letta-style memory server)
121
+ could be wrapped as another `MemoryIndex` implementation β€” `index()` writing through
122
+ to it, `search()` reading back β€” without touching `SalienceMemory` or the agent
123
+ protocol, as long as it too treats the ledger as authoritative and stays rebuildable.
124
+ - Follow-ups: surface the active relevance mode (keyword vs semantic) on the stats
125
+ panel; add a one-shot "rebuild index from ledger" path for cold start / after a wipe;
126
+ evaluate scoping vector entries by `run_id` for multi-run isolation (mirrors the
127
+ ADR-0014 single-store caveat); blend semantic and lexical relevance rather than
128
+ switching between them.
docs/architecture/memory-stack.md CHANGED
@@ -48,7 +48,7 @@ salience(e) = w_relΒ·relevance(e, query) + w_recΒ·recency(e, turn) + w_impΒ·impo
48
 
49
  | Component | How computed | Default weight |
50
  |---|---|---|
51
- | relevance | Jaccard similarity between event text and current scene | 0.30 |
52
  | recency | exp(βˆ’Ξ»Β·Ξ”turn), Ξ»=0.1 β†’ half-life β‰ˆ7 turns | 0.40 |
53
  | importance | Kind-based weight table | 0.30 |
54
 
@@ -73,9 +73,10 @@ prompt reads naturally (not by importance descending).
73
  important but older memories over irrelevant recent ones.
74
  First enable point: when the agent window fills up (>30 turns).
75
 
76
- **Phase 3 upgrade**: replace keyword-Jaccard relevance with cosine similarity
77
- over sentence embeddings (`sentence-transformers` or a lightweight embedding
78
- model), scoring against the current scene as the query vector.
 
79
 
80
  ---
81
 
@@ -105,6 +106,46 @@ already present in `src/core/memory.py` β€” it just needs the agent to check
105
 
106
  ---
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  ## Context Builder Layering
109
 
110
  The ContextBuilder assembles layers in this order (permanent cost β†’ variable cost):
@@ -132,6 +173,6 @@ The layering order is deliberate:
132
  |---|---|---|
133
  | Keyword salience | 2 | `SalienceMemory` with Jaccard relevance |
134
  | Reflection events | 2 | `ReflectionTracker` + `agent.reflected` kind |
135
- | Embedding relevance | 3 | Replace Jaccard with cosine over embedding model |
136
- | pgvector retrieval | 3 | Store event embeddings; query by ANN similarity |
137
  | Belief graph | 4 | Structured belief store derived from reflection events |
 
48
 
49
  | Component | How computed | Default weight |
50
  |---|---|---|
51
+ | relevance | Semantic similarity when a `MemoryIndex` is attached (ADR-0018); else Jaccard similarity between event text and current scene | 0.30 |
52
  | recency | exp(βˆ’Ξ»Β·Ξ”turn), Ξ»=0.1 β†’ half-life β‰ˆ7 turns | 0.40 |
53
  | importance | Kind-based weight table | 0.30 |
54
 
 
73
  important but older memories over irrelevant recent ones.
74
  First enable point: when the agent window fills up (>30 turns).
75
 
76
+ **Semantic relevance (ADR-0018, implemented)**: the keyword-Jaccard relevance is
77
+ the offline default; attaching a `MemoryIndex` upgrades only that term to
78
+ semantic search (see "Semantic Relevance Index" below). Recency, importance, the
79
+ visibility filter, and the `format_for_prompt` shape are unchanged.
80
 
81
  ---
82
 
 
106
 
107
  ---
108
 
109
+ ## Semantic Relevance Index (ADR-0018, optional)
110
+
111
+ The `relevance` term in Layer 2 can be computed by **semantic search** instead of
112
+ keyword overlap. This is a *derived, rebuildable lens over the ledger* β€” it
113
+ changes how relevance is scored, never which events are eligible (the visibility
114
+ filter and the recency/importance terms are untouched). The ledger stays the
115
+ single source of truth (ADR-0005): the index is keyed by `event.id` (re-indexing
116
+ is idempotent) and can be wiped and rebuilt from the ledger.
117
+
118
+ ```python
119
+ @runtime_checkable
120
+ class MemoryIndex(Protocol):
121
+ def index(self, events: tuple[Event, ...]) -> None: ... # derive, idempotent by id
122
+ def search(self, query: str, k: int) -> list[Event]: ... # read back by relevance
123
+ ```
124
+
125
+ `SalienceMemory(..., index=...)` derives, then reads: it indexes the visible
126
+ candidates first, then queries, so a hit can never be an event the ledger has not
127
+ produced. With `index=None` (the offline default) the relevance term is keyword
128
+ Jaccard, byte-for-byte unchanged.
129
+
130
+ **Backend (`Mem0MemoryIndex`)**: stores each event as one raw memory with
131
+ inference disabled (the text is embedded verbatim β€” no model-driven fact
132
+ extraction), carrying the full event in metadata so a hit reconstructs the
133
+ `Event`. Lazy-imported, so `import src.*` / `import app` work with the package not
134
+ installed.
135
+
136
+ **Gate**: `memory_index_from_env()` returns `None` unless `MEMORY_INDEX` is
137
+ truthy. When active, an embedding model is required β€” routed via `OPENAI_API_KEY`
138
+ by default, or pinned to a local embedder / the project's Postgres+pgvector
139
+ (ADR-0014) via `MEMORY_INDEX_CONFIG` (a JSON blob forwarded to the backend's
140
+ `from_config`). Install the `memory` extra (`mem0ai`).
141
+
142
+ **Alternative backends**: the two-method protocol can wrap any retrieval store β€”
143
+ a stateful agent-memory service (e.g. a Letta-style memory server) could be a
144
+ `MemoryIndex` too, as long as it stays derived from and rebuildable from the
145
+ ledger.
146
+
147
+ ---
148
+
149
  ## Context Builder Layering
150
 
151
  The ContextBuilder assembles layers in this order (permanent cost β†’ variable cost):
 
173
  |---|---|---|
174
  | Keyword salience | 2 | `SalienceMemory` with Jaccard relevance |
175
  | Reflection events | 2 | `ReflectionTracker` + `agent.reflected` kind |
176
+ | Embedding relevance | done | `MemoryIndex` semantic search for the relevance term (ADR-0018) |
177
+ | pgvector retrieval | done | `MEMORY_INDEX_CONFIG` persists vectors in the ADR-0014 Postgres/pgvector store |
178
  | Belief graph | 4 | Structured belief store derived from reflection events |
pyproject.toml CHANGED
@@ -47,6 +47,16 @@ instructor = [
47
  mcp = [
48
  "mcp>=1.0",
49
  ]
 
 
 
 
 
 
 
 
 
 
50
 
51
  [tool.ruff]
52
  line-length = 120
 
47
  mcp = [
48
  "mcp>=1.0",
49
  ]
50
+ # Semantic memory index (ADR-0018). Optional: a derived, rebuildable vector lens
51
+ # over the event ledger that upgrades the salience relevance term from keyword
52
+ # overlap to semantic search when the MEMORY_INDEX gate is set. The ledger stays
53
+ # the single source of truth; the index is repopulated from it. The system runs
54
+ # fully offline on the keyword path without this; mem0 is imported lazily so
55
+ # importing src.* and app never requires it. An embedding model is needed when
56
+ # active (routed via OPENAI_API_KEY by default, or MEMORY_INDEX_CONFIG).
57
+ memory = [
58
+ "mem0ai>=0.1",
59
+ ]
60
 
61
  [tool.ruff]
62
  line-length = 120
src/agents/base.py CHANGED
@@ -30,6 +30,7 @@ from src.core.structured import build_output_model, json_instruction, parse_agen
30
  from src.models.router import ModelRouter
31
 
32
  if TYPE_CHECKING:
 
33
  from src.tools.registry import ToolRegistry
34
 
35
  _ctx = ContextBuilder()
@@ -69,9 +70,18 @@ class ManifestAgent(Agent):
69
 
70
  manifest: AgentManifest
71
 
72
- def __init__(self, router: ModelRouter, tools: "ToolRegistry | None" = None) -> None:
 
 
 
 
 
73
  self.router = router
74
  self.tools = tools
 
 
 
 
75
  self._reflection_tracker: ReflectionTracker | None = None
76
  self.last_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
77
 
@@ -176,9 +186,12 @@ class ManifestAgent(Agent):
176
  def _recall(self, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
177
  cfg = self.manifest.memory
178
  if cfg.use_salience:
179
- return SalienceMemory(self.manifest.name, top_k=cfg.salience_top_k).format_for_prompt(
180
- recent_events, current_turn=turn, query=projection.current_scene
181
- )
 
 
 
182
  return EpisodicMemory(self.manifest.name, max_recent=cfg.window).format_for_prompt(recent_events)
183
 
184
  def _tracker(self, threshold: int) -> ReflectionTracker:
 
30
  from src.models.router import ModelRouter
31
 
32
  if TYPE_CHECKING:
33
+ from src.core.memory_index import MemoryIndex
34
  from src.tools.registry import ToolRegistry
35
 
36
  _ctx = ContextBuilder()
 
70
 
71
  manifest: AgentManifest
72
 
73
+ def __init__(
74
+ self,
75
+ router: ModelRouter,
76
+ tools: "ToolRegistry | None" = None,
77
+ memory_index: "MemoryIndex | None" = None,
78
+ ) -> None:
79
  self.router = router
80
  self.tools = tools
81
+ # Optional semantic relevance index β€” a derived, rebuildable lens over the
82
+ # ledger (ADR-0018). ``None`` (offline default) keeps salience on the
83
+ # keyword path.
84
+ self.memory_index = memory_index
85
  self._reflection_tracker: ReflectionTracker | None = None
86
  self.last_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
87
 
 
186
  def _recall(self, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
187
  cfg = self.manifest.memory
188
  if cfg.use_salience:
189
+ # The index (when attached) upgrades only the relevance term to
190
+ # semantic search; recency/importance and the visibility filter are
191
+ # unchanged. With no index this is the keyword-Jaccard path.
192
+ return SalienceMemory(
193
+ self.manifest.name, top_k=cfg.salience_top_k, index=self.memory_index
194
+ ).format_for_prompt(recent_events, current_turn=turn, query=projection.current_scene)
195
  return EpisodicMemory(self.manifest.name, max_recent=cfg.window).format_for_prompt(recent_events)
196
 
197
  def _tracker(self, threshold: int) -> ReflectionTracker:
src/core/memory.py CHANGED
@@ -15,17 +15,29 @@ Memory architecture (three layers):
15
  a high-level belief. Reflection events are themselves visible to
16
  the agent, so beliefs accumulate over time without blowing the window.
17
 
18
- None of these layers maintain separate persistent state β€” they are pure
19
- functions over the shared append-only ledger. Memory is always consistent
20
- with the ledger because it *is* the ledger.
 
 
 
 
 
 
 
 
21
  """
22
  from __future__ import annotations
23
 
24
  import math
25
  from dataclasses import dataclass, field
 
26
 
27
  from src.core.events import Event
28
 
 
 
 
29
  # ── importance weights by event kind ─────────────────────────────────────────
30
 
31
  _KIND_IMPORTANCE: dict[str, float] = {
@@ -84,10 +96,18 @@ class SalienceMemory:
84
 
85
  salience(e) = w_relΒ·relevance + w_recΒ·recency + w_impΒ·importance
86
 
87
- relevance: keyword overlap between event text and the current scene.
88
- Replace with cosine(embedding(e), embedding(scene)) in Phase 3.
 
 
 
 
 
89
  recency: exponential decay β€” exp(βˆ’Ξ»Β·Ξ”turn). Ξ»=0.1 gives half-life β‰ˆ7 turns.
90
  importance: event-kind weight from _KIND_IMPORTANCE table.
 
 
 
91
  """
92
 
93
  agent_name: str
@@ -96,31 +116,76 @@ class SalienceMemory:
96
  w_recency: float = 0.4
97
  w_importance: float = 0.3
98
  decay_lambda: float = 0.1
 
99
 
100
- def score(self, event: Event, current_turn: int, query: str) -> float:
101
- recency = math.exp(-self.decay_lambda * max(0, current_turn - event.turn))
102
- importance = _KIND_IMPORTANCE.get(event.kind, 0.5)
103
- event_text = str(event.payload.get("text", "")).lower()
104
  query_words = set(query.lower().split())
105
- event_words = set(event_text.split())
106
  if not query_words or not event_words:
107
- relevance = 0.0
108
- else:
109
- relevance = len(query_words & event_words) / len(query_words | event_words)
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  return (
111
  self.w_relevance * relevance
112
  + self.w_recency * recency
113
  + self.w_importance * importance
114
  )
115
 
116
- def visible(self, events: tuple[Event, ...], current_turn: int, query: str) -> list[Event]:
117
- candidates = [
 
 
 
118
  e for e in events
119
  if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE
120
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  scored = sorted(
122
  candidates,
123
- key=lambda e: self.score(e, current_turn, query),
 
 
 
 
 
124
  reverse=True,
125
  )
126
  # Return in chronological order so prompts read naturally
@@ -130,14 +195,21 @@ class SalienceMemory:
130
  def format_for_prompt(
131
  self, events: tuple[Event, ...], current_turn: int, query: str
132
  ) -> str:
133
- recalled = self.visible(events, current_turn, query)
 
 
 
 
 
 
 
 
134
  if not recalled:
135
  return "(no salient memories)"
136
  lines = []
137
  for e in recalled:
138
  text = e.payload.get("text") or e.payload.get("summary") or str(e.payload)
139
- s = self.score(e, current_turn, query)
140
- lines.append(f"[turn {e.turn:03d}][{e.kind}][sal={s:.2f}] {text}")
141
  return "\n".join(lines)
142
 
143
 
 
15
  a high-level belief. Reflection events are themselves visible to
16
  the agent, so beliefs accumulate over time without blowing the window.
17
 
18
+ None of these layers maintain a separate *source of truth* β€” they are functions
19
+ over the shared append-only ledger. Memory is always consistent with the ledger
20
+ because it *is* the ledger.
21
+
22
+ The one optional accelerator is a semantic relevance index
23
+ (:class:`~src.core.memory_index.MemoryIndex`, attached via ``SalienceMemory.index``
24
+ and gated by ``MEMORY_INDEX`` β€” see ADR-0018). It is a *derived, rebuildable*
25
+ view: populated FROM ledger events, keyed by ``event.id`` (idempotent re-index),
26
+ and used only to score the relevance term over events the visibility filter
27
+ already admits. Wipe it and it rebuilds from the ledger; with it unattached
28
+ (the offline default) relevance is keyword overlap, exactly as below.
29
  """
30
  from __future__ import annotations
31
 
32
  import math
33
  from dataclasses import dataclass, field
34
+ from typing import TYPE_CHECKING
35
 
36
  from src.core.events import Event
37
 
38
+ if TYPE_CHECKING: # pragma: no cover - typing only
39
+ from src.core.memory_index import MemoryIndex
40
+
41
  # ── importance weights by event kind ─────────────────────────────────────────
42
 
43
  _KIND_IMPORTANCE: dict[str, float] = {
 
96
 
97
  salience(e) = w_relΒ·relevance + w_recΒ·recency + w_impΒ·importance
98
 
99
+ relevance: semantic similarity between the event and the current scene when
100
+ a :class:`~src.core.memory_index.MemoryIndex` is attached
101
+ (``index`` set), else keyword (Jaccard) overlap between the event
102
+ text and the scene. The index is a *derived* lens over the same
103
+ ledger events β€” it changes only how the relevance term is scored,
104
+ never which events are eligible (see ``visible``) nor the recency
105
+ or importance terms.
106
  recency: exponential decay β€” exp(βˆ’Ξ»Β·Ξ”turn). Ξ»=0.1 gives half-life β‰ˆ7 turns.
107
  importance: event-kind weight from _KIND_IMPORTANCE table.
108
+
109
+ Attach an index via ``index=...`` to use semantic relevance; with ``index``
110
+ left ``None`` (the default) the scoring is exactly the offline keyword path.
111
  """
112
 
113
  agent_name: str
 
116
  w_recency: float = 0.4
117
  w_importance: float = 0.3
118
  decay_lambda: float = 0.1
119
+ index: "MemoryIndex | None" = None
120
 
121
+ def _keyword_relevance(self, event: Event, query: str) -> float:
122
+ event_words = set(str(event.payload.get("text", "")).lower().split())
 
 
123
  query_words = set(query.lower().split())
 
124
  if not query_words or not event_words:
125
+ return 0.0
126
+ return len(query_words & event_words) / len(query_words | event_words)
127
+
128
+ def score(
129
+ self,
130
+ event: Event,
131
+ current_turn: int,
132
+ query: str,
133
+ relevance: float | None = None,
134
+ ) -> float:
135
+ """Composite salience. *relevance* may be supplied (e.g. a semantic rank);
136
+ when ``None`` it is computed from keyword overlap as before."""
137
+ recency = math.exp(-self.decay_lambda * max(0, current_turn - event.turn))
138
+ importance = _KIND_IMPORTANCE.get(event.kind, 0.5)
139
+ if relevance is None:
140
+ relevance = self._keyword_relevance(event, query)
141
  return (
142
  self.w_relevance * relevance
143
  + self.w_recency * recency
144
  + self.w_importance * importance
145
  )
146
 
147
+ def _candidates(self, events: tuple[Event, ...]) -> list[Event]:
148
+ """Ledger-derived visibility filter β€” unchanged whether or not an index
149
+ is attached: an agent only ever recalls its own events plus globally
150
+ visible kinds."""
151
+ return [
152
  e for e in events
153
  if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE
154
  ]
155
+
156
+ def _relevance_map(
157
+ self, candidates: list[Event], query: str
158
+ ) -> dict[str, float] | None:
159
+ """When an index is attached, derive a semantic relevance score per
160
+ candidate event (id β†’ score in [0,1] by descending rank); else ``None``
161
+ so :meth:`score` uses keyword overlap.
162
+
163
+ The index is populated from the candidate events first, then queried β€”
164
+ derive, then read β€” so it never reports events the ledger has not
165
+ produced, and re-indexing is idempotent (keyed by ``event.id``).
166
+ """
167
+ if self.index is None or not query or not candidates:
168
+ return None
169
+ self.index.index(tuple(candidates))
170
+ hits = self.index.search(query, k=len(candidates))
171
+ eligible = {e.id for e in candidates}
172
+ ranked = [h.id for h in hits if h.id in eligible]
173
+ if not ranked:
174
+ return {}
175
+ n = len(ranked)
176
+ return {eid: (n - i) / n for i, eid in enumerate(ranked)}
177
+
178
+ def visible(self, events: tuple[Event, ...], current_turn: int, query: str) -> list[Event]:
179
+ candidates = self._candidates(events)
180
+ relevance = self._relevance_map(candidates, query)
181
  scored = sorted(
182
  candidates,
183
+ key=lambda e: self.score(
184
+ e,
185
+ current_turn,
186
+ query,
187
+ relevance=None if relevance is None else relevance.get(e.id, 0.0),
188
+ ),
189
  reverse=True,
190
  )
191
  # Return in chronological order so prompts read naturally
 
195
  def format_for_prompt(
196
  self, events: tuple[Event, ...], current_turn: int, query: str
197
  ) -> str:
198
+ candidates = self._candidates(events)
199
+ relevance = self._relevance_map(candidates, query)
200
+
201
+ def _score(e: Event) -> float:
202
+ rel = None if relevance is None else relevance.get(e.id, 0.0)
203
+ return self.score(e, current_turn, query, relevance=rel)
204
+
205
+ top = sorted(candidates, key=_score, reverse=True)[: self.top_k]
206
+ recalled = sorted(top, key=lambda e: e.turn)
207
  if not recalled:
208
  return "(no salient memories)"
209
  lines = []
210
  for e in recalled:
211
  text = e.payload.get("text") or e.payload.get("summary") or str(e.payload)
212
+ lines.append(f"[turn {e.turn:03d}][{e.kind}][sal={_score(e):.2f}] {text}")
 
213
  return "\n".join(lines)
214
 
215
 
src/core/memory_index.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrieval index for the salience *relevance* term β€” a derived ledger lens.
2
+
3
+ The append-only event ledger is the single source of truth (ADR-0005). This
4
+ module adds an optional **semantic** retrieval index *over* that ledger: it does
5
+ not store anything the ledger does not already own, and it can be wiped and
6
+ rebuilt from the ledger at any time. It is a faster lens on the same events, not
7
+ a second store (ADR-0018).
8
+
9
+ Two pieces:
10
+
11
+ * :class:`MemoryIndex` β€” a tiny protocol the salience layer can lean on:
12
+ ``index(events)`` derives vector entries from ledger events (idempotent,
13
+ keyed by ``event.id``) and ``search(query, k)`` returns the most relevant
14
+ events back. Any backend that satisfies this protocol can supply semantic
15
+ relevance β€” a vector service, a local embedding store, or a fake in tests.
16
+
17
+ * :class:`Mem0MemoryIndex` β€” a concrete backend. It is **lazy-imported and
18
+ env-gated**: with the backend not installed or not configured, nothing here
19
+ is imported and :class:`~src.core.memory.SalienceMemory` falls back to its
20
+ keyword-Jaccard relevance exactly as before. The backend activates only when
21
+ :func:`memory_index_from_env` finds it configured.
22
+
23
+ Because the index is derived, ``index()`` upserts each event under its
24
+ ``event.id`` so re-indexing the same events is a no-op (no duplicates) β€” this is
25
+ what makes the index rebuildable rather than authoritative.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
31
+
32
+ from src.core.events import Event
33
+
34
+ if TYPE_CHECKING: # pragma: no cover - typing only
35
+ from mem0 import Memory
36
+
37
+ #: Env gate. Set to a truthy value to activate the semantic index; unset (the
38
+ #: default) keeps memory on the offline keyword path with nothing imported.
39
+ INDEX_ENV = "MEMORY_INDEX"
40
+
41
+ #: Truthy spellings accepted for the gate and boolean sub-options.
42
+ _TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on", "mem0"})
43
+
44
+
45
+ # ── protocol ──────────────────────────────────────────────────────────────────
46
+
47
+ @runtime_checkable
48
+ class MemoryIndex(Protocol):
49
+ """A derived, rebuildable semantic index over ledger events.
50
+
51
+ Implementations MUST treat the ledger as authoritative: ``index`` is an
52
+ idempotent upsert keyed by ``event.id`` (re-indexing never duplicates), and
53
+ ``search`` only ever returns events that were previously indexed.
54
+ """
55
+
56
+ def index(self, events: tuple[Event, ...]) -> None:
57
+ """Derive/refresh index entries for *events* (idempotent by ``event.id``)."""
58
+ ...
59
+
60
+ def search(self, query: str, k: int) -> list[Event]:
61
+ """Return up to *k* indexed events most semantically relevant to *query*."""
62
+ ...
63
+
64
+
65
+ # ── mem0 backend ────────────────────────────────────────────────────────────
66
+
67
+ def _event_text(event: Event) -> str:
68
+ """The natural-language surface of an event used for embedding/recall."""
69
+ return str(event.payload.get("text") or event.payload.get("summary") or event.payload)
70
+
71
+
72
+ class Mem0MemoryIndex:
73
+ """Semantic :class:`MemoryIndex` backed by the ``mem0`` vector memory.
74
+
75
+ Derived, not authoritative. Each ledger event is upserted as one raw memory
76
+ (``infer=False`` β€” text is stored verbatim, **no model extraction**, so
77
+ indexing is deterministic and the ledger stays the source of truth) carrying
78
+ the full event in ``metadata`` so a search hit reconstructs the original
79
+ :class:`Event` without a second lookup. The entry id is the ``event.id``, so
80
+ re-indexing the same event updates in place rather than duplicating β€” the
81
+ index is rebuildable from the ledger.
82
+
83
+ Configuration (env, read by :func:`memory_index_from_env`):
84
+
85
+ * ``MEMORY_INDEX`` β€” gate; truthy activates the index, unset disables it.
86
+ * Embedder credentials β€” an embedding model is required to vectorise event
87
+ text. By default ``mem0`` routes embeddings via ``OPENAI_API_KEY`` (the
88
+ same key the live model path already uses); point it elsewhere with a
89
+ ``MEMORY_INDEX_CONFIG`` JSON blob (passed verbatim to ``mem0`` as its
90
+ config β€” see its docs for ``embedder`` / ``vector_store`` keys).
91
+ * ``MEMORY_INDEX_CONFIG`` β€” optional JSON config forwarded to
92
+ ``mem0.Memory.from_config``. Use it to pin a local embedder or to persist
93
+ vectors in the project's own Postgres/pgvector (the durable store from
94
+ ADR-0014) instead of the default in-process vector store, so the index
95
+ lives beside the ledger it derives from.
96
+
97
+ ``mem0`` is imported lazily inside :meth:`_memory` so ``import src.*`` and
98
+ ``import app`` work with the package not installed.
99
+ """
100
+
101
+ #: mem0 scopes memories to a session id; the index is engine-wide, so a fixed
102
+ #: namespace keeps every event in one searchable space.
103
+ _NAMESPACE = "ledger"
104
+
105
+ def __init__(self, config: dict | None = None) -> None:
106
+ self._config = config
107
+ self._mem: "Memory | None" = None
108
+ self._indexed: set[str] = set()
109
+
110
+ # ── lazy construction ─────────────────────────────────────────────────────
111
+
112
+ def _memory(self) -> "Memory":
113
+ """Construct (once) and return the underlying ``mem0`` memory."""
114
+ if self._mem is None:
115
+ from mem0 import Memory # lazy: offline import must not require mem0
116
+
117
+ self._mem = (
118
+ Memory.from_config(self._config) if self._config else Memory()
119
+ )
120
+ return self._mem
121
+
122
+ # ── MemoryIndex protocol ──────────────────────────────────────────────────
123
+
124
+ def index(self, events: tuple[Event, ...]) -> None:
125
+ """Upsert *events* into the vector store, keyed by ``event.id``.
126
+
127
+ Idempotent: an ``event.id`` already indexed in this process is skipped, so
128
+ re-indexing the same ledger slice each turn does not duplicate entries.
129
+ """
130
+ fresh = [e for e in events if e.id not in self._indexed]
131
+ if not fresh:
132
+ return
133
+ mem = self._memory()
134
+ for event in fresh:
135
+ mem.add(
136
+ _event_text(event),
137
+ user_id=self._NAMESPACE,
138
+ metadata=_event_metadata(event),
139
+ infer=False, # store verbatim; the ledger, not a model, is truth
140
+ )
141
+ self._indexed.add(event.id)
142
+
143
+ def search(self, query: str, k: int) -> list[Event]:
144
+ """Semantic search; map hits back to :class:`Event` via stored metadata."""
145
+ if not query or k <= 0:
146
+ return []
147
+ mem = self._memory()
148
+ hits = mem.search(query, top_k=k, filters={"user_id": self._NAMESPACE})
149
+ events: list[Event] = []
150
+ for hit in _result_items(hits):
151
+ event = _event_from_metadata(hit.get("metadata"))
152
+ if event is not None:
153
+ events.append(event)
154
+ return events
155
+
156
+
157
+ # ── metadata round-trip (event ⇄ vector entry) ────────────────────────────────
158
+
159
+ def _event_metadata(event: Event) -> dict:
160
+ """Flatten an event into JSON-safe metadata for the vector entry."""
161
+ return {
162
+ "event_id": event.id,
163
+ "run_id": event.run_id,
164
+ "turn": event.turn,
165
+ "kind": event.kind,
166
+ "actor": event.actor,
167
+ "payload": event.payload,
168
+ "created_at": event.created_at.isoformat(),
169
+ "schema_version": event.schema_version,
170
+ }
171
+
172
+
173
+ def _event_from_metadata(metadata: dict | None) -> Event | None:
174
+ """Reconstruct an :class:`Event` from stored metadata, or ``None`` if absent."""
175
+ if not metadata or "event_id" not in metadata:
176
+ return None
177
+ try:
178
+ return Event(
179
+ id=str(metadata["event_id"]),
180
+ run_id=str(metadata.get("run_id", "")),
181
+ turn=int(metadata.get("turn", 0)),
182
+ kind=str(metadata["kind"]),
183
+ actor=str(metadata.get("actor", "")),
184
+ payload=dict(metadata.get("payload") or {}),
185
+ schema_version=int(metadata.get("schema_version", 1)),
186
+ )
187
+ except (KeyError, ValueError, TypeError): # pragma: no cover - defensive
188
+ return None
189
+
190
+
191
+ def _result_items(hits: object) -> list[dict]:
192
+ """Normalise ``mem0.search`` output to a list of hit dicts.
193
+
194
+ ``mem0`` returns either ``{"results": [...]}`` (v1.1+) or a bare list,
195
+ depending on version/config; accept both so the backend is version-tolerant.
196
+ """
197
+ if isinstance(hits, dict):
198
+ results = hits.get("results", [])
199
+ else:
200
+ results = hits
201
+ return [h for h in results if isinstance(h, dict)] if isinstance(results, list) else []
202
+
203
+
204
+ # ── env gate ───────────────────────────────────────────────────────────────────
205
+
206
+ def _is_truthy(value: str | None) -> bool:
207
+ return (value or "").strip().lower() in _TRUTHY
208
+
209
+
210
+ def memory_index_from_env(env: dict[str, str] | None = None) -> MemoryIndex | None:
211
+ """Build a :class:`Mem0MemoryIndex` from the env gate, or ``None`` if unset.
212
+
213
+ Returns ``None`` (the offline default the suite exercises) unless
214
+ ``MEMORY_INDEX`` is truthy. ``mem0`` is only imported later, on first use, so
215
+ a truthy gate without the package installed still imports cleanly and fails
216
+ loudly only when the index is actually exercised.
217
+ """
218
+ source = os.environ if env is None else env
219
+ if not _is_truthy(source.get(INDEX_ENV)):
220
+ return None
221
+ raw_config = (source.get("MEMORY_INDEX_CONFIG") or "").strip()
222
+ config: dict | None = None
223
+ if raw_config:
224
+ import json
225
+
226
+ config = json.loads(raw_config)
227
+ return Mem0MemoryIndex(config=config)
src/core/registry.py CHANGED
@@ -132,12 +132,12 @@ class Registry:
132
  router.specs = specs
133
  return router
134
 
135
- def build_agent(self, name: str, router: ModelRouter, tools=None) -> Agent:
136
  if name not in self.agents:
137
  raise KeyError(f"unknown agent {name!r} (have: {sorted(self.agents)})")
138
  manifest = self.agents[name]
139
  cls = HANDLERS.get(manifest.handler, ManifestAgent) if manifest.handler else ManifestAgent
140
- agent = cls(router, tools)
141
  agent.manifest = manifest # YAML is the source of truth for declarative fields
142
  return agent
143
 
@@ -146,7 +146,15 @@ class Registry:
146
  raise KeyError(f"unknown scenario {name!r} (have: {sorted(self.scenarios)})")
147
  cfg = self.scenarios[name]
148
  router = router or self.build_router()
149
- agents = tuple(self.build_agent(agent_name, router, tools) for agent_name in cfg.cast)
 
 
 
 
 
 
 
 
150
  return Scenario(
151
  name=cfg.name,
152
  default_seed=cfg.default_seed,
 
132
  router.specs = specs
133
  return router
134
 
135
+ def build_agent(self, name: str, router: ModelRouter, tools=None, memory_index=None) -> Agent:
136
  if name not in self.agents:
137
  raise KeyError(f"unknown agent {name!r} (have: {sorted(self.agents)})")
138
  manifest = self.agents[name]
139
  cls = HANDLERS.get(manifest.handler, ManifestAgent) if manifest.handler else ManifestAgent
140
+ agent = cls(router, tools, memory_index)
141
  agent.manifest = manifest # YAML is the source of truth for declarative fields
142
  return agent
143
 
 
146
  raise KeyError(f"unknown scenario {name!r} (have: {sorted(self.scenarios)})")
147
  cfg = self.scenarios[name]
148
  router = router or self.build_router()
149
+ # Optional semantic relevance index β€” env-gated (MEMORY_INDEX), a derived
150
+ # lens over the ledger (ADR-0018). None offline; one engine-wide index is
151
+ # shared across the cast.
152
+ from src.core.memory_index import memory_index_from_env
153
+
154
+ memory_index = memory_index_from_env()
155
+ agents = tuple(
156
+ self.build_agent(agent_name, router, tools, memory_index) for agent_name in cfg.cast
157
+ )
158
  return Scenario(
159
  name=cfg.name,
160
  default_seed=cfg.default_seed,
tests/test_memory_index.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Semantic memory index tests (ADR-0018).
2
+
3
+ Three tiers, mirroring the optional-dependency tests elsewhere:
4
+
5
+ * A FAKE in-memory ``MemoryIndex`` (no ``mem0`` required) proves the layering:
6
+ when an index is attached, ``SalienceMemory`` retrieves by semantic rank;
7
+ with none it falls back to keyword Jaccard. Indexing is idempotent.
8
+ * The env gate returns ``None`` when unset and a backend when set β€” provable
9
+ with no ``mem0`` installed (construction is lazy).
10
+ * A guarded real-``mem0`` round-trip (skipped without the package or an
11
+ embedder configured) asserts an event survives index β†’ search.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+ import pytest
18
+
19
+ from src.agents.base import ManifestAgent
20
+ from src.core.events import Event
21
+ from src.core.manifest import AgentManifest, MemoryConfig
22
+ from src.core.memory import SalienceMemory
23
+ from src.core.memory_index import (
24
+ Mem0MemoryIndex,
25
+ MemoryIndex,
26
+ memory_index_from_env,
27
+ )
28
+ from src.models.router import ModelRouter
29
+
30
+
31
+ def _event(kind: str, actor: str = "x", turn: int = 1, text: str = "hello", eid: str | None = None) -> Event:
32
+ kwargs = {"run_id": "r", "turn": turn, "kind": kind, "actor": actor, "payload": {"text": text}}
33
+ if eid is not None:
34
+ kwargs["id"] = eid
35
+ return Event(**kwargs) # type: ignore[arg-type]
36
+
37
+
38
+ class _FakeIndex:
39
+ """A deterministic in-memory ``MemoryIndex`` β€” no ``mem0``, no embeddings.
40
+
41
+ ``search`` ranks indexed events by substring/word overlap so a test can steer
42
+ *which* event the salience layer treats as most relevant, independently of
43
+ the keyword-Jaccard the offline path would compute. Records calls so a test
44
+ can assert idempotent indexing.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self.store: dict[str, Event] = {}
49
+ self.add_calls: list[str] = []
50
+
51
+ def index(self, events: tuple[Event, ...]) -> None:
52
+ for e in events:
53
+ self.add_calls.append(e.id)
54
+ self.store[e.id] = e # upsert by id β†’ idempotent
55
+
56
+ def search(self, query: str, k: int) -> list[Event]:
57
+ q = set(query.lower().split())
58
+ scored = [
59
+ (len(q & set(str(e.payload.get("text", "")).lower().split())), e)
60
+ for e in self.store.values()
61
+ ]
62
+ scored.sort(key=lambda t: t[0], reverse=True)
63
+ return [e for _, e in scored[:k]]
64
+
65
+
66
+ # ── the fake satisfies the protocol (structural typing) ─────────────────────────
67
+
68
+ class TestProtocol:
69
+ def test_fake_is_memory_index(self):
70
+ assert isinstance(_FakeIndex(), MemoryIndex)
71
+
72
+ def test_mem0_backend_is_memory_index(self):
73
+ # No mem0 import needed: the backend is constructed lazily.
74
+ assert isinstance(Mem0MemoryIndex(), MemoryIndex)
75
+
76
+
77
+ # ── layering: index drives the relevance term, recency/importance intact ────────
78
+
79
+ class TestSalienceUsesIndex:
80
+ def test_semantic_hit_outranks_keyword_irrelevant(self):
81
+ """An event the index ranks top wins even with no keyword overlap to the
82
+ query β€” proving the relevance term came from the index, not Jaccard."""
83
+ idx = _FakeIndex()
84
+ # The query shares NO words with either event text; the fake index is
85
+ # seeded to rank the 'beacon' event first via its own signal.
86
+ target = _event("world.observed", turn=2, text="beacon glow signal", eid="hit")
87
+ other = _event("world.observed", turn=2, text="quiet empty room", eid="miss")
88
+ mem = SalienceMemory("x", top_k=1, index=idx)
89
+
90
+ # Steer the fake: query overlaps only the target's words.
91
+ recalled = mem.visible((other, target), current_turn=3, query="beacon glow")
92
+ assert [e.id for e in recalled] == ["hit"]
93
+
94
+ def test_falls_back_to_keyword_without_index(self):
95
+ match = _event("world.observed", turn=5, text="golden spores drift upward")
96
+ miss = _event("world.observed", turn=5, text="completely unrelated content")
97
+ mem = SalienceMemory("a") # no index β†’ keyword path
98
+ s_match = mem.score(match, current_turn=6, query="golden spores")
99
+ s_miss = mem.score(miss, current_turn=6, query="golden spores")
100
+ assert s_match > s_miss
101
+
102
+ def test_index_is_populated_from_visible_events_only(self):
103
+ """The index is DERIVED from the ledger: only events that pass the
104
+ visibility filter are indexed, never another agent's private thoughts."""
105
+ idx = _FakeIndex()
106
+ mine = _event("agent.thought", actor="a", turn=1, text="my secret", eid="mine")
107
+ theirs = _event("agent.thought", actor="b", turn=1, text="their secret", eid="theirs")
108
+ glob = _event("world.observed", actor="narrator", turn=1, text="the stage", eid="glob")
109
+ mem = SalienceMemory("a", index=idx)
110
+ mem.visible((mine, theirs, glob), current_turn=2, query="stage")
111
+ assert set(idx.store) == {"mine", "glob"} # 'theirs' never indexed
112
+
113
+ def test_recency_still_applies_with_index(self):
114
+ """Relevance is one term; recency must still separate equally-relevant
115
+ events so the index does not flatten the composite score."""
116
+ idx = _FakeIndex()
117
+ old = _event("world.observed", turn=1, text="same words here", eid="old")
118
+ new = _event("world.observed", turn=10, text="same words here", eid="new")
119
+ mem = SalienceMemory("x", top_k=2, index=idx)
120
+ recalled = mem.visible((old, new), current_turn=12, query="same words here")
121
+ # both relevant + chronological order, but recency makes 'new' score higher
122
+ s_old = mem.score(old, current_turn=12, query="x", relevance=1.0)
123
+ s_new = mem.score(new, current_turn=12, query="x", relevance=1.0)
124
+ assert s_new > s_old
125
+ assert {e.id for e in recalled} == {"old", "new"}
126
+
127
+ def test_format_for_prompt_shape_with_index(self):
128
+ idx = _FakeIndex()
129
+ e = _event("world.observed", turn=1, text="something", eid="e1")
130
+ out = SalienceMemory("x", index=idx).format_for_prompt((e,), current_turn=2, query="something")
131
+ assert isinstance(out, str)
132
+ assert "something" in out and "sal=" in out # output shape unchanged
133
+
134
+
135
+ # ── idempotent indexing (derived, rebuildable) ──────────────────────────────────
136
+
137
+ class TestIdempotentIndexing:
138
+ def test_reindex_does_not_duplicate(self):
139
+ idx = _FakeIndex()
140
+ events = (_event("world.observed", turn=1, text="a", eid="e1"),
141
+ _event("world.observed", turn=2, text="b", eid="e2"))
142
+ idx.index(events)
143
+ idx.index(events) # re-index same slice
144
+ assert len(idx.store) == 2 # keyed by id β†’ no duplicates
145
+
146
+ def test_mem0_backend_skips_already_indexed_ids(self):
147
+ """The real backend dedupes by id before touching mem0, so a process that
148
+ re-indexes the same ledger slice each turn does not re-embed it."""
149
+ backend = Mem0MemoryIndex()
150
+ backend._indexed.add("e1") # pretend already indexed this process
151
+ # No mem0 call should happen for an already-indexed id; _memory() would
152
+ # raise (mem0 may be absent), so reaching it on a dup would surface here.
153
+ backend.index((_event("world.observed", eid="e1"),)) # no-op, no import
154
+
155
+
156
+ # ── env gate (no mem0 required) ──────────────────────────────────────────────────
157
+
158
+ class TestEnvGate:
159
+ def test_none_when_unset(self):
160
+ assert memory_index_from_env({}) is None
161
+
162
+ def test_none_when_falsey(self):
163
+ assert memory_index_from_env({"MEMORY_INDEX": "0"}) is None
164
+
165
+ def test_backend_when_truthy(self):
166
+ idx = memory_index_from_env({"MEMORY_INDEX": "1"})
167
+ assert isinstance(idx, Mem0MemoryIndex)
168
+
169
+ def test_config_blob_is_parsed(self):
170
+ idx = memory_index_from_env(
171
+ {"MEMORY_INDEX": "true", "MEMORY_INDEX_CONFIG": '{"version": "v1.1"}'}
172
+ )
173
+ assert isinstance(idx, Mem0MemoryIndex)
174
+ assert idx._config == {"version": "v1.1"}
175
+
176
+
177
+ # ── agent wiring: _recall threads the index into salience ────────────────────────
178
+
179
+ class _SalienceAgent(ManifestAgent):
180
+ manifest = AgentManifest(
181
+ name="recaller",
182
+ persona="p",
183
+ may_emit=["agent.spoke"],
184
+ memory=MemoryConfig(use_salience=True, salience_top_k=1),
185
+ )
186
+
187
+
188
+ class TestRecallWiring:
189
+ def test_recall_uses_attached_index(self):
190
+ idx = _FakeIndex()
191
+ agent = _SalienceAgent(ModelRouter(offline=True), memory_index=idx)
192
+ from src.core.projections import StageProjection
193
+
194
+ events = (
195
+ _event("world.observed", actor="n", turn=1, text="beacon glow signal", eid="hit"),
196
+ _event("world.observed", actor="n", turn=1, text="quiet empty room", eid="miss"),
197
+ )
198
+ proj = StageProjection(current_scene="beacon glow")
199
+ out = agent._recall(turn=2, projection=proj, recent_events=events)
200
+ assert "beacon" in out # the index-ranked event made it into the prompt
201
+ assert idx.store # the index was derived from the ledger events
202
+
203
+ def test_recall_without_index_is_keyword_path(self):
204
+ agent = _SalienceAgent(ModelRouter(offline=True)) # no index attached
205
+ from src.core.projections import StageProjection
206
+
207
+ e = _event("world.observed", actor="n", turn=1, text="golden spores")
208
+ out = agent._recall(turn=2, projection=StageProjection(current_scene="golden spores"), recent_events=(e,))
209
+ assert isinstance(out, str) and "golden" in out
210
+
211
+
212
+ # ── guarded real-mem0 round-trip (requires mem0 + an embedder) ───────────────────
213
+
214
+ class TestMem0RoundTrip:
215
+ def test_index_then_search_recovers_event(self):
216
+ pytest.importorskip("mem0")
217
+ if not os.getenv("OPENAI_API_KEY") and not os.getenv("MEMORY_INDEX_CONFIG"):
218
+ pytest.skip("mem0 needs an embedder (OPENAI_API_KEY or MEMORY_INDEX_CONFIG)")
219
+
220
+ backend = Mem0MemoryIndex()
221
+ ev = _event("world.observed", turn=1, text="golden spores drift over the glass forest", eid="rt1")
222
+ try:
223
+ backend.index((ev,))
224
+ hits = backend.search("golden spores", k=5)
225
+ except Exception as exc: # pragma: no cover - environment dependent
226
+ pytest.skip(f"mem0 backend unavailable: {exc}")
227
+
228
+ assert any(h.id == "rt1" for h in hits)
229
+ # Mapped back to a real Event with payload intact (derived from metadata).
230
+ hit = next(h for h in hits if h.id == "rt1")
231
+ assert hit.kind == "world.observed"
232
+ assert hit.payload.get("text", "").startswith("golden spores")