-
Notifications
You must be signed in to change notification settings - Fork 0
feat(memory): add durable memory store #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """Test fake for curated memory storage.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field, replace | ||
|
|
||
| from forge_loop.memory.models import REJECTED_PATH_TAG, MemoryItem, MemoryKind | ||
|
|
||
|
|
||
| @dataclass | ||
| class FakeMemoryStore: | ||
| items: dict[str, MemoryItem] = field(default_factory=dict) | ||
|
|
||
| def put(self, item: MemoryItem) -> MemoryItem: | ||
| self.items[item.memory_id] = item | ||
| return item | ||
|
|
||
| def get(self, memory_id: str) -> MemoryItem | None: | ||
| return self.items.get(memory_id) | ||
|
|
||
| def list_active(self, *, kind: MemoryKind | None = None) -> tuple[MemoryItem, ...]: | ||
| return tuple( | ||
| item | ||
| for item in self.items.values() | ||
| if item.is_active and (kind is None or item.kind is kind) | ||
| ) | ||
|
|
||
| def list_rejected_paths(self) -> tuple[MemoryItem, ...]: | ||
| return tuple(item for item in self.list_active() if REJECTED_PATH_TAG in item.tags) | ||
|
|
||
| def supersede(self, memory_id: str, *, by_memory_id: str) -> MemoryItem: | ||
| if by_memory_id not in self.items: | ||
| raise KeyError(by_memory_id) | ||
| item = self.items.get(memory_id) | ||
| if item is None: | ||
| raise KeyError(memory_id) | ||
| superseded = replace(item, superseded_by=by_memory_id) | ||
| self.items[memory_id] = superseded | ||
| return superseded |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,18 @@ | ||
| """Curated long-term project memory contracts.""" | ||
|
|
||
| from forge_loop.memory.models import MemoryItem, MemoryKind, MemoryProvenance | ||
| from forge_loop.memory.models import ( | ||
| REJECTED_PATH_TAG, | ||
| MemoryItem, | ||
| MemoryKind, | ||
| MemoryProvenance, | ||
| ) | ||
| from forge_loop.memory.store import MemoryStore, SqliteMemoryStore | ||
|
|
||
| __all__ = ["MemoryItem", "MemoryKind", "MemoryProvenance"] | ||
| __all__ = [ | ||
| "REJECTED_PATH_TAG", | ||
| "MemoryItem", | ||
| "MemoryKind", | ||
| "MemoryProvenance", | ||
| "MemoryStore", | ||
| "SqliteMemoryStore", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| """Durable store for curated project memory.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import sqlite3 | ||
| from collections.abc import Iterable | ||
| from datetime import datetime | ||
| from pathlib import Path | ||
| from typing import Protocol | ||
|
|
||
| from forge_loop.eventlog.models import EventId, EventRef | ||
| from forge_loop.memory.models import ( | ||
| REJECTED_PATH_TAG, | ||
| MemoryItem, | ||
| MemoryKind, | ||
| MemoryProvenance, | ||
| ) | ||
|
|
||
| _SCHEMA = """ | ||
| CREATE TABLE IF NOT EXISTS memory_items ( | ||
| memory_id TEXT PRIMARY KEY, | ||
| kind TEXT NOT NULL, | ||
| title TEXT NOT NULL, | ||
| body TEXT NOT NULL, | ||
| tags_json TEXT NOT NULL, | ||
| source_event_id TEXT, | ||
| source_sequence INTEGER, | ||
| source_task_ref TEXT, | ||
| authored_by TEXT NOT NULL, | ||
| confidence REAL NOT NULL, | ||
| created_at TEXT NOT NULL, | ||
| supersedes_json TEXT NOT NULL, | ||
| evidence_refs_json TEXT NOT NULL, | ||
| superseded_by TEXT | ||
| ); | ||
| """ | ||
|
|
||
|
|
||
| class MemoryStore(Protocol): | ||
| """Persistence boundary for curated memory.""" | ||
|
|
||
| def put(self, item: MemoryItem) -> MemoryItem: | ||
| """Persist ``item`` and return the stored shape.""" | ||
| ... | ||
|
|
||
| def get(self, memory_id: str) -> MemoryItem | None: | ||
| """Return one memory item, including superseded items.""" | ||
| ... | ||
|
|
||
| def list_active(self, *, kind: MemoryKind | None = None) -> tuple[MemoryItem, ...]: | ||
| """Return non-superseded memory items in insertion order.""" | ||
| ... | ||
|
|
||
| def list_rejected_paths(self) -> tuple[MemoryItem, ...]: | ||
| """Return active memory items tagged as rejected paths.""" | ||
| ... | ||
|
|
||
| def supersede(self, memory_id: str, *, by_memory_id: str) -> MemoryItem: | ||
| """Mark ``memory_id`` as superseded by ``by_memory_id``.""" | ||
| ... | ||
|
|
||
|
|
||
| class SqliteMemoryStore: | ||
| """SQLite-backed durable memory store.""" | ||
|
|
||
| def __init__(self, path: str | Path) -> None: | ||
| self.path = Path(path) | ||
| connect_path: str | Path = ":memory:" if str(path) == ":memory:" else self.path | ||
| if str(path) != ":memory:": | ||
| self.path.parent.mkdir(parents=True, exist_ok=True) | ||
| self._connection = sqlite3.connect(connect_path) | ||
| self._connection.row_factory = sqlite3.Row | ||
| if str(path) != ":memory:": | ||
| self._connection.execute("PRAGMA journal_mode=WAL") | ||
| self._connection.executescript(_SCHEMA) | ||
|
|
||
| def put(self, item: MemoryItem) -> MemoryItem: | ||
| source_event_id = None | ||
| source_sequence = None | ||
| if item.provenance.source_event is not None: | ||
| source_event_id = str(item.provenance.source_event.event_id) | ||
| source_sequence = item.provenance.source_event.sequence | ||
|
|
||
| with self._connection: | ||
| self._connection.execute( | ||
| """ | ||
| INSERT INTO memory_items ( | ||
| memory_id, | ||
| kind, | ||
| title, | ||
| body, | ||
| tags_json, | ||
| source_event_id, | ||
| source_sequence, | ||
| source_task_ref, | ||
| authored_by, | ||
| confidence, | ||
| created_at, | ||
| supersedes_json, | ||
| evidence_refs_json, | ||
| superseded_by | ||
| ) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| ON CONFLICT(memory_id) | ||
| DO UPDATE SET | ||
| kind = excluded.kind, | ||
| title = excluded.title, | ||
| body = excluded.body, | ||
| tags_json = excluded.tags_json, | ||
| source_event_id = excluded.source_event_id, | ||
| source_sequence = excluded.source_sequence, | ||
| source_task_ref = excluded.source_task_ref, | ||
| authored_by = excluded.authored_by, | ||
| confidence = excluded.confidence, | ||
| created_at = excluded.created_at, | ||
| supersedes_json = excluded.supersedes_json, | ||
| evidence_refs_json = excluded.evidence_refs_json, | ||
| superseded_by = excluded.superseded_by | ||
| """, | ||
| ( | ||
| item.memory_id, | ||
| item.kind.value, | ||
| item.title, | ||
| item.body, | ||
| _json_tuple(item.tags), | ||
| source_event_id, | ||
| source_sequence, | ||
| item.provenance.source_task_ref, | ||
| item.provenance.authored_by, | ||
| item.provenance.confidence, | ||
| item.provenance.created_at.isoformat(), | ||
| _json_tuple(item.provenance.supersedes), | ||
| _json_tuple(item.provenance.evidence_refs), | ||
| item.superseded_by, | ||
| ), | ||
| ) | ||
| return item | ||
|
|
||
| def get(self, memory_id: str) -> MemoryItem | None: | ||
| row = self._connection.execute( | ||
| """ | ||
| SELECT * | ||
| FROM memory_items | ||
| WHERE memory_id = ? | ||
| """, | ||
| (memory_id,), | ||
| ).fetchone() | ||
| if row is None: | ||
| return None | ||
| return _item_from_row(row) | ||
|
|
||
| def list_active(self, *, kind: MemoryKind | None = None) -> tuple[MemoryItem, ...]: | ||
| params: tuple[str, ...] | ||
| where = "WHERE superseded_by IS NULL" | ||
| if kind is None: | ||
| params = () | ||
| else: | ||
| where += " AND kind = ?" | ||
| params = (kind.value,) | ||
| return tuple(self._select(f"SELECT * FROM memory_items {where} ORDER BY rowid ASC", params)) | ||
|
|
||
| def list_rejected_paths(self) -> tuple[MemoryItem, ...]: | ||
| return tuple(item for item in self.list_active() if REJECTED_PATH_TAG in item.tags) | ||
|
|
||
| def supersede(self, memory_id: str, *, by_memory_id: str) -> MemoryItem: | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [sev2/tests] SqliteMemoryStore.supersede adds a public failure path for missing memory IDs, but the new tests only cover successful supersession. Add an adversarial test that asserts supersede('missing', by_memory_id=...) raises KeyError for the real store and fake, so this public error contract is locked down. |
||
| if self.get(by_memory_id) is None: | ||
| raise KeyError(by_memory_id) | ||
| with self._connection: | ||
| cursor = self._connection.execute( | ||
| """ | ||
| UPDATE memory_items | ||
| SET superseded_by = ? | ||
| WHERE memory_id = ? | ||
| """, | ||
| (by_memory_id, memory_id), | ||
| ) | ||
| if cursor.rowcount != 1: | ||
| raise KeyError(memory_id) | ||
| superseded = self.get(memory_id) | ||
| if superseded is None: | ||
| raise KeyError(memory_id) | ||
| return superseded | ||
|
|
||
| def _select(self, query: str, params: tuple[str, ...]) -> Iterable[MemoryItem]: | ||
| rows = self._connection.execute(query, params) | ||
| return (_item_from_row(row) for row in rows) | ||
|
|
||
|
|
||
| def _json_tuple(values: tuple[str, ...]) -> str: | ||
| return json.dumps(list(values), sort_keys=True, separators=(",", ":")) | ||
|
|
||
|
|
||
| def _load_tuple(raw: str, field_name: str) -> tuple[str, ...]: | ||
| values = json.loads(raw) | ||
| if not isinstance(values, list) or not all(isinstance(value, str) for value in values): | ||
| raise ValueError(f"memory field {field_name} must be a JSON list of strings") | ||
| return tuple(values) | ||
|
|
||
|
|
||
| def _item_from_row(row: sqlite3.Row) -> MemoryItem: | ||
| source_event = None | ||
| if row["source_event_id"] is not None and row["source_sequence"] is not None: | ||
| source_event = EventRef( | ||
| event_id=EventId(row["source_event_id"]), | ||
| sequence=row["source_sequence"], | ||
| ) | ||
| return MemoryItem( | ||
| memory_id=row["memory_id"], | ||
| kind=MemoryKind(row["kind"]), | ||
| title=row["title"], | ||
| body=row["body"], | ||
| tags=_load_tuple(row["tags_json"], "tags"), | ||
| provenance=MemoryProvenance( | ||
| source_event=source_event, | ||
| authored_by=row["authored_by"], | ||
| source_task_ref=row["source_task_ref"], | ||
| confidence=row["confidence"], | ||
| created_at=datetime.fromisoformat(row["created_at"]), | ||
| supersedes=_load_tuple(row["supersedes_json"], "supersedes"), | ||
| evidence_refs=_load_tuple(row["evidence_refs_json"], "evidence_refs"), | ||
| ), | ||
| superseded_by=row["superseded_by"], | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[sev2/product] The PR exports a new MemoryStore protocol and adds a FakeMemoryStore contract surface, but the repo has no downstream consumer wired to the store or protocol. Under the no-scaffold-theatre rule, wire this into the maestro/boot memory-loading path in this PR, or quarantine/remove the plug-in surface behind an experimental extra until it has an in-repo consumer.