diff options
| author | batsumaru <> | 2026-09-15 14:25:49 +0900 |
|---|---|---|
| committer | batsumaru <> | 2026-09-15 14:25:49 +0900 |
| commit | 5629cabbb38c7d91e66a419ba7cb3a93a9a988a1 (patch) | |
| tree | f72fc35376dc9bce014bc515ea7672c85782ed14 | |
| parent | 606e264c9c6ba99ae0c04a8463d37d85568f0c86 (diff) | |
Added long-term memory
| -rw-r--r-- | agent.toml | 6 | ||||
| -rw-r--r-- | database/__init__.py | 0 | ||||
| -rw-r--r-- | database/database.py | 73 | ||||
| -rw-r--r-- | database/migrations/001_create_memories.sql | 6 | ||||
| -rw-r--r-- | llm.py | 3 | ||||
| -rw-r--r-- | memory.py | 21 | ||||
| -rw-r--r-- | memory_store.py | 99 | ||||
| -rw-r--r-- | pyproject.toml | 4 | ||||
| -rw-r--r-- | tests/test_database.py | 89 | ||||
| -rw-r--r-- | tests/test_memory_store.py | 213 | ||||
| -rw-r--r-- | tools.py | 10 |
11 files changed, 519 insertions, 5 deletions
@@ -14,6 +14,12 @@ favorite_color = "purple and orange" task_management = """ Determine whether the user is asking you to pursue an ongoing objective. +The task objective should describe the durable goal being pursued, not +specific implementation requirements, preferences, or constraints that may +change during the task. When deciding what to pass to task_start, summarize +the underlying goal and omit mutable details unless they are essential to +defining the goal itself. + Start a task with the task_start tool when: - the user explicitly asks you to work on a project, activity, or goal over multiple interactions; diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/database/__init__.py diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..7119c7e --- /dev/null +++ b/database/database.py @@ -0,0 +1,73 @@ +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + self.migrations_dir = Path(__file__).parent / "migrations" + + def connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.path) + + def migrate(self) -> None: + with self.connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ) + """ + ) + + applied_versions = { + row[0] + for row in connection.execute("SELECT version FROM schema_migrations") + } + + for migration_version, path in self._get_migrations(): + if migration_version in applied_versions: + continue + + sql = path.read_text(encoding="utf-8") + + connection.execute("BEGIN") + + try: + connection.execute(sql) + + applied_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") + + connection.execute( + """ + INSERT INTO schema_migrations (version, applied_at) + VALUES (?, ?) + """, + (migration_version, applied_at), + ) + + connection.commit() + except Exception: + connection.rollback() + raise + + def _get_migrations(self) -> list[tuple[int, Path]]: + migrations = [] + + for path in self.migrations_dir.glob("*.sql"): + version_text, _, _ = path.stem.partition("_") + + if not version_text.isdigit(): + continue + + migrations.append((int(version_text), path)) + + migrations.sort() + + versions = [version for version, _ in migrations] + if len(versions) != len(set(versions)): + raise ValueError("Duplicate migration version") + + return migrations diff --git a/database/migrations/001_create_memories.sql b/database/migrations/001_create_memories.sql new file mode 100644 index 0000000..e79c907 --- /dev/null +++ b/database/migrations/001_create_memories.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + category TEXT NOT NULL, + created_at TEXT NOT NULL +); @@ -30,7 +30,7 @@ class LLM: payload: dict[str, object] = { "messages": messages, "max_tokens": max_tokens, - "temperature": 0.7, + "temperature": 0.9, } if tools is not None: @@ -43,6 +43,7 @@ class LLM: response.raise_for_status() data = response.json() + print(f"\n[debug: {data}]\n") message = data["choices"][0]["message"] content = message.get("content") @@ -1,5 +1,26 @@ from dataclasses import dataclass, field +from datetime import UTC, datetime from enum import Enum +from uuid import UUID, uuid7 + + +class MemoryCategory(Enum): + SEMANTIC = "semantic" + EPISODIC = "episodic" + + +@dataclass +class LongTermMemory: + id: UUID + content: str + category: MemoryCategory + created_at: datetime + + @classmethod + def create(cls, content: str, category: MemoryCategory) -> LongTermMemory: + return cls( + id=uuid7(), content=content, category=category, created_at=datetime.now(UTC) + ) class TaskStatus(Enum): diff --git a/memory_store.py b/memory_store.py new file mode 100644 index 0000000..2c69113 --- /dev/null +++ b/memory_store.py @@ -0,0 +1,99 @@ +from datetime import datetime +from uuid import UUID + +from database.database import Database +from memory import LongTermMemory, MemoryCategory + + +class LongTermMemoryStore: + def __init__(self, database: Database) -> None: + self.database = database + + def add(self, memory: LongTermMemory) -> None: + created_at = memory.created_at.isoformat().replace("+00:00", "Z") + + with self.database.connect() as connection: + connection.execute( + """ + INSERT INTO memories (id, content, category, created_at) + VALUES (?, ?, ?, ?) + """, + (str(memory.id), memory.content, memory.category.value, created_at), + ) + + def get(self, memory_id: UUID) -> LongTermMemory | None: + with self.database.connect() as connection: + row = connection.execute( + """ + SELECT id, content, category, created_at + FROM memories + WHERE id = ? + """, + (str(memory_id),), + ).fetchone() + + if row is None: + return None + + return self._from_row(row) + + def list(self) -> list[LongTermMemory]: + with self.database.connect() as connection: + rows = connection.execute( + """ + SELECT id, content, category, created_at + FROM memories + ORDER BY id + """ + ).fetchall() + + return [self._from_row(row) for row in rows] + + def delete(self, memory_id: UUID) -> None: + with self.database.connect() as connection: + connection.execute( + "DELETE FROM memories WHERE id = ?", + (str(memory_id),), + ) + + def search( + self, + query: str, + category: MemoryCategory | None = None, + limit: int = 10, + ) -> list[LongTermMemory]: + if limit <= 0: + raise ValueError("Search limit must be positive") + + if not query.strip(): + raise ValueError("Search query must not be empty") + + sql = """ + SELECT id, content, category, created_at + FROM memories + WHERE content LIKE ? + """ + parameters: list[str | int] = [f"%{query}%"] + + if category is not None: + sql += " AND category = ?" + parameters.append(category.value) + + sql += " ORDER BY id LIMIT ?" + parameters.append(limit) + + with self.database.connect() as connection: + rows = connection.execute(sql, parameters).fetchall() + + return [self._from_row(row) for row in rows] + + @staticmethod + def _from_row(row: tuple[str, str, str, str]) -> LongTermMemory: + memory_id, content, category, created_at = row + + return LongTermMemory( + id=UUID(memory_id), + content=content, + category=MemoryCategory(category), + created_at=datetime.fromisoformat(created_at), + ) diff --git a/pyproject.toml b/pyproject.toml index b32d935..c721343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.0.1" requires-python = ">=3.14" dependencies = [ "dataclasses>=0.8", + "pytest>=9.1.1", "requests>=2.34.2", ] @@ -12,3 +13,6 @@ typeCheckingMode = "standard" [tool.ruff.lint] extend-select = ["I"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..e33fdae --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,89 @@ +import sqlite3 + +import pytest + +from database.database import Database + + +def test_migrate_creates_schema(tmp_path): + database = Database(tmp_path / "test.db") + + database.migrate() + + with database.connect() as connection: + tables = { + row[0] + for row in connection.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + """ + ) + } + + assert "schema_migrations" in tables + assert "memories" in tables + + +def test_migrate_records_version(tmp_path): + database = Database(tmp_path / "test.db") + + database.migrate() + + with database.connect() as connection: + rows = connection.execute("SELECT version FROM schema_migrations").fetchall() + + assert rows == [(1,)] + + +def test_migrate_is_idempotent(tmp_path): + database = Database(tmp_path / "test.db") + + database.migrate() + database.migrate() + + with database.connect() as connection: + rows = connection.execute("SELECT version FROM schema_migrations").fetchall() + + assert rows == [(1,)] + + +def test_failed_migration_rolls_back(tmp_path): + database = Database(tmp_path / "test.db") + + migration = database.migrations_dir / "002_failing_migration.sql" + migration.write_text( + """ + THIS IS INVALID SQL; + """, + encoding="utf-8", + ) + try: + with pytest.raises(sqlite3.OperationalError): + database.migrate() + + with database.connect() as connection: + migrations = connection.execute( + "SELECT version FROM schema_migrations" + ).fetchall() + + assert migrations == [(1,)] + finally: + migration.unlink() + + +def test_duplicate_migration_versions_are_rejected(tmp_path): + database = Database(tmp_path / "test.db") + + duplicate = database.migrations_dir / "001_duplicate.sql" + duplicate.write_text( + "CREATE TABLE duplicate_table (id INTEGER PRIMARY KEY);", + encoding="utf-8", + ) + + try: + with pytest.raises(ValueError, match="Duplicate migration version"): + database.migrate() + finally: + duplicate.unlink() diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py new file mode 100644 index 0000000..f4862cb --- /dev/null +++ b/tests/test_memory_store.py @@ -0,0 +1,213 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid7 + +from database.database import Database +from memory import LongTermMemory, MemoryCategory +from memory_store import LongTermMemoryStore + + +def create_store(tmp_path): + database = Database(tmp_path / "test.db") + database.migrate() + + return LongTermMemoryStore(database) + + +def test_get_returns_memory(tmp_path): + store = create_store(tmp_path) + + memory = LongTermMemory.create( + "The user prefers direct technical explanations.", + MemoryCategory.SEMANTIC, + ) + + store.add(memory) + + assert store.get(memory.id) == memory + + +def test_created_memory_has_uuid7_id(): + memory = LongTermMemory.create( + "UUIDv7 teset", + MemoryCategory.SEMANTIC, + ) + + assert memory.id.version == 7 + + +def test_created_memories_have_unique_ids(): + first = LongTermMemory.create( + "First memory.", + MemoryCategory.SEMANTIC, + ) + second = LongTermMemory.create( + "Second memory.", + MemoryCategory.SEMANTIC, + ) + + assert first.id != second.id + + +def test_get_preserves_memory_id(tmp_path): + store = create_store(tmp_path) + + memory = LongTermMemory.create( + "ID round-trip test.", + MemoryCategory.SEMANTIC, + ) + + store.add(memory) + + result = store.get(memory.id) + + assert result is not None + assert result.id == memory.id + + +def test_get_missing_memory_returns_none(tmp_path): + store = create_store(tmp_path) + + assert store.get(UUID("00000000-0000-7a3f-9c2e-1b4d8f6a0e37")) is None + + +def test_list_returns_memories(tmp_path): + store = create_store(tmp_path) + + first = LongTermMemory.create( + "The user likes systems programming.", + MemoryCategory.SEMANTIC, + ) + second = LongTermMemory.create( + "The user completed Phase 7.1a.", + MemoryCategory.EPISODIC, + ) + + store.add(first) + store.add(second) + + assert store.list() == [first, second] + + +def test_delete_removes_memory(tmp_path): + store = create_store(tmp_path) + + memory = LongTermMemory.create( + "Temporary memory.", + MemoryCategory.EPISODIC, + ) + + store.add(memory) + + store.delete(memory.id) + + assert store.get(memory.id) is None + + +def test_created_at_round_trips_as_utc(tmp_path): + store = create_store(tmp_path) + + created_at = datetime( + 2026, + 9, + 15, + 12, + 34, + 56, + 123456, + tzinfo=UTC, + ) + + memory = LongTermMemory( + id=uuid7(), + content="UTC timestamp test.", + category=MemoryCategory.SEMANTIC, + created_at=created_at, + ) + + store.add(memory) + result = store.get(memory.id) + + assert result is not None + assert result.created_at == created_at + assert result.created_at.tzinfo is not None + assert result.created_at.utcoffset() == UTC.utcoffset(None) + + +def test_search_matches_content(tmp_path): + store = create_store(tmp_path) + + first = LongTermMemory.create( + "The user prefers direct technical explanations.", + MemoryCategory.SEMANTIC, + ) + second = LongTermMemory.create( + "The user is working on a local personal agent.", + MemoryCategory.SEMANTIC, + ) + + store.add(first) + store.add(second) + + assert store.search("technical") == [first] + + +def test_search_returns_empty_list_when_no_memory_matches(tmp_path): + store = create_store(tmp_path) + + memory = LongTermMemory.create( + "The user likes systems programming.", + MemoryCategory.SEMANTIC, + ) + + store.add(memory) + + assert store.search("quantum physics") == [] + + +def test_search_is_case_insensitive(tmp_path): + store = create_store(tmp_path) + + memory = LongTermMemory.create( + "The user prefers Rust.", + MemoryCategory.SEMANTIC, + ) + + store.add(memory) + + assert store.search("RUST") == [memory] + + +def test_search_can_filter_by_category(tmp_path): + store = create_store(tmp_path) + + semantic = LongTermMemory.create( + "The user likes programming.", + MemoryCategory.SEMANTIC, + ) + episodic = LongTermMemory.create( + "The user discussed programming yesterday.", + MemoryCategory.EPISODIC, + ) + + store.add(semantic) + store.add(episodic) + + assert store.search("programming", MemoryCategory.SEMANTIC) == [semantic] + assert store.search("programming", MemoryCategory.EPISODIC) == [episodic] + + +def test_search_respects_limit(tmp_path): + store = create_store(tmp_path) + + memories = [ + LongTermMemory.create( + f"Programming memory {index}.", + MemoryCategory.SEMANTIC, + ) + for index in range(3) + ] + + for memory in memories: + store.add(memory) + + assert store.search("programming", limit=2) == memories[:2] @@ -86,10 +86,12 @@ class TaskStartTool(Tool): "objective": { "type": "string", "description": ( - "A concise description of the ongoing objective the user wants " - "to pursue. For example: 'Build an image tagging API' or " - "'Design and implement a Rust CLI that converts directory trees " - "into static HTML.'" + "A concise description of the durable goal the user wants to pursue. " + "Describe what they are trying to accomplish, not mutable implementation " + "details, preferences, or constraints. For example: " + "'Build an image tagging API' or " + "'Design and implement a Rust CLI that converts directory trees into " + "static HTML.'" ), }, }, |
