snipara-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ """Public package exports for snipara-memory."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .adapters import InMemoryMemoryStore, JsonFileMemoryStore, get_default_store_path
6
+ from .api import create_app
7
+ from .domain import (
8
+ CompactionResult,
9
+ Contradiction,
10
+ ContradictionResolution,
11
+ ContradictionStatus,
12
+ GraveyardEntry,
13
+ GraveyardReason,
14
+ Memory,
15
+ MemoryScope,
16
+ MemoryService,
17
+ MemoryStatus,
18
+ MemoryTier,
19
+ MemoryType,
20
+ Namespace,
21
+ NamespaceType,
22
+ RecallMatch,
23
+ RecallQuery,
24
+ ResolveContradictionRequest,
25
+ SessionMemoryBundle,
26
+ StoreMemoryRequest,
27
+ calculate_confidence_decay,
28
+ classify_memory_tier,
29
+ )
30
+ from .ports import CacheStore, EmbeddingsProvider, MemoryStore
31
+
32
+ try:
33
+ __version__ = version("snipara-memory")
34
+ except PackageNotFoundError:
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "CacheStore",
39
+ "CompactionResult",
40
+ "Contradiction",
41
+ "ContradictionResolution",
42
+ "ContradictionStatus",
43
+ "EmbeddingsProvider",
44
+ "GraveyardEntry",
45
+ "GraveyardReason",
46
+ "InMemoryMemoryStore",
47
+ "JsonFileMemoryStore",
48
+ "Memory",
49
+ "MemoryScope",
50
+ "MemoryService",
51
+ "MemoryStatus",
52
+ "MemoryStore",
53
+ "MemoryTier",
54
+ "MemoryType",
55
+ "Namespace",
56
+ "NamespaceType",
57
+ "RecallMatch",
58
+ "RecallQuery",
59
+ "ResolveContradictionRequest",
60
+ "SessionMemoryBundle",
61
+ "StoreMemoryRequest",
62
+ "__version__",
63
+ "calculate_confidence_decay",
64
+ "classify_memory_tier",
65
+ "create_app",
66
+ "get_default_store_path",
67
+ ]
@@ -0,0 +1,7 @@
1
+ """Module entrypoint for the standalone CLI."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,6 @@
1
+ """Adapter exports for snipara-memory."""
2
+
3
+ from .in_memory_store import InMemoryMemoryStore
4
+ from .json_file_store import JsonFileMemoryStore, get_default_store_path
5
+
6
+ __all__ = ["InMemoryMemoryStore", "JsonFileMemoryStore", "get_default_store_path"]
@@ -0,0 +1,212 @@
1
+ """In-memory adapter for local testing of the standalone memory engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ from ..domain import Contradiction, GraveyardEntry, Memory, MemoryStatus, MemoryTier, MemoryType, RecallMatch, RecallQuery
9
+
10
+
11
+ class InMemoryMemoryStore:
12
+ """Simple in-memory store that implements the memory service contract."""
13
+
14
+ def __init__(self) -> None:
15
+ self._memories: dict[str, Memory] = {}
16
+ self._graveyard: dict[str, GraveyardEntry] = {}
17
+ self._contradictions: dict[str, Contradiction] = {}
18
+ self._embeddings: dict[str, list[float]] = {}
19
+
20
+ async def create_memory(
21
+ self,
22
+ memory: Memory,
23
+ *,
24
+ embedding: Sequence[float] | None = None,
25
+ ) -> Memory:
26
+ self._memories[memory.id] = memory
27
+ if embedding is not None:
28
+ self._embeddings[memory.id] = list(embedding)
29
+ return memory
30
+
31
+ async def create_memories(
32
+ self,
33
+ memories: Sequence[Memory],
34
+ *,
35
+ embeddings: Sequence[Sequence[float] | None] | None = None,
36
+ ) -> list[Memory]:
37
+ created: list[Memory] = []
38
+ embeddings = embeddings or [None] * len(memories)
39
+
40
+ for memory, embedding in zip(memories, embeddings, strict=False):
41
+ created.append(await self.create_memory(memory, embedding=embedding))
42
+
43
+ return created
44
+
45
+ async def search(
46
+ self,
47
+ query: RecallQuery,
48
+ *,
49
+ query_embedding: Sequence[float] | None = None,
50
+ ) -> list[RecallMatch]:
51
+ candidates = self._filter_memories(
52
+ query.namespace_id,
53
+ include_archived=query.include_archived,
54
+ types=query.types,
55
+ tiers=query.tiers,
56
+ tags=query.tags,
57
+ )
58
+ matches = [
59
+ RecallMatch(memory=memory, score=self._score(memory, query.query, query_embedding))
60
+ for memory in candidates
61
+ ]
62
+ matches = [match for match in matches if match.score > 0]
63
+ matches.sort(
64
+ key=lambda match: (
65
+ match.score,
66
+ match.memory.confidence,
67
+ match.memory.last_accessed_at or match.memory.created_at,
68
+ ),
69
+ reverse=True,
70
+ )
71
+ return matches[: query.limit]
72
+
73
+ async def list_memories(
74
+ self,
75
+ namespace_id: str,
76
+ *,
77
+ statuses: Sequence[MemoryStatus] | None = None,
78
+ tiers: Sequence[MemoryTier] | None = None,
79
+ types: Sequence[MemoryType] | None = None,
80
+ limit: int | None = None,
81
+ ) -> list[Memory]:
82
+ memories = [
83
+ memory
84
+ for memory in self._memories.values()
85
+ if memory.namespace_id == namespace_id
86
+ and (not statuses or memory.status in statuses)
87
+ and (not tiers or memory.tier in tiers)
88
+ and (not types or memory.type in types)
89
+ ]
90
+ memories.sort(
91
+ key=lambda memory: memory.last_accessed_at or memory.created_at,
92
+ reverse=True,
93
+ )
94
+ if limit is not None:
95
+ return memories[:limit]
96
+ return memories
97
+
98
+ async def get_memory(self, memory_id: str) -> Memory | None:
99
+ return self._memories.get(memory_id)
100
+
101
+ async def update_memory(self, memory: Memory) -> Memory:
102
+ self._memories[memory.id] = memory
103
+ return memory
104
+
105
+ async def delete_memory(self, memory_id: str) -> None:
106
+ self._memories.pop(memory_id, None)
107
+ self._embeddings.pop(memory_id, None)
108
+
109
+ async def create_graveyard_entry(self, entry: GraveyardEntry) -> GraveyardEntry:
110
+ self._graveyard[entry.id] = entry
111
+ return entry
112
+
113
+ async def get_graveyard_entry(self, entry_id: str) -> GraveyardEntry | None:
114
+ return self._graveyard.get(entry_id)
115
+
116
+ async def save_contradiction(self, contradiction: Contradiction) -> Contradiction:
117
+ self._contradictions[contradiction.id] = contradiction
118
+ return contradiction
119
+
120
+ async def get_contradiction(self, contradiction_id: str) -> Contradiction | None:
121
+ return self._contradictions.get(contradiction_id)
122
+
123
+ async def list_contradiction_candidates(
124
+ self,
125
+ namespace_id: str,
126
+ *,
127
+ similarity_threshold: float,
128
+ ) -> list[tuple[Memory, Memory, float]]:
129
+ memories = self._filter_memories(namespace_id, include_archived=False)
130
+ candidates: list[tuple[Memory, Memory, float]] = []
131
+
132
+ for index, memory_a in enumerate(memories):
133
+ for memory_b in memories[index + 1 :]:
134
+ if memory_a.content_hash == memory_b.content_hash:
135
+ continue
136
+ similarity = self._pair_similarity(memory_a, memory_b)
137
+ if similarity >= similarity_threshold:
138
+ candidates.append((memory_a, memory_b, similarity))
139
+
140
+ return candidates
141
+
142
+ def _filter_memories(
143
+ self,
144
+ namespace_id: str,
145
+ *,
146
+ include_archived: bool,
147
+ types: Sequence[MemoryType] | None = None,
148
+ tiers: Sequence[MemoryTier] | None = None,
149
+ tags: Sequence[str] | None = None,
150
+ ) -> list[Memory]:
151
+ allowed_statuses = {MemoryStatus.ACTIVE}
152
+ if include_archived:
153
+ allowed_statuses.add(MemoryStatus.ARCHIVED)
154
+
155
+ return [
156
+ memory
157
+ for memory in self._memories.values()
158
+ if memory.namespace_id == namespace_id
159
+ and memory.status in allowed_statuses
160
+ and memory.status is not MemoryStatus.GRAVEYARD
161
+ and (not types or memory.type in types)
162
+ and (not tiers or memory.tier in tiers)
163
+ and (not tags or set(tags).issubset(set(memory.tags)))
164
+ ]
165
+
166
+ def _score(
167
+ self,
168
+ memory: Memory,
169
+ query: str,
170
+ query_embedding: Sequence[float] | None,
171
+ ) -> float:
172
+ if query_embedding is not None and memory.id in self._embeddings:
173
+ return self._cosine_similarity(query_embedding, self._embeddings[memory.id])
174
+
175
+ query_terms = self._tokenize(query)
176
+ memory_terms = self._tokenize(memory.content)
177
+ if not query_terms or not memory_terms:
178
+ return 0.0
179
+
180
+ overlap = len(query_terms & memory_terms)
181
+ return overlap / max(len(query_terms), 1)
182
+
183
+ def _pair_similarity(self, memory_a: Memory, memory_b: Memory) -> float:
184
+ embedding_a = self._embeddings.get(memory_a.id)
185
+ embedding_b = self._embeddings.get(memory_b.id)
186
+ if embedding_a is not None and embedding_b is not None:
187
+ return self._cosine_similarity(embedding_a, embedding_b)
188
+
189
+ terms_a = self._tokenize(memory_a.content)
190
+ terms_b = self._tokenize(memory_b.content)
191
+ union = terms_a | terms_b
192
+ if not union:
193
+ return 0.0
194
+ return len(terms_a & terms_b) / len(union)
195
+
196
+ def _cosine_similarity(
197
+ self,
198
+ left: Sequence[float],
199
+ right: Sequence[float],
200
+ ) -> float:
201
+ if len(left) != len(right) or not left:
202
+ return 0.0
203
+
204
+ numerator = sum(a * b for a, b in zip(left, right, strict=False))
205
+ left_norm = math.sqrt(sum(value * value for value in left))
206
+ right_norm = math.sqrt(sum(value * value for value in right))
207
+ if left_norm == 0 or right_norm == 0:
208
+ return 0.0
209
+ return numerator / (left_norm * right_norm)
210
+
211
+ def _tokenize(self, text: str) -> set[str]:
212
+ return {token for token in text.lower().split() if token}
@@ -0,0 +1,251 @@
1
+ """Persistent JSON-backed adapter for local standalone usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from datetime import date, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ..domain import (
12
+ Contradiction,
13
+ ContradictionResolution,
14
+ ContradictionStatus,
15
+ GraveyardEntry,
16
+ GraveyardReason,
17
+ Memory,
18
+ MemoryScope,
19
+ MemoryStatus,
20
+ MemoryTier,
21
+ MemoryType,
22
+ )
23
+ from .in_memory_store import InMemoryMemoryStore
24
+
25
+
26
+ DEFAULT_STORE_PATH = Path.home() / ".snipara-memory" / "store.json"
27
+
28
+
29
+ def get_default_store_path() -> Path:
30
+ """Return the default persistent store path."""
31
+ return DEFAULT_STORE_PATH
32
+
33
+
34
+ class JsonFileMemoryStore(InMemoryMemoryStore):
35
+ """Persist the in-memory adapter state to a local JSON file."""
36
+
37
+ def __init__(self, path: str | Path | None = None) -> None:
38
+ super().__init__()
39
+ self._path = Path(path or DEFAULT_STORE_PATH).expanduser()
40
+ self._load_state()
41
+
42
+ async def create_memory(
43
+ self,
44
+ memory: Memory,
45
+ *,
46
+ embedding: list[float] | tuple[float, ...] | None = None,
47
+ ) -> Memory:
48
+ created = await super().create_memory(memory, embedding=embedding)
49
+ self._write_state()
50
+ return created
51
+
52
+ async def create_memories(
53
+ self,
54
+ memories,
55
+ *,
56
+ embeddings=None,
57
+ ) -> list[Memory]:
58
+ created = await super().create_memories(memories, embeddings=embeddings)
59
+ self._write_state()
60
+ return created
61
+
62
+ async def update_memory(self, memory: Memory) -> Memory:
63
+ updated = await super().update_memory(memory)
64
+ self._write_state()
65
+ return updated
66
+
67
+ async def delete_memory(self, memory_id: str) -> None:
68
+ await super().delete_memory(memory_id)
69
+ self._write_state()
70
+
71
+ async def create_graveyard_entry(self, entry: GraveyardEntry) -> GraveyardEntry:
72
+ stored = await super().create_graveyard_entry(entry)
73
+ self._write_state()
74
+ return stored
75
+
76
+ async def save_contradiction(self, contradiction: Contradiction) -> Contradiction:
77
+ stored = await super().save_contradiction(contradiction)
78
+ self._write_state()
79
+ return stored
80
+
81
+ def _load_state(self) -> None:
82
+ if not self._path.exists():
83
+ return
84
+
85
+ data = json.loads(self._path.read_text(encoding="utf-8"))
86
+ self._memories = {
87
+ item["id"]: _memory_from_dict(item)
88
+ for item in data.get("memories", [])
89
+ }
90
+ self._graveyard = {
91
+ item["id"]: _graveyard_entry_from_dict(item)
92
+ for item in data.get("graveyard", [])
93
+ }
94
+ self._contradictions = {
95
+ item["id"]: _contradiction_from_dict(item)
96
+ for item in data.get("contradictions", [])
97
+ }
98
+ self._embeddings = {
99
+ memory_id: [float(value) for value in embedding]
100
+ for memory_id, embedding in data.get("embeddings", {}).items()
101
+ }
102
+
103
+ def _write_state(self) -> None:
104
+ self._path.parent.mkdir(parents=True, exist_ok=True)
105
+ payload = {
106
+ "version": 1,
107
+ "memories": [_serialize(asdict(memory)) for memory in self._memories.values()],
108
+ "graveyard": [_serialize(asdict(entry)) for entry in self._graveyard.values()],
109
+ "contradictions": [
110
+ _serialize(asdict(contradiction))
111
+ for contradiction in self._contradictions.values()
112
+ ],
113
+ "embeddings": self._embeddings,
114
+ }
115
+ tmp_path = self._path.with_suffix(".tmp")
116
+ tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
117
+ tmp_path.replace(self._path)
118
+
119
+
120
+ def _serialize(value: Any) -> Any:
121
+ if isinstance(value, dict):
122
+ return {key: _serialize(item) for key, item in value.items()}
123
+ if isinstance(value, list):
124
+ return [_serialize(item) for item in value]
125
+ if isinstance(value, tuple):
126
+ return [_serialize(item) for item in value]
127
+ if isinstance(value, (MemoryType, MemoryScope, MemoryTier, MemoryStatus)):
128
+ return value.value
129
+ if isinstance(value, (GraveyardReason, ContradictionStatus, ContradictionResolution)):
130
+ return value.value
131
+ if isinstance(value, datetime):
132
+ return value.isoformat()
133
+ if isinstance(value, date):
134
+ return value.isoformat()
135
+ return value
136
+
137
+
138
+ def _parse_datetime(value: str | None) -> datetime | None:
139
+ if value is None:
140
+ return None
141
+ return datetime.fromisoformat(value)
142
+
143
+
144
+ def _parse_date(value: str | None) -> date | None:
145
+ if value is None:
146
+ return None
147
+ return date.fromisoformat(value)
148
+
149
+
150
+ def _memory_from_dict(data: dict[str, Any]) -> Memory:
151
+ return Memory(
152
+ id=data["id"],
153
+ namespace_id=data["namespace_id"],
154
+ content=data["content"],
155
+ content_hash=data["content_hash"],
156
+ title=data.get("title"),
157
+ type=MemoryType(data.get("type", MemoryType.FACT.value)),
158
+ scope=MemoryScope(data.get("scope", MemoryScope.PROJECT.value)),
159
+ category=data.get("category"),
160
+ source=data.get("source"),
161
+ tags=list(data.get("tags", [])),
162
+ metadata=dict(data.get("metadata", {})),
163
+ confidence=float(data.get("confidence", 1.0)),
164
+ relevance_boost=float(data.get("relevance_boost", 1.0)),
165
+ access_count=int(data.get("access_count", 0)),
166
+ last_accessed_at=_parse_datetime(data.get("last_accessed_at")),
167
+ tier=MemoryTier(data.get("tier", MemoryTier.ARCHIVE.value)),
168
+ status=MemoryStatus(data.get("status", MemoryStatus.ACTIVE.value)),
169
+ related_memory_ids=list(data.get("related_memory_ids", [])),
170
+ document_refs=list(data.get("document_refs", [])),
171
+ invalid_document_refs=list(data.get("invalid_document_refs", [])),
172
+ expires_at=_parse_datetime(data.get("expires_at")),
173
+ journal_date=_parse_date(data.get("journal_date")),
174
+ contradiction_count=int(data.get("contradiction_count", 0)),
175
+ superseded_by_id=data.get("superseded_by_id"),
176
+ restored_from_graveyard_id=data.get("restored_from_graveyard_id"),
177
+ promoted_at=_parse_datetime(data.get("promoted_at")),
178
+ promoted_by=data.get("promoted_by"),
179
+ buried_at=_parse_datetime(data.get("buried_at")),
180
+ buried_reason=(
181
+ GraveyardReason(data["buried_reason"])
182
+ if data.get("buried_reason") is not None
183
+ else None
184
+ ),
185
+ created_at=_parse_datetime(data.get("created_at")),
186
+ updated_at=_parse_datetime(data.get("updated_at")),
187
+ )
188
+
189
+
190
+ def _graveyard_entry_from_dict(data: dict[str, Any]) -> GraveyardEntry:
191
+ return GraveyardEntry(
192
+ id=data["id"],
193
+ namespace_id=data["namespace_id"],
194
+ original_memory_id=data.get("original_memory_id"),
195
+ content=data["content"],
196
+ content_hash=data["content_hash"],
197
+ reason=GraveyardReason(data["reason"]),
198
+ buried_at=_parse_datetime(data["buried_at"]) or datetime.now(),
199
+ replaced_by_id=data.get("replaced_by_id"),
200
+ contradiction_id=data.get("contradiction_id"),
201
+ title=data.get("title"),
202
+ type=MemoryType(data.get("type", MemoryType.FACT.value)),
203
+ scope=MemoryScope(data.get("scope", MemoryScope.PROJECT.value)),
204
+ category=data.get("category"),
205
+ source=data.get("source"),
206
+ tags=list(data.get("tags", [])),
207
+ metadata=dict(data.get("metadata", {})),
208
+ confidence=float(data.get("confidence", 1.0)),
209
+ previous_tier=(
210
+ MemoryTier(data["previous_tier"])
211
+ if data.get("previous_tier") is not None
212
+ else None
213
+ ),
214
+ previous_status=(
215
+ MemoryStatus(data["previous_status"])
216
+ if data.get("previous_status") is not None
217
+ else None
218
+ ),
219
+ related_memory_ids=list(data.get("related_memory_ids", [])),
220
+ document_refs=list(data.get("document_refs", [])),
221
+ invalid_document_refs=list(data.get("invalid_document_refs", [])),
222
+ restore_hint=data.get("restore_hint"),
223
+ snapshot=dict(data.get("snapshot", {})),
224
+ )
225
+
226
+
227
+ def _contradiction_from_dict(data: dict[str, Any]) -> Contradiction:
228
+ return Contradiction(
229
+ id=data["id"],
230
+ namespace_id=data["namespace_id"],
231
+ memory_a_id=data["memory_a_id"],
232
+ memory_b_id=data["memory_b_id"],
233
+ similarity=float(data["similarity"]),
234
+ status=ContradictionStatus(data["status"]),
235
+ created_at=_parse_datetime(data["created_at"]) or datetime.now(),
236
+ memory_a_summary=data.get("memory_a_summary"),
237
+ memory_b_summary=data.get("memory_b_summary"),
238
+ contradiction_kind=data.get("contradiction_kind"),
239
+ detected_by=data.get("detected_by"),
240
+ resolution=(
241
+ ContradictionResolution(data["resolution"])
242
+ if data.get("resolution") is not None
243
+ else None
244
+ ),
245
+ winner_memory_id=data.get("winner_memory_id"),
246
+ loser_memory_id=data.get("loser_memory_id"),
247
+ rationale=data.get("rationale"),
248
+ resolved_by=data.get("resolved_by"),
249
+ resolved_at=_parse_datetime(data.get("resolved_at")),
250
+ metadata=dict(data.get("metadata", {})),
251
+ )
@@ -0,0 +1,5 @@
1
+ """API exports for snipara-memory."""
2
+
3
+ from .fastapi_app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -0,0 +1,95 @@
1
+ """Minimal FastAPI entrypoint for the standalone memory engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from datetime import date, datetime
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.responses import JSONResponse
11
+ from pydantic import BaseModel, Field
12
+
13
+ from ..domain import (
14
+ MemoryScope,
15
+ MemoryService,
16
+ MemoryStatus,
17
+ MemoryTier,
18
+ MemoryType,
19
+ RecallQuery,
20
+ StoreMemoryRequest,
21
+ )
22
+
23
+
24
+ class StoreMemoryBody(BaseModel):
25
+ content: str = Field(..., min_length=1)
26
+ title: str | None = None
27
+ memory_type: MemoryType = MemoryType.FACT
28
+ scope: MemoryScope = MemoryScope.PROJECT
29
+ category: str | None = None
30
+ source: str | None = None
31
+ tags: list[str] = Field(default_factory=list)
32
+ metadata: dict[str, Any] = Field(default_factory=dict)
33
+ confidence: float = Field(default=0.7, ge=0.0, le=1.0)
34
+ relevance_boost: float = Field(default=1.0, ge=0.0)
35
+ tier: MemoryTier | None = None
36
+ status: MemoryStatus = MemoryStatus.ACTIVE
37
+ related_memory_ids: list[str] = Field(default_factory=list)
38
+ document_refs: list[str] = Field(default_factory=list)
39
+ invalid_document_refs: list[str] = Field(default_factory=list)
40
+ expires_at: datetime | None = None
41
+ journal_date: date | None = None
42
+
43
+
44
+ class RecallBody(BaseModel):
45
+ query: str = Field(..., min_length=1)
46
+ limit: int = Field(default=10, ge=1, le=100)
47
+ min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
48
+ include_archived: bool = False
49
+ types: list[MemoryType] = Field(default_factory=list)
50
+ tiers: list[MemoryTier] = Field(default_factory=list)
51
+ tags: list[str] = Field(default_factory=list)
52
+
53
+
54
+ def create_app(service: MemoryService) -> FastAPI:
55
+ """Create a standalone API app around the memory service."""
56
+ app = FastAPI(title="snipara-memory", version="0.1.0")
57
+
58
+ @app.get("/health")
59
+ async def health() -> dict[str, str]:
60
+ return {"status": "ok"}
61
+
62
+ @app.post("/v1/namespaces/{namespace_id}/memories")
63
+ async def store_memory(namespace_id: str, body: StoreMemoryBody) -> dict[str, Any]:
64
+ memory = await service.store_memory(
65
+ StoreMemoryRequest(namespace_id=namespace_id, **body.model_dump())
66
+ )
67
+ return asdict(memory)
68
+
69
+ @app.post("/v1/namespaces/{namespace_id}/memories/recall")
70
+ async def recall(namespace_id: str, body: RecallBody) -> list[dict[str, Any]]:
71
+ matches = await service.semantic_recall(
72
+ RecallQuery(namespace_id=namespace_id, **body.model_dump())
73
+ )
74
+ return [asdict(match) for match in matches]
75
+
76
+ @app.get("/v1/namespaces/{namespace_id}/session-memories")
77
+ async def session_memories(
78
+ namespace_id: str,
79
+ critical_limit: int = 12,
80
+ daily_limit: int = 20,
81
+ archive_limit: int = 20,
82
+ ) -> dict[str, Any]:
83
+ bundle = await service.get_session_memories(
84
+ namespace_id,
85
+ critical_limit=critical_limit,
86
+ daily_limit=daily_limit,
87
+ archive_limit=archive_limit,
88
+ )
89
+ return asdict(bundle)
90
+
91
+ @app.exception_handler(ValueError)
92
+ async def handle_value_error(_: Request, exc: ValueError) -> JSONResponse:
93
+ return JSONResponse(status_code=400, content={"detail": str(exc)})
94
+
95
+ return app