mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
723cad24bb
* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting Replace flat key-value memories table with structured_memories (migration 014). Four memory types (user/project/feedback/reference), three scopes (global/workstream/user). Consolidate remember/recall/forget into two tools: memory (action-based: save/search/delete/list) and recall (conversation history only). BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5 memories for system message injection based on conversation context. Metacognitive prompting injects ephemeral nudges after corrections, tool denials, workstream resume, and completion signals. Scope isolation enforced: system message injection and nudge counts filtered to visible memories only (global + current workstream + authenticated user). User scope requires authentication. Content capped at 32KB. ILIKE/LIKE metacharacters escaped in both backends. 113 new tests (2053 total). * fix: CI failure + copilot review feedback - Fix time.monotonic() cooldown: use None sentinel instead of 0.0 default (monotonic clock starts at boot, not epoch — fresh CI runners have uptime < 300s so cooldown check always triggered) - Catch sa.exc.IntegrityError specifically in upsert instead of broad Exception (copilot review) - Preserve existing description/type on upsert when caller doesn't explicitly set them (copilot review) - Add last_accessed + access_count columns to schema/migration for future LRU/LFU eviction support
81 lines
2.5 KiB
Python
81 lines
2.5 KiB
Python
"""Tests for turnstone.core.memory — database operations."""
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.memory import (
|
|
normalize_key,
|
|
save_message,
|
|
search_history,
|
|
search_history_recent,
|
|
)
|
|
from turnstone.core.storage import get_storage
|
|
|
|
|
|
class TestSchemaCreation:
|
|
def test_creates_tables(self, tmp_db):
|
|
engine = get_storage()._engine # noqa: SLF001
|
|
with engine.connect() as conn:
|
|
rows = conn.execute(
|
|
sa.text(
|
|
"SELECT name FROM sqlite_master "
|
|
"WHERE type='table' AND name='structured_memories'"
|
|
)
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
rows = conn.execute(
|
|
sa.text(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'"
|
|
)
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
|
|
|
|
class TestSaveAndSearchHistory:
|
|
def test_save_and_search_roundtrip(self, tmp_db):
|
|
save_message("sess1", "user", "hello world test message")
|
|
results = search_history("hello")
|
|
assert len(results) >= 1
|
|
found = any(r[3] == "hello world test message" for r in results)
|
|
assert found
|
|
|
|
def test_search_empty_query_returns_empty(self, tmp_db):
|
|
save_message("sess1", "user", "something")
|
|
assert search_history("") == []
|
|
assert search_history(" ") == []
|
|
|
|
def test_search_no_match(self, tmp_db):
|
|
save_message("sess1", "user", "hello world")
|
|
results = search_history("zzzznotfound")
|
|
assert results == []
|
|
|
|
|
|
class TestSearchHistoryRecent:
|
|
def test_returns_recent_messages(self, tmp_db):
|
|
save_message("sess1", "user", "first message")
|
|
save_message("sess1", "assistant", "second message")
|
|
results = search_history_recent(limit=10)
|
|
assert len(results) == 2
|
|
|
|
def test_respects_limit(self, tmp_db):
|
|
for i in range(5):
|
|
save_message("sess1", "user", f"message {i}")
|
|
results = search_history_recent(limit=3)
|
|
assert len(results) == 3
|
|
|
|
|
|
class TestNormalizeKey:
|
|
def test_lowercase(self):
|
|
assert normalize_key("Hello") == "hello"
|
|
|
|
def test_hyphens_to_underscores(self):
|
|
assert normalize_key("my-key") == "my_key"
|
|
|
|
def test_spaces_to_underscores(self):
|
|
assert normalize_key("my key") == "my_key"
|
|
|
|
def test_combined(self):
|
|
assert normalize_key("My-Key Name") == "my_key_name"
|
|
|
|
def test_already_normalized(self):
|
|
assert normalize_key("my_key") == "my_key"
|