yc1838 commited on
Commit
c338c4c
·
1 Parent(s): 6b4f07b

fix 3 layer memory ststem

Browse files
README.md CHANGED
@@ -18,6 +18,7 @@ hf_oauth_expiration_minutes: 480
18
  ## Features
19
 
20
  - **Explicit ReAct graph** — tool-call dedup, per-tool error feedback, recursion cap, iteration fail-safe
 
21
  - **Tool belt** — web search, URL fetch, sandboxed Python, file I/O, PDF, audio/video transcription, YouTube frame extraction, vision (Gemini + FAL fallbacks), arXiv, CrossRef, todos
22
  - **Multi-provider routing** — cheap / strong / extra-strong model tiers with independent provider+model config
23
  - **Observability** — per-session JSONL trace + rotating log file, optional Arize AX + LangSmith tracing
@@ -88,6 +89,9 @@ The TUI prints the logo, caveman status, and the trace file path. Type your ques
88
  | Command | Effect |
89
  | --- | --- |
90
  | `/clear` | Wipe conversation memory, start a new thread |
 
 
 
91
  | `/caveman` | Toggle caveman on/off |
92
  | `/caveman off` / `/caveman on` | Explicit on/off |
93
  | `/caveman lite` | Lightest — keep articles & full sentences, cut fluff |
@@ -136,7 +140,8 @@ All tools live under [src/lilith_agent/tools/](src/lilith_agent/tools/) and are
136
  | `inspect_pdf` | PDF → text |
137
  | `inspect_visual_content` | Multimodal vision (Gemini + FAL moondream/llava fallbacks) |
138
  | `arxiv_search`, `crossref_search`, `count_journal_articles`, `filter_entities` | Academic metadata |
139
- | `write_todos`, `mark_todo_done` | High-level planning |
 
140
 
141
  ### Vision fallback chain
142
 
@@ -156,6 +161,7 @@ src/lilith_agent/
156
  app.py # ReAct graph, model routing, caveman prompt wrapping
157
  tui.py # interactive loop, slash commands, rich output
158
  runner.py # batch runner over GAIA questions
 
159
  config.py # Config.from_env(), model + API key + feature flags
160
  observability.py # logging, Arize setup, JsonlTraceCallback
161
  models.py # provider -> chat model builder
@@ -164,11 +170,37 @@ src/lilith_agent/
164
  scripts/
165
  dev_run_gaia.py # CLI to run against real GAIA questions
166
  .checkpoints/ # per-question answers (gitignored)
167
- .lilith/ # session logs + JSONL traces (gitignored)
168
  ```
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  ## Testing
171
 
172
  ```bash
173
  pytest
174
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  ## Features
19
 
20
  - **Explicit ReAct graph** — tool-call dedup, per-tool error feedback, recursion cap, iteration fail-safe
21
+ - **Three-layer persistent memory** — short-term thread checkpoints, long-term semantic facts (LangMem), episodic task experiences; inspired by the Engram memory architecture
22
  - **Tool belt** — web search, URL fetch, sandboxed Python, file I/O, PDF, audio/video transcription, YouTube frame extraction, vision (Gemini + FAL fallbacks), arXiv, CrossRef, todos
23
  - **Multi-provider routing** — cheap / strong / extra-strong model tiers with independent provider+model config
24
  - **Observability** — per-session JSONL trace + rotating log file, optional Arize AX + LangSmith tracing
 
89
  | Command | Effect |
90
  | --- | --- |
91
  | `/clear` | Wipe conversation memory, start a new thread |
92
+ | `/memory list` | Show all stored facts and recent episodic experiences |
93
+ | `/memory forget <id>` | Delete a fact by ID prefix |
94
+ | `/memory reflect` | Manually trigger long-term memory extraction for the current thread |
95
  | `/caveman` | Toggle caveman on/off |
96
  | `/caveman off` / `/caveman on` | Explicit on/off |
97
  | `/caveman lite` | Lightest — keep articles & full sentences, cut fluff |
 
140
  | `inspect_pdf` | PDF → text |
141
  | `inspect_visual_content` | Multimodal vision (Gemini + FAL moondream/llava fallbacks) |
142
  | `arxiv_search`, `crossref_search`, `count_journal_articles`, `filter_entities` | Academic metadata |
143
+ | `todos` | High-level planning |
144
+ | `search_memory` | Query Lilith's long-term memory (facts + episodes) by keyword |
145
 
146
  ### Vision fallback chain
147
 
 
161
  app.py # ReAct graph, model routing, caveman prompt wrapping
162
  tui.py # interactive loop, slash commands, rich output
163
  runner.py # batch runner over GAIA questions
164
+ memory.py # three-layer memory: checkpoints, semantic facts, episodic
165
  config.py # Config.from_env(), model + API key + feature flags
166
  observability.py # logging, Arize setup, JsonlTraceCallback
167
  models.py # provider -> chat model builder
 
170
  scripts/
171
  dev_run_gaia.py # CLI to run against real GAIA questions
172
  .checkpoints/ # per-question answers (gitignored)
173
+ .lilith/ # session logs, JSONL traces, long_term_memory.sqlite (gitignored)
174
  ```
175
 
176
+ ## Memory system
177
+
178
+ Lilith uses a three-layer persistent memory architecture loosely inspired by the [Engram memory model](https://arxiv.org/abs/2501.12599):
179
+
180
+ | Layer | Storage | Role |
181
+ | --- | --- | --- |
182
+ | **Short-term** (thread checkpoints) | `.lilith/threads.sqlite` via LangGraph `SqliteSaver` | Preserves full conversation state across restarts within a thread |
183
+ | **Long-term semantic** (facts) | `.lilith/long_term_memory.sqlite` | Extracts and deduplicates user preferences, names, project details using LangMem; injected into the system prompt on new queries |
184
+ | **Episodic** (task experiences) | `.lilith/long_term_memory.sqlite` | Summarises past tool trajectories — what failed, what worked — so Lilith avoids repeating mistakes |
185
+
186
+ The semantic layer uses LangMem as a memory governance engine (extraction, conflict resolution, forgetting) while SQLite provides local, auditable, migratable persistence. Long-term memory extraction runs automatically after each conversation and can be triggered manually with `/memory reflect`. The agent can also call `search_memory` during reasoning when the system-prompt injection has been truncated.
187
+
188
+ For batch GAIA runs each question gets an isolated ephemeral memory store (`MemoryStore(":memory:")`) so questions cannot contaminate each other.
189
+
190
  ## Testing
191
 
192
  ```bash
