1313import json
1414import logging
1515import sqlite3
16+ import threading
1617from dataclasses import dataclass
1718from datetime import datetime , timezone
1819from pathlib import Path
@@ -31,38 +32,108 @@ class Fact(NamedTuple):
3132 access_count : int
3233
3334
35+ class Message (NamedTuple ):
36+ id : int
37+ role : str # 'user' | 'assistant'
38+ content : str
39+ session_id : int
40+ created_at : str
41+
42+
43+ # Schema is idempotent (IF NOT EXISTS everywhere) so every freshly-opened
44+ # connection can run it safely. That's what makes per-thread connections
45+ # work without coordination: each thread opens its own, runs the same DDL,
46+ # and gets the same view of the DB.
47+ _SCHEMA = """
48+ CREATE TABLE IF NOT EXISTS facts (
49+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50+ content TEXT NOT NULL,
51+ category TEXT DEFAULT 'general',
52+ created_at TEXT NOT NULL,
53+ last_accessed TEXT,
54+ access_count INTEGER DEFAULT 0
55+ );
56+ CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
57+ USING fts5(content, category, content='facts', content_rowid='id');
58+
59+ CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
60+ INSERT INTO facts_fts(rowid, content, category)
61+ VALUES (new.id, new.content, new.category);
62+ END;
63+ CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
64+ INSERT INTO facts_fts(facts_fts, rowid, content, category)
65+ VALUES ('delete', old.id, old.content, old.category);
66+ END;
67+
68+ CREATE TABLE IF NOT EXISTS messages (
69+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70+ role TEXT NOT NULL,
71+ content TEXT NOT NULL,
72+ session_id INTEGER DEFAULT 0,
73+ created_at TEXT NOT NULL
74+ );
75+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
76+ USING fts5(content, content='messages', content_rowid='id');
77+ CREATE INDEX IF NOT EXISTS idx_messages_session
78+ ON messages(session_id, created_at);
79+
80+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
81+ INSERT INTO messages_fts(rowid, content)
82+ VALUES (new.id, new.content);
83+ END;
84+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
85+ INSERT INTO messages_fts(messages_fts, rowid, content)
86+ VALUES ('delete', old.id, old.content);
87+ END;
88+ """
89+
90+
3491class Memory :
35- """Persistent fact store backed by SQLite FTS5."""
92+ """Persistent fact store backed by SQLite FTS5.
93+
94+ Two tables:
95+ facts — LLM-extracted semantic knowledge (the agent's "beliefs")
96+ messages — verbatim turn-by-turn log (the raw conversation)
97+
98+ Extracted facts are lossy but searchable by concept. Verbatim messages
99+ preserve nuance and exact phrasing for recall of specific moments.
100+ Recall from both paths is cheap (FTS5) and complementary.
101+
102+ Threading model
103+ ---------------
104+ One Memory instance is shared across threads; each thread that touches
105+ it opens its own `sqlite3.Connection` stored in a `threading.local`.
106+ This matches how the stdlib `sqlite3` module is designed to be used
107+ from multi-threaded code: share the database, not the connection.
108+
109+ SQLite's WAL mode lets readers proceed in parallel with a writer and
110+ serializes writers at the file layer, so no Python-level lock is
111+ needed — the correctness guarantee comes from SQLite itself.
112+ """
36113
37114 def __init__ (self , db_path : Path | None = None ):
38115 p = db_path or DEFAULT_DB_PATH
39116 p .parent .mkdir (parents = True , exist_ok = True )
40- self ._conn = sqlite3 .connect (str (p ))
41- self ._conn .execute ("PRAGMA journal_mode=WAL" )
42- self ._init_schema ()
43-
44- def _init_schema (self ) -> None :
45- self ._conn .executescript ("""
46- CREATE TABLE IF NOT EXISTS facts (
47- id INTEGER PRIMARY KEY AUTOINCREMENT,
48- content TEXT NOT NULL,
49- category TEXT DEFAULT 'general',
50- created_at TEXT NOT NULL,
51- last_accessed TEXT,
52- access_count INTEGER DEFAULT 0
53- );
54- CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
55- USING fts5(content, category, content='facts', content_rowid='id');
56-
57- CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
58- INSERT INTO facts_fts(rowid, content, category)
59- VALUES (new.id, new.content, new.category);
60- END;
61- CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
62- INSERT INTO facts_fts(facts_fts, rowid, content, category)
63- VALUES ('delete', old.id, old.content, old.category);
64- END;
65- """ )
117+ self ._path = str (p )
118+ self ._local = threading .local ()
119+ # Eager open: if the path is unwritable or the schema fails, surface
120+ # the error at construction time rather than on the first operation.
121+ self ._connect ()
122+
123+ def _connect (self ) -> sqlite3 .Connection :
124+ """Return the current thread's connection, opening it on first use."""
125+ conn = getattr (self ._local , "conn" , None )
126+ if conn is None :
127+ conn = sqlite3 .connect (self ._path )
128+ conn .execute ("PRAGMA journal_mode=WAL" )
129+ conn .executescript (_SCHEMA )
130+ self ._local .conn = conn
131+ return conn
132+
133+ @property
134+ def _conn (self ) -> sqlite3 .Connection :
135+ """Thread-local connection — lazy-created per thread, never shared."""
136+ return self ._connect ()
66137
67138 def save (self , content : str , category : str = "general" ) -> int :
68139 """Store a fact. Returns its id."""
@@ -111,6 +182,59 @@ def count(self) -> int:
111182 row = self ._conn .execute ("SELECT COUNT(*) FROM facts" ).fetchone ()
112183 return row [0 ] if row else 0
113184
185+ # ── Verbatim messages ─────────────────────────────────────
186+
187+ def log_message (self , role : str , content : str , session_id : int = 0 ) -> int :
188+ """Store a verbatim turn. Returns its id."""
189+ if not content :
190+ return 0
191+ now = datetime .now (timezone .utc ).isoformat ()
192+ cur = self ._conn .execute (
193+ "INSERT INTO messages (role, content, session_id, created_at) "
194+ "VALUES (?, ?, ?, ?)" ,
195+ (role , content , session_id , now ),
196+ )
197+ self ._conn .commit ()
198+ return cur .lastrowid # type: ignore[return-value]
199+
200+ def search_messages (self , query : str , limit : int = 5 ) -> list [Message ]:
201+ """FTS5 search across verbatim messages, ranked by relevance."""
202+ if not query .strip ():
203+ return []
204+ rows = self ._conn .execute (
205+ """
206+ SELECT m.id, m.role, m.content, m.session_id, m.created_at
207+ FROM messages_fts
208+ JOIN messages m ON m.id = messages_fts.rowid
209+ WHERE messages_fts MATCH ?
210+ ORDER BY messages_fts.rank
211+ LIMIT ?
212+ """ ,
213+ (query , limit ),
214+ ).fetchall ()
215+ return [Message (* r ) for r in rows ]
216+
217+ def recent_messages (self , session_id : int = 0 , limit : int = 20 ) -> list [Message ]:
218+ """Latest messages for a session (or all sessions if session_id=0)."""
219+ if session_id :
220+ rows = self ._conn .execute (
221+ "SELECT id, role, content, session_id, created_at "
222+ "FROM messages WHERE session_id = ? "
223+ "ORDER BY created_at DESC LIMIT ?" ,
224+ (session_id , limit ),
225+ ).fetchall ()
226+ else :
227+ rows = self ._conn .execute (
228+ "SELECT id, role, content, session_id, created_at "
229+ "FROM messages ORDER BY created_at DESC LIMIT ?" ,
230+ (limit ,),
231+ ).fetchall ()
232+ return [Message (* r ) for r in rows ]
233+
234+ def message_count (self ) -> int :
235+ row = self ._conn .execute ("SELECT COUNT(*) FROM messages" ).fetchone ()
236+ return row [0 ] if row else 0
237+
114238 def prune (self , max_age_days : int = 90 , min_access : int = 0 ) -> int :
115239 """Remove stale facts. Returns count of deleted rows.
116240
@@ -134,7 +258,17 @@ def prune(self, max_age_days: int = 90, min_access: int = 0) -> int:
134258 return deleted
135259
136260 def close (self ) -> None :
137- self ._conn .close ()
261+ """Close the current thread's connection. Best-effort cleanup.
262+
263+ Connections held by other threads are reclaimed when those threads
264+ exit or when the garbage collector runs over their thread-locals.
265+ Under WAL every `commit()` is durable, so this cannot cause data
266+ loss — it only releases the current thread's file handle.
267+ """
268+ conn = getattr (self ._local , "conn" , None )
269+ if conn is not None :
270+ conn .close ()
271+ self ._local .conn = None
138272
139273
140274# ── Core Memory (MemGPT-inspired) ──────────────────────────────
0 commit comments