summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbatsumaru <>2026-09-15 14:25:49 +0900
committerbatsumaru <>2026-09-15 14:25:49 +0900
commit5629cabbb38c7d91e66a419ba7cb3a93a9a988a1 (patch)
treef72fc35376dc9bce014bc515ea7672c85782ed14
parent606e264c9c6ba99ae0c04a8463d37d85568f0c86 (diff)
Added long-term memory
-rw-r--r--agent.toml6
-rw-r--r--database/__init__.py0
-rw-r--r--database/database.py73
-rw-r--r--database/migrations/001_create_memories.sql6
-rw-r--r--llm.py3
-rw-r--r--memory.py21
-rw-r--r--memory_store.py99
-rw-r--r--pyproject.toml4
-rw-r--r--tests/test_database.py89
-rw-r--r--tests/test_memory_store.py213
-rw-r--r--tools.py10
11 files changed, 519 insertions, 5 deletions
diff --git a/agent.toml b/agent.toml
index 7a3cfec..fac365a 100644
--- a/agent.toml
+++ b/agent.toml
@@ -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
+);
diff --git a/llm.py b/llm.py
index afd890c..16c8b82 100644
--- a/llm.py
+++ b/llm.py
@@ -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")
diff --git a/memory.py b/memory.py
index 6ba3368..aab2064 100644
--- a/memory.py
+++ b/memory.py
@@ -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]
diff --git a/tools.py b/tools.py
index 78299b8..ee5e63c 100644
--- a/tools.py
+++ b/tools.py
@@ -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.'"
),
},
},