193
  pytest
194
  ```
195
+
196
+ Memory tests can use the `ephemeral_memory()` context manager for isolated in-memory stores:
197
+
198
+ ```python
199
+ from lilith_agent.memory import ephemeral_memory
200
+
201
+ def test_something():
202
+ with ephemeral_memory() as store:
203
+ store.add_episode("task", "summary", "success")
204
+ assert len(store.get_recent_episodes()) == 1
205
+ # store discarded on exit, no disk writes
206
+ ```
requirements.txt CHANGED
@@ -3,6 +3,7 @@ requests
3
  httpx
4
  pandas
5
  langgraph>=0.2.0
 
6
  langchain-core>=0.3.0
7
  langchain-anthropic>=0.2.0
8
  langchain-google-genai>=2.0.0
 
3
  httpx
4
  pandas
5
  langgraph>=0.2.0
6
+ langmem>=0.0.1
7
  langchain-core>=0.3.0
8
  langchain-anthropic>=0.2.0
9
  langchain-google-genai>=2.0.0
src/lilith_agent/app.py CHANGED
@@ -4,6 +4,7 @@ import json
4
  import logging
5
  import re
6
  import string
 
7
  from typing import Callable
8
 
9
  from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@@ -19,6 +20,7 @@ from typing import Annotated, TypedDict
19
  class AgentState(TypedDict):
20
  messages: Annotated[list, add_messages]
21
  iterations: int
 
22
 
23
 
24
  from lilith_agent.config import Config
@@ -292,6 +294,7 @@ def _build_tool_node(
292
  messages = state["messages"]
293
  last = messages[-1]
294
  tool_calls = getattr(last, "tool_calls", None) or []
 
295
  if tool_calls:
296
  log_tools.info(
297
  "[tools] dispatching %d call(s): %s",
@@ -410,13 +413,31 @@ def _build_tool_node(
410
  continue
411
 
412
  out_str = str(out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  preview = out_str.replace("\n", " ")
414
  if len(preview) > _TOOL_RESULT_PREVIEW_CHARS:
415
  preview = preview[:_TOOL_RESULT_PREVIEW_CHARS] + "…"
416
  log_tools.info("[tools] tool result (%d chars): %s", len(out_str), preview)
417
  results.append(ToolMessage(tool_call_id=tc_id, name=name, content=out_str))
418
 
419
- return {"messages": results}
 
 
 
420
 
421
  return tool_node
422
 
@@ -592,10 +613,14 @@ def build_react_agent(cfg: Config):
592
  return {"messages": [response]}
593
 
594
  def extract_memory_node(state):
595
- from lilith_agent.memory import extract_and_compress_facts
 
 
 
 
596
  try:
597
  cheap_model = get_cheap_model(cfg)
598
- extract_and_compress_facts(state["messages"], cheap_model)
599
  except Exception as e:
600
  log.warning("[memory] failed to run extraction: %s", e)
601
  return state
 
4
  import logging
5
  import re
6
  import string
7
+ import ast
8
  from typing import Callable
9
 
10
  from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
 
20
  class AgentState(TypedDict):
21
  messages: Annotated[list, add_messages]
22
  iterations: int
23
+ todos: list[str]
24
 
25
 
26
  from lilith_agent.config import Config
 
294
  messages = state["messages"]
295
  last = messages[-1]
296
  tool_calls = getattr(last, "tool_calls", None) or []
297
+ todo_state_update = None
298
  if tool_calls:
299
  log_tools.info(
300
  "[tools] dispatching %d call(s): %s",
 
413
  continue
414
 
415
  out_str = str(out)
416
+ if out_str.startswith("SET_TODOS:"):
417
+ try:
418
+ parsed = ast.literal_eval(out_str[len("SET_TODOS:"):].strip())
419
+ if isinstance(parsed, list):
420
+ todo_state_update = [str(item) for item in parsed]
421
+ except Exception:
422
+ pass
423
+ elif out_str.startswith("DONE_TODO:"):
424
+ try:
425
+ idx = int(out_str[len("DONE_TODO:"):].strip())
426
+ current = list(state.get("todos", []))
427
+ if 0 <= idx < len(current):
428
+ todo_state_update = current[:idx] + current[idx + 1:]
429
+ except Exception:
430
+ pass
431
  preview = out_str.replace("\n", " ")
432
  if len(preview) > _TOOL_RESULT_PREVIEW_CHARS:
433
  preview = preview[:_TOOL_RESULT_PREVIEW_CHARS] + "…"
434
  log_tools.info("[tools] tool result (%d chars): %s", len(out_str), preview)
435
  results.append(ToolMessage(tool_call_id=tc_id, name=name, content=out_str))
436
 
437
+ update = {"messages": results}
438
+ if todo_state_update is not None:
439
+ update["todos"] = todo_state_update
440
+ return update
441
 
442
  return tool_node
443
 
 
613
  return {"messages": [response]}
614
 
615
  def extract_memory_node(state):
616
+ from lilith_agent.memory import extract_and_compress_facts, MIN_MESSAGES_FOR_EXTRACTION
617
+ messages = state["messages"]
618
+ if len(messages) < MIN_MESSAGES_FOR_EXTRACTION:
619
+ log.debug("[memory] skipping extraction: only %d messages", len(messages))
620
+ return state
621
  try:
622
  cheap_model = get_cheap_model(cfg)
623
+ extract_and_compress_facts(messages, cheap_model)
624
  except Exception as e:
625
  log.warning("[memory] failed to run extraction: %s", e)
626
  return state
src/lilith_agent/memory.py CHANGED
@@ -2,8 +2,10 @@ import os
2
  import json
3
  import sqlite3
4
  import logging
 
 
5
  from pathlib import Path
6
- from typing import List, Dict, Any
7
  from datetime import datetime
8
  from langchain_core.messages import BaseMessage, AIMessage, HumanMessage
9
 
@@ -12,6 +14,8 @@ log = logging.getLogger(__name__)
12
  # Constants
13
  LILITH_HOME = Path(os.getenv("LILITH_HOME", ".lilith"))
14
  MEMORY_DB_PATH = LILITH_HOME / "long_term_memory.sqlite"
 
 
15
 
16
  def _content_to_text(content: Any) -> str:
17
  if content is None:
@@ -42,13 +46,28 @@ def _memory_content_to_text(content: Any) -> str:
42
  return _content_to_text(content)
43
 
44
  class MemoryStore:
45
- def __init__(self, db_path: Path = MEMORY_DB_PATH):
46
- self.db_path = db_path
 
 
47
  self._init_db()
48
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def _init_db(self):
50
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
51
- conn = sqlite3.connect(str(self.db_path))
 
52
  with conn:
53
  conn.execute("""
54
  CREATE TABLE IF NOT EXISTS memories (
@@ -68,62 +87,130 @@ class MemoryStore:
68
  created_at TEXT NOT NULL
69
  )
70
  """)
71
- conn.close()
 
72
 
73
  def get_all_memories(self) -> List[Dict[str, Any]]:
74
- conn = sqlite3.connect(str(self.db_path))
75
  conn.row_factory = sqlite3.Row
76
  cur = conn.cursor()
77
  cur.execute("SELECT * FROM memories ORDER BY updated_at DESC")
78
  rows = [dict(r) for r in cur.fetchall()]
79
- conn.close()
 
80
  return rows
81
 
82
- def save_memories(self, memories: List[Dict[str, Any]]):
83
  """Replaces the memories table with the provided list (active compression)."""
84
- conn = sqlite3.connect(str(self.db_path))
 
 
 
 
 
 
85
  with conn:
86
  conn.execute("DELETE FROM memories")
87
  for m in memories:
88
  now = datetime.now().isoformat()
 
 
89
  conn.execute(
90
  "INSERT INTO memories (id, content, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
91
- (m.get("id", str(hash(m["content"]))), m["content"], m.get("type", "fact"), now, now)
92
  )
93
- conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  def add_episode(self, task: str, summary: str, outcome: str):
96
- conn = sqlite3.connect(str(self.db_path))
97
  with conn:
98
  now = datetime.now().isoformat()
99
  conn.execute(
100
  "INSERT INTO episodes (id, task, summary, outcome, created_at) VALUES (?, ?, ?, ?, ?)",
101
- (str(hash(task + now)), task, summary, outcome, now)
102
  )
103
- conn.close()
 
104
 
105
  def get_recent_episodes(self, limit: int = 3) -> List[Dict[str, Any]]:
106
- conn = sqlite3.connect(str(self.db_path))
107
  conn.row_factory = sqlite3.Row
108
  cur = conn.cursor()
109
  cur.execute("SELECT * FROM episodes ORDER BY created_at DESC LIMIT ?", (limit,))
110
  rows = [dict(r) for r in cur.fetchall()]
111
- conn.close()
 
112
  return rows
113
 
114
  _store = MemoryStore()
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
117
  """
118
  Extracts new facts and merges them with existing ones using langmem's manager.
119
  Implements professional reflection and conflict resolution.
120
  """
121
  from langmem import create_memory_manager
 
122
  log.info("[memory] Running langmem memory management...")
123
  try:
124
  # 1. Get existing memories from our local store
125
  existing_rows = _store.get_all_memories()
126
- existing_memories = [m["content"] for m in existing_rows]
 
 
127
 
128
  # 2. Initialize langmem manager (it's a Runnable)
129
  # We use the default schema which is basically content strings
@@ -141,14 +228,44 @@ def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
141
  # The result is a list of ExtractedMemory objects or tuples depending on version
142
  # Usually it's (id, content) or just objects. We'll be robust here.
143
  updated_facts = []
 
144
  for item in result:
145
- # result elements can be ExtractedMemory objects or simple dicts
146
- content = getattr(item, "content", None) or (item[1] if isinstance(item, tuple) else item.get("content"))
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  if content:
148
- updated_facts.append({"content": _memory_content_to_text(content)})
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
- _store.save_memories(updated_facts)
151
- log.info(f"[memory] langmem updated store to {len(updated_facts)} facts.")
 
 
 
 
152
 
153
  except Exception:
154
  log.exception("[memory] langmem extraction failed")
@@ -193,22 +310,94 @@ Keep it under 150 words.
193
  except Exception:
194
  log.exception("[memory] Summarization failed")
195
 
196
- def retrieve_relevant_context(query: str) -> str:
197
- """Fetches all facts and recent episodes to inject into the prompt."""
 
 
 
 
 
 
 
 
 
198
  try:
 
199
  facts = _store.get_all_memories()
200
- episodes = _store.get_recent_episodes(limit=2)
201
-
 
 
202
  context_parts = []
 
 
203
  if facts:
204
- fact_lines = "\n".join([f"- {m['content']}" for m in facts])
205
- context_parts.append(f"<known_facts>\n{fact_lines}\n</known_facts>")
206
-
207
- if episodes:
208
- epi_lines = "\n\n".join([f"Task: {e['task']}\nSummary: {e['summary']}" for e in episodes])
209
- context_parts.append(f"<past_experiences>\n{epi_lines}\n</past_experiences>")
210
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  return "\n\n".join(context_parts)
212
  except Exception:
213
  log.exception("[memory] Retrieval failed")
214
  return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import json
3
  import sqlite3
4
  import logging
5
+ import uuid
6
+ from contextlib import contextmanager
7
  from pathlib import Path
8
+ from typing import List, Dict, Any, Optional, Union
9
  from datetime import datetime
10
  from langchain_core.messages import BaseMessage, AIMessage, HumanMessage
11
 
 
14
  # Constants
15
  LILITH_HOME = Path(os.getenv("LILITH_HOME", ".lilith"))
16
  MEMORY_DB_PATH = LILITH_HOME / "long_term_memory.sqlite"
17
+ MEMORY_CONTEXT_CHAR_BUDGET = 3000
18
+ MIN_MESSAGES_FOR_EXTRACTION = 2
19
 
20
  def _content_to_text(content: Any) -> str:
21
  if content is None:
 
46
  return _content_to_text(content)
47
 
48
  class MemoryStore:
49
+ def __init__(self, db_path: Union[Path, str] = MEMORY_DB_PATH):
50
+ self._in_memory = (str(db_path) == ":memory:")
51
+ self.db_path = db_path if self._in_memory else Path(db_path)
52
+ self._mem_conn: Optional[sqlite3.Connection] = None
53
  self._init_db()
54
 
55
+ def _connect(self) -> sqlite3.Connection:
56
+ if self._in_memory:
57
+ if self._mem_conn is None:
58
+ self._mem_conn = sqlite3.connect(":memory:", check_same_thread=False)
59
+ return self._mem_conn
60
+ return sqlite3.connect(str(self.db_path))
61
+
62
+ def close(self):
63
+ if self._mem_conn is not None:
64
+ self._mem_conn.close()
65
+ self._mem_conn = None
66
+
67
  def _init_db(self):
68
+ if not self._in_memory:
69
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
70
+ conn = self._connect()
71
  with conn:
72
  conn.execute("""
73
  CREATE TABLE IF NOT EXISTS memories (
 
87
  created_at TEXT NOT NULL
88
  )
89
  """)
90
+ if not self._in_memory:
91
+ conn.close()
92
 
93
  def get_all_memories(self) -> List[Dict[str, Any]]:
94
+ conn = self._connect()
95
  conn.row_factory = sqlite3.Row
96
  cur = conn.cursor()
97
  cur.execute("SELECT * FROM memories ORDER BY updated_at DESC")
98
  rows = [dict(r) for r in cur.fetchall()]
99
+ if not self._in_memory:
100
+ conn.close()
101
  return rows
102
 
103
+ def save_memories(self, memories: List[Dict[str, Any]], allow_empty: bool = False):
104
  """Replaces the memories table with the provided list (active compression)."""
105
+ if not memories:
106
+ existing = self.get_all_memories()
107
+ if existing and not allow_empty:
108
+ log.warning("[memory] save_memories called with empty list while %d facts exist — refusing to wipe",
109
+ len(existing))
110
+ return
111
+ conn = self._connect()
112
  with conn:
113
  conn.execute("DELETE FROM memories")
114
  for m in memories:
115
  now = datetime.now().isoformat()
116
+ created_at = m.get("created_at", now)
117
+ updated_at = m.get("updated_at", now)
118
  conn.execute(
119
  "INSERT INTO memories (id, content, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
120
+ (m.get("id", str(uuid.uuid4())), m["content"], m.get("type", "fact"), created_at, updated_at)
121
  )
122
+ if not self._in_memory:
123
+ conn.close()
124
+
125
+ def delete_memory_prefix(self, prefix: str) -> int:
126
+ if not prefix:
127
+ return 0
128
+ matching_ids = [
129
+ row["id"]
130
+ for row in self.get_all_memories()
131
+ if row["id"].startswith(prefix)
132
+ ]
133
+ if not matching_ids:
134
+ return 0
135
+ conn = self._connect()
136
+ with conn:
137
+ for memory_id in matching_ids:
138
+ conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
139
+ if not self._in_memory:
140
+ conn.close()
141
+ return len(matching_ids)
142
 
143
  def add_episode(self, task: str, summary: str, outcome: str):
144
+ conn = self._connect()
145
  with conn:
146
  now = datetime.now().isoformat()
147
  conn.execute(
148
  "INSERT INTO episodes (id, task, summary, outcome, created_at) VALUES (?, ?, ?, ?, ?)",
149
+ (str(uuid.uuid4()), task, summary, outcome, now)
150
  )
151
+ if not self._in_memory:
152
+ conn.close()
153
 
154
  def get_recent_episodes(self, limit: int = 3) -> List[Dict[str, Any]]:
155
+ conn = self._connect()
156
  conn.row_factory = sqlite3.Row
157
  cur = conn.cursor()
158
  cur.execute("SELECT * FROM episodes ORDER BY created_at DESC LIMIT ?", (limit,))
159
  rows = [dict(r) for r in cur.fetchall()]
160
+ if not self._in_memory:
161
+ conn.close()
162
  return rows
163
 
164
  _store = MemoryStore()
165
 
166
+
167
+ def _set_store(store: MemoryStore) -> MemoryStore:
168
+ """Swap the module-level store and return the previous one."""
169
+ global _store
170
+ prev, _store = _store, store
171
+ return prev
172
+
173
+
174
+ @contextmanager
175
+ def ephemeral_memory(db_path: Union[str, Path] = ":memory:"):
176
+ """Context manager that replaces the global _store with a fresh, isolated
177
+ MemoryStore for the duration of the block. On exit the store is closed and
178
+ the previous store is restored. Use db_path=':memory:' (default) for tests
179
+ or GAIA benchmarking where cross-contamination must be prevented.
180
+
181
+ Example::
182
+
183
+ with ephemeral_memory():
184
+ result = graph.invoke(state, config)
185
+ # any memory writes here are discarded on exit
186
+ """
187
+ fresh = MemoryStore(db_path)
188
+ prev = _set_store(fresh)
189
+ try:
190
+ yield fresh
191
+ finally:
192
+ _set_store(prev)
193
+ fresh.close()
194
+
195
+
196
+ def _is_remove_doc(content: Any) -> bool:
197
+ return hasattr(content, "__repr_name__") and content.__repr_name__() == "RemoveDoc"
198
+
199
+
200
  def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
201
  """
202
  Extracts new facts and merges them with existing ones using langmem's manager.
203
  Implements professional reflection and conflict resolution.
204
  """
205
  from langmem import create_memory_manager
206
+ from langmem.knowledge.extraction import Memory
207
  log.info("[memory] Running langmem memory management...")
208
  try:
209
  # 1. Get existing memories from our local store
210
  existing_rows = _store.get_all_memories()
211
+ existing_memories = [(m["id"], Memory(content=m["content"])) for m in existing_rows]
212
+ existing_by_content = {m["content"]: m for m in existing_rows}
213
+ existing_by_id = {m["id"]: m for m in existing_rows}
214
 
215
  # 2. Initialize langmem manager (it's a Runnable)
216
  # We use the default schema which is basically content strings
 
228
  # The result is a list of ExtractedMemory objects or tuples depending on version
229
  # Usually it's (id, content) or just objects. We'll be robust here.
230
  updated_facts = []
231
+ removed_ids = set()
232
  for item in result:
233
+ item_id = getattr(item, "id", None)
234
+ content = getattr(item, "content", None)
235
+ if isinstance(item, tuple):
236
+ if len(item) > 0:
237
+ item_id = item[0]
238
+ if len(item) > 1 and content is None:
239
+ content = item[1]
240
+ elif isinstance(item, dict):
241
+ item_id = item.get("id", item_id)
242
+ content = item.get("content", content)
243
+ stable_id = str(item_id) if item_id else None
244
+ if _is_remove_doc(content):
245
+ if stable_id:
246
+ removed_ids.add(stable_id)
247
+ continue
248
  if content:
249
+ text = _memory_content_to_text(content)
250
+ existing = existing_by_id.get(stable_id) if stable_id else existing_by_content.get(text)
251
+ fact = {"content": text}
252
+ if stable_id:
253
+ fact["id"] = stable_id
254
+ elif existing:
255
+ fact["id"] = existing["id"]
256
+ if existing:
257
+ fact["created_at"] = existing["created_at"]
258
+ if existing["content"] == text:
259
+ fact["updated_at"] = existing["updated_at"]
260
+ if fact.get("id") not in removed_ids:
261
+ updated_facts.append(fact)
262
 
263
+ if updated_facts or removed_ids:
264
+ _store.save_memories(updated_facts, allow_empty=bool(removed_ids))
265
+ log.info(f"[memory] langmem updated store to {len(updated_facts)} facts.")
266
+ else:
267
+ log.info("[memory] langmem returned empty result — keeping existing facts")
268
+ summarize_episode(messages, model)
269
 
270
  except Exception:
271
  log.exception("[memory] langmem extraction failed")
 
310
  except Exception:
311
  log.exception("[memory] Summarization failed")
312
 
313
+ def _relevance_score(text: str, query_tokens: set) -> float:
314
+ """Simple word-overlap score between a fact and the query (Jaccard-like)."""
315
+ if not query_tokens:
316
+ return 0.0
317
+ fact_tokens = set(text.lower().split())
318
+ return len(fact_tokens & query_tokens) / (len(fact_tokens | query_tokens) or 1)
319
+
320
+
321
+ def retrieve_relevant_context(query: str, char_budget: int = MEMORY_CONTEXT_CHAR_BUDGET) -> str:
322
+ """Fetches facts and recent episodes ranked by relevance, capped by char_budget.
323
+ If truncated, appends a note instructing the agent to call search_memory for more."""
324
  try:
325
+ query_tokens = set(query.lower().split())
326
  facts = _store.get_all_memories()
327
+ episodes = _store.get_recent_episodes(limit=5)
328
+
329
+ facts.sort(key=lambda m: _relevance_score(m["content"], query_tokens), reverse=True)
330
+
331
  context_parts = []
332
+ budget_remaining = char_budget
333
+
334
  if facts:
335
+ included, total = [], len(facts)
336
+ for m in facts:
337
+ line = f"- {m['content']}"
338
+ if budget_remaining - len(line) - 1 > 0:
339
+ included.append(line)
340
+ budget_remaining -= len(line) + 1
341
+ else:
342
+ break
343
+ fact_block = "<known_facts>\n" + "\n".join(included) + "\n</known_facts>"
344
+ omitted = total - len(included)
345
+ if omitted:
346
+ fact_block += f"\n<!-- {omitted} fact(s) omitted (budget). Call search_memory(query) to retrieve more. -->"
347
+ context_parts.append(fact_block)
348
+
349
+ if episodes and budget_remaining > 0:
350
+ included_ep = []
351
+ for e in episodes:
352
+ line = f"Task: {e['task']}\nSummary: {e['summary']}"
353
+ if budget_remaining - len(line) - 2 > 0:
354
+ included_ep.append(line)
355
+ budget_remaining -= len(line) + 2
356
+ else:
357
+ break
358
+ if included_ep:
359
+ ep_block = "<past_experiences>\n" + "\n\n".join(included_ep) + "\n</past_experiences>"
360
+ omitted_ep = len(episodes) - len(included_ep)
361
+ if omitted_ep:
362
+ ep_block += f"\n<!-- {omitted_ep} episode(s) omitted. Call search_memory(query) for more. -->"
363
+ context_parts.append(ep_block)
364
+
365
  return "\n\n".join(context_parts)
366
  except Exception:
367
  log.exception("[memory] Retrieval failed")
368
  return ""
369
+
370
+
371
+ def search_memory_store(query: str, max_results: int = 10) -> str:
372
+ """Keyword search across all facts and episodes. Called by the agent when
373
+ the system-prompt injection was truncated or the query needs deeper lookup."""
374
+ try:
375
+ query_tokens = set(query.lower().split())
376
+ facts = _store.get_all_memories()
377
+ episodes = _store.get_recent_episodes(limit=50)
378
+
379
+ scored_facts = sorted(
380
+ [(f, _relevance_score(f["content"], query_tokens)) for f in facts],
381
+ key=lambda x: x[1], reverse=True
382
+ )
383
+ scored_eps = sorted(
384
+ [(e, _relevance_score(e["task"] + " " + e["summary"], query_tokens)) for e in episodes],
385
+ key=lambda x: x[1], reverse=True
386
+ )
387
+
388
+ results = []
389
+ for m, score in scored_facts[:max_results]:
390
+ if score > 0:
391
+ results.append((score, f"[fact] {m['content']}"))
392
+ for e, score in scored_eps[:max_results]:
393
+ if score > 0:
394
+ results.append((score, f"[episode] Task: {e['task']}\n Summary: {e['summary']}"))
395
+
396
+ results.sort(key=lambda x: x[0], reverse=True)
397
+ parts = [text for _, text in results[:max_results]]
398
+ if not parts:
399
+ return "No matching memories found."
400
+ return "\n\n".join(parts)
401
+ except Exception:
402
+ log.exception("[memory] search_memory_store failed")
403
+ return "Memory search failed."
src/lilith_agent/runner.py CHANGED
@@ -205,8 +205,10 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
205
  "iterations": 0
206
  }
207
 
 
208
  try:
209
- result = graph.invoke(state, {"configurable": {"thread_id": task_id}})
 
210
  except Exception as exc:
211
  log_runner.warning("[runner] task=%s agent error: %s", task_id, exc)
212
  answers.append(
 
205
  "iterations": 0
206
  }
207
 
208
+ from lilith_agent.memory import ephemeral_memory
209
  try:
210
+ with ephemeral_memory():
211
+ result = graph.invoke(state, {"configurable": {"thread_id": task_id}})
212
  except Exception as exc:
213
  log_runner.warning("[runner] task=%s agent error: %s", task_id, exc)
214
  answers.append(
src/lilith_agent/tools/__init__.py CHANGED
@@ -14,11 +14,7 @@ from lilith_agent.tools.files import (
14
  find_files as _find_files,
15
  write_file as _write_file
16
  )
17
- from lilith_agent.tools.todos import (
18
- write_todos as _write_todos,
19
- mark_todo_done as _mark_todo_done,
20
- read_todos as _read_todos
21
- )
22
  from lilith_agent.tools.pdf import inspect_pdf as _inspect_pdf
23
  from lilith_agent.tools.python_exec import run_python as _run_python
24
  from lilith_agent.tools.search import web_search as _web_search
@@ -117,14 +113,13 @@ def build_tools(cfg: Config) -> list[BaseTool]:
117
  return _write_file(path, content)
118
 
119
  @tool
120
- def write_todos(todos: list[str]) -> str:
121
- """Initialize or overwrite the current task list (Todo list). Use for high-level planning."""
122
- return _write_todos(todos)
123
 
124
- @tool
125
- def mark_todo_done(index: int) -> str:
126
- """Mark a specific todo as complete by its position."""
127
- return _mark_todo_done(index)
128
 
129
  @tool
130
  def transcribe_audio(path: str) -> str:
@@ -184,6 +179,15 @@ def build_tools(cfg: Config) -> list[BaseTool]:
184
  """
185
  return _filter_entities(entities, keep_conditions=keep_conditions, remove_conditions=remove_conditions)
186
 
 
 
 
 
 
 
 
 
 
187
  return [
188
  web_search,
189
  fetch_url,
@@ -203,6 +207,6 @@ def build_tools(cfg: Config) -> list[BaseTool]:
203
  glob_files,
204
  find_files,
205
  write_file,
206
- write_todos,
207
- mark_todo_done,
208
  ]
 
14
  find_files as _find_files,
15
  write_file as _write_file
16
  )
17
+ from lilith_agent.tools.todos import todos as _todos
 
 
 
 
18
  from lilith_agent.tools.pdf import inspect_pdf as _inspect_pdf
19
  from lilith_agent.tools.python_exec import run_python as _run_python
20
  from lilith_agent.tools.search import web_search as _web_search
 
113
  return _write_file(path, content)
114
 
115
  @tool
116
+ def todos(action: str, items: Optional[list[str]] = None, index: Optional[int] = None) -> str:
117
+ """Manage the planning todo list.
 
118
 
119
+ action='write': overwrite the list. Requires `items` (list of strings).
120
+ action='done': mark a todo complete. Requires `index` (0-based position).
121
+ """
122
+ return _todos(action, items=items, index=index)
123
 
124
  @tool
125
  def transcribe_audio(path: str) -> str:
 
179
  """
180
  return _filter_entities(entities, keep_conditions=keep_conditions, remove_conditions=remove_conditions)
181
 
182
+ @tool
183
+ def search_memory(query: str) -> str:
184
+ """Search Lilith's long-term memory (semantic facts and episodic experiences) for information
185
+ matching the given query. Use this when the system context mentions omitted facts, or when
186
+ you need to recall specific past experiences, user preferences, or project details that may
187
+ not be in the current context window."""
188
+ from lilith_agent.memory import search_memory_store
189
+ return search_memory_store(query)
190
+
191
  return [
192
  web_search,
193
  fetch_url,
 
207
  glob_files,
208
  find_files,
209
  write_file,
210
+ todos,
211
+ search_memory,
212
  ]
src/lilith_agent/tools/todos.py CHANGED
@@ -1,29 +1,19 @@
1
  from __future__ import annotations
2
- from typing import List
3
 
4
- def write_todos(todos: List[str]) -> str:
5
- """
6
- Initialize or overwrite the current task list (Todo list).
7
- Used for high-level planning and tracking progress.
8
- """
9
- # This tool will be handled specially by the executor node to update AgentState
10
- return f"SET_TODOS: {todos}"
11
 
12
- def mark_todo_done(index: int) -> str:
13
- """
14
- Mark a specific todo as complete by its 0-indexed position.
15
- """
16
- # This tool will be handled specially by the executor node to update AgentState
17
- return f"DONE_TODO: {index}"
18
-
19
- def read_todos(todo_list: List[str]) -> str:
20
- """
21
- Read the current state of the todo list.
22
- """
23
- if not todo_list:
24
- return "Todo list is empty."
25
-
26
- lines = []
27
- for i, todo in enumerate(todo_list):
28
- lines.append(f"{i}. {todo}")
29
- return "\n".join(lines)
 
1
  from __future__ import annotations
2
+ from typing import List, Optional
3
 
 
 
 
 
 
 
 
4
 
5
+ def todos(
6
+ action: str,
7
+ items: Optional[List[str]] = None,
8
+ index: Optional[int] = None,
9
+ ) -> str:
10
+ """Branching todo tool. Executor node consumes SET_TODOS / DONE_TODO sentinels."""
11
+ if action == "write":
12
+ if items is None:
13
+ return "ERROR: action='write' requires `items` (list of strings)."
14
+ return f"SET_TODOS: {items}"
15
+ if action == "done":
16
+ if index is None:
17
+ return "ERROR: action='done' requires `index` (0-based position)."
18
+ return f"DONE_TODO: {index}"
19
+ return f"ERROR: unknown action '{action}'. Use 'write' or 'done'."
 
 
 
src/lilith_agent/tui.py CHANGED
@@ -123,7 +123,55 @@ def main_loop(cfg):
123
  if text.lower() in ("exit", "quit"):
124
  console.print("[magenta]Goodbye! 🦋[/magenta]")
125
  break
126
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  if text.lower() == "/clear":
128
  thread_id = str(uuid.uuid4())
129
  trace_path = log_path.with_name(f"{log_path.stem}-{thread_id[:8]}.jsonl")
 
123
  if text.lower() in ("exit", "quit"):
124
  console.print("[magenta]Goodbye! 🦋[/magenta]")
125
  break
126
+
127
+ if text.lower().startswith("/memory"):
128
+ from lilith_agent.memory import _store, extract_and_compress_facts
129
+ from lilith_agent.models import get_cheap_model
130
+ parts = text.split(maxsplit=1)
131
+ sub = parts[1].strip() if len(parts) > 1 else "list"
132
+
133
+ if sub == "list":
134
+ facts = _store.get_all_memories()
135
+ episodes = _store.get_recent_episodes(limit=5)
136
+ if facts:
137
+ console.print("\n[bold cyan]── Semantic Facts ──[/bold cyan]")
138
+ for m in facts:
139
+ console.print(f" [dim]{m['id'][:8]}[/dim] {m['content']}")
140
+ else:
141
+ console.print("[dim]No semantic facts stored.[/dim]")
142
+ if episodes:
143
+ console.print("\n[bold cyan]── Episodic Memory ──[/bold cyan]")
144
+ for e in episodes:
145
+ console.print(f" [dim]{e['id'][:8]}[/dim] [bold]{e['task'][:60]}[/bold]\n {e['summary'][:120]}...")
146
+ else:
147
+ console.print("[dim]No episodes stored.[/dim]")
148
+ console.print()
149
+
150
+ elif sub.startswith("forget "):
151
+ target_id = sub[len("forget "):].strip()
152
+ deleted = _store.delete_memory_prefix(target_id)
153
+ if deleted:
154
+ console.print(f"[dim cyan]Deleted {deleted} fact(s) matching '{target_id}'.[/dim cyan]\n")
155
+ else:
156
+ console.print(f"[yellow]No fact found with id starting with '{target_id}'.[/yellow]\n")
157
+
158
+ elif sub == "reflect":
159
+ console.print("[dim cyan]Running memory reflection...[/dim cyan]")
160
+ try:
161
+ cheap_model = get_cheap_model(cfg)
162
+ state = graph.get_state(thread_config)
163
+ msgs = state.values.get("messages", []) if state and state.values else []
164
+ if msgs:
165
+ extract_and_compress_facts(msgs, cheap_model)
166
+ console.print("[dim cyan]Reflection complete.[/dim cyan]\n")
167
+ else:
168
+ console.print("[yellow]No messages in current thread to reflect on.[/yellow]\n")
169
+ except Exception as exc:
170
+ console.print(f"[bold red]Reflection failed: {exc}[/bold red]\n")
171
+ else:
172
+ console.print("[dim]Usage: /memory list | /memory forget <id> | /memory reflect[/dim]\n")
173
+ continue
174
+
175
  if text.lower() == "/clear":
176
  thread_id = str(uuid.uuid4())
177
  trace_path = log_path.with_name(f"{log_path.stem}-{thread_id[:8]}.jsonl")
tests/test_graph.py CHANGED
@@ -130,3 +130,27 @@ def test_tool_node_catches_tool_exceptions_and_feeds_back():
130
  msg = out["messages"][0]
131
  assert isinstance(msg, ToolMessage)
132
  assert "kaboom" in msg.content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  msg = out["messages"][0]
131
  assert isinstance(msg, ToolMessage)
132
  assert "kaboom" in msg.content
133
+
134
+
135
+ @tool_decorator
136
+ def todo_sentinel_tool(action: str) -> str:
137
+ """Returns todo sentinel output."""
138
+ if action == "write":
139
+ return "SET_TODOS: ['first', 'second']"
140
+ return "DONE_TODO: 0"
141
+
142
+
143
+ def test_tool_node_consumes_todo_sentinels_into_state():
144
+ node = _build_tool_node([todo_sentinel_tool])
145
+
146
+ write_out = node({
147
+ "messages": [_ai_with_calls([{"id": "1", "name": "todo_sentinel_tool", "args": {"action": "write"}}])],
148
+ "todos": [],
149
+ })
150
+ assert write_out["todos"] == ["first", "second"]
151
+
152
+ done_out = node({
153
+ "messages": [_ai_with_calls([{"id": "2", "name": "todo_sentinel_tool", "args": {"action": "done"}}])],
154
+ "todos": write_out["todos"],
155
+ })
156
+ assert done_out["todos"] == ["second"]
tests/test_memory_persistence.py CHANGED
@@ -65,7 +65,7 @@ def test_summarize_episode_logs_traceback_on_failure(tmp_path, monkeypatch, capl
65
  assert record.exc_info is not None
66
 
67
 
68
- def test_extract_and_compress_facts_passes_existing_memories_as_strings(tmp_path, monkeypatch):
69
  from lilith_agent import memory
70
  import langmem
71
 
@@ -81,6 +81,15 @@ def test_extract_and_compress_facts_passes_existing_memories_as_strings(tmp_path
81
  monkeypatch.setattr(memory, "_store", store)
82
  monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
83
 
84
- memory.extract_and_compress_facts([HumanMessage(content="New fact")], object())
 
 
 
 
 
 
 
85
 
86
- assert captured["existing"] == ["Existing fact"]
 
 
 
65
  assert record.exc_info is not None
66
 
67
 
68
+ def test_extract_and_compress_facts_passes_existing_memories_with_ids(tmp_path, monkeypatch):
69
  from lilith_agent import memory
70
  import langmem
71
 
 
81
  monkeypatch.setattr(memory, "_store", store)
82
  monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
83
 
84
+ class FakeModel:
85
+ def invoke(self, prompt):
86
+ class Response:
87
+ content = "Lesson"
88
+
89
+ return Response()
90
+
91
+ memory.extract_and_compress_facts([HumanMessage(content="New fact")], FakeModel())
92
 
93
+ existing_id, existing_memory = captured["existing"][0]
94
+ assert existing_id == "memory-1"
95
+ assert existing_memory.content == "Existing fact"
tests/test_memory_safety.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for memory safety guards: empty-list wipe prevention and ID stability."""
2
+ import pytest
3
+ from langchain_core.messages import HumanMessage
4
+
5
+ from lilith_agent.memory import MemoryStore, MIN_MESSAGES_FOR_EXTRACTION, ephemeral_memory
6
+
7
+
8
+ class _FakeEpisodeModel:
9
+ def invoke(self, prompt):
10
+ class Response:
11
+ content = "Test lesson"
12
+
13
+ return Response()
14
+
15
+
16
+ class TestSaveMemoriesEmptyGuard:
17
+ """save_memories([]) must not wipe existing facts."""
18
+
19
+ def test_refuses_empty_list_when_facts_exist(self, tmp_path):
20
+ store = MemoryStore(tmp_path / "test.sqlite")
21
+ store.save_memories([
22
+ {"id": "fact-1", "content": "User prefers dark theme"},
23
+ {"id": "fact-2", "content": "User name is Yujing"},
24
+ ])
25
+ assert len(store.get_all_memories()) == 2
26
+
27
+ # Empty list should NOT wipe
28
+ store.save_memories([])
29
+
30
+ facts = store.get_all_memories()
31
+ assert len(facts) == 2, "Empty list wiped all facts!"
32
+
33
+ def test_empty_list_on_empty_store_is_noop(self, tmp_path):
34
+ store = MemoryStore(tmp_path / "test.sqlite")
35
+ assert len(store.get_all_memories()) == 0
36
+
37
+ # Empty save on empty store is fine — no error, still empty
38
+ store.save_memories([])
39
+ assert len(store.get_all_memories()) == 0
40
+
41
+
42
+ class TestExtractDoesNotWipeOnEmptyResult:
43
+ """extract_and_compress_facts must not wipe store when LangMem returns []."""
44
+
45
+ def test_langmem_empty_result_preserves_existing_facts(self, tmp_path, monkeypatch):
46
+ from lilith_agent import memory
47
+ import langmem
48
+
49
+ class FakeManager:
50
+ def invoke(self, payload):
51
+ return [] # LangMem found nothing new
52
+
53
+ store = MemoryStore(tmp_path / "test.sqlite")
54
+ store.save_memories([
55
+ {"id": "fact-1", "content": "Important fact"},
56
+ ])
57
+ monkeypatch.setattr(memory, "_store", store)
58
+ monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
59
+
60
+ memory.extract_and_compress_facts(
61
+ [HumanMessage(content="Hello"), HumanMessage(content="How are you?")],
62
+ _FakeEpisodeModel(),
63
+ )
64
+
65
+ facts = store.get_all_memories()
66
+ assert len(facts) == 1, "LangMem empty result wiped existing facts!"
67
+ assert facts[0]["content"] == "Important fact"
68
+
69
+
70
+ class TestMemoryIdStability:
71
+ def test_passes_existing_memories_with_stable_ids_to_langmem(self, tmp_path, monkeypatch):
72
+ from lilith_agent import memory
73
+ import langmem
74
+
75
+ captured = {}
76
+
77
+ class FakeManager:
78
+ def invoke(self, payload):
79
+ captured["existing"] = payload["existing"]
80
+ return [
81
+ {"id": "memory-1", "content": "Existing fact"},
82
+ {"content": "New fact"},
83
+ ]
84
+
85
+ store = MemoryStore(tmp_path / "test.sqlite")
86
+ store.save_memories([
87
+ {"id": "memory-1", "content": "Existing fact"},
88
+ ])
89
+ original = store.get_all_memories()[0]
90
+ monkeypatch.setattr(memory, "_store", store)
91
+ monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
92
+
93
+ memory.extract_and_compress_facts(
94
+ [HumanMessage(content="Remember a new fact"), HumanMessage(content="New fact")],
95
+ _FakeEpisodeModel(),
96
+ )
97
+
98
+ facts_by_content = {fact["content"]: fact for fact in store.get_all_memories()}
99
+ existing_payload = captured["existing"][0]
100
+ assert existing_payload[0] == "memory-1"
101
+ assert getattr(existing_payload[1], "content") == "Existing fact"
102
+ assert facts_by_content["Existing fact"]["id"] == "memory-1"
103
+ assert facts_by_content["Existing fact"]["created_at"] == original["created_at"]
104
+ assert facts_by_content["New fact"]["id"] != "memory-1"
105
+
106
+ def test_langmem_remove_doc_deletes_fact_instead_of_persisting_marker(self, tmp_path, monkeypatch):
107
+ from lilith_agent import memory
108
+ import langmem
109
+
110
+ class RemoveDocLike:
111
+ def __repr_name__(self):
112
+ return "RemoveDoc"
113
+
114
+ class FakeExtractedMemory:
115
+ def __init__(self, id, content):
116
+ self.id = id
117
+ self.content = content
118
+
119
+ class FakeMemory:
120
+ content = "Keep fact"
121
+
122
+ class FakeManager:
123
+ def invoke(self, payload):
124
+ return [
125
+ FakeExtractedMemory("fact-1", RemoveDocLike()),
126
+ FakeExtractedMemory("fact-2", FakeMemory()),
127
+ ]
128
+
129
+ store = MemoryStore(tmp_path / "test.sqlite")
130
+ store.save_memories([
131
+ {"id": "fact-1", "content": "Delete fact"},
132
+ {"id": "fact-2", "content": "Keep fact"},
133
+ ])
134
+ monkeypatch.setattr(memory, "_store", store)
135
+ monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
136
+
137
+ memory.extract_and_compress_facts(
138
+ [HumanMessage(content="Forget delete fact"), HumanMessage(content="Keep fact")],
139
+ _FakeEpisodeModel(),
140
+ )
141
+
142
+ facts_by_id = {fact["id"]: fact for fact in store.get_all_memories()}
143
+ assert "fact-1" not in facts_by_id
144
+ assert facts_by_id["fact-2"]["content"] == "Keep fact"
145
+
146
+ def test_successful_fact_extraction_also_records_episode(self, tmp_path, monkeypatch):
147
+ from lilith_agent import memory
148
+ import langmem
149
+
150
+ class FakeManager:
151
+ def invoke(self, payload):
152
+ return [{"content": "New fact"}]
153
+
154
+ class FakeModel:
155
+ def invoke(self, prompt):
156
+ class Response:
157
+ content = "Successful lesson"
158
+
159
+ return Response()
160
+
161
+ store = MemoryStore(tmp_path / "test.sqlite")
162
+ monkeypatch.setattr(memory, "_store", store)
163
+ monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
164
+
165
+ memory.extract_and_compress_facts(
166
+ [HumanMessage(content="Remember useful fact"), HumanMessage(content="New fact")],
167
+ FakeModel(),
168
+ )
169
+
170
+ episodes = store.get_recent_episodes()
171
+ assert episodes[0]["summary"] == "Successful lesson"
172
+
173
+ def test_one_turn_exchange_is_eligible_for_memory_extraction(self):
174
+ assert MIN_MESSAGES_FOR_EXTRACTION <= 2
175
+
176
+
177
+ class TestForgetSafety:
178
+ def test_delete_memory_prefix_does_not_treat_sql_wildcards_as_patterns(self, tmp_path):
179
+ store = MemoryStore(tmp_path / "test.sqlite")
180
+ store.save_memories([
181
+ {"id": "abc-1", "content": "First fact"},
182
+ {"id": "def-1", "content": "Second fact"},
183
+ ])
184
+
185
+ assert store.delete_memory_prefix("%") == 0
186
+ assert len(store.get_all_memories()) == 2
187
+ assert store.delete_memory_prefix("abc") == 1
188
+ assert [fact["id"] for fact in store.get_all_memories()] == ["def-1"]
189
+
190
+
191
+ class TestSearchMemoryLimits:
192
+ def test_max_results_applies_to_combined_facts_and_episodes(self, tmp_path, monkeypatch):
193
+ from lilith_agent import memory
194
+
195
+ store = MemoryStore(tmp_path / "test.sqlite")
196
+ store.save_memories([
197
+ {"id": "fact-1", "content": "alpha fact one"},
198
+ {"id": "fact-2", "content": "alpha fact two"},
199
+ ])
200
+ store.add_episode("alpha task one", "alpha summary one", "success")
201
+ store.add_episode("alpha task two", "alpha summary two", "success")
202
+ monkeypatch.setattr(memory, "_store", store)
203
+
204
+ result = memory.search_memory_store("alpha", max_results=3)
205
+
206
+ result_count = result.count("[fact]") + result.count("[episode]")
207
+ assert result_count == 3