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.
- snipara_memory/__init__.py +67 -0
- snipara_memory/__main__.py +7 -0
- snipara_memory/adapters/__init__.py +6 -0
- snipara_memory/adapters/in_memory_store.py +212 -0
- snipara_memory/adapters/json_file_store.py +251 -0
- snipara_memory/api/__init__.py +5 -0
- snipara_memory/api/fastapi_app.py +95 -0
- snipara_memory/benchmark.py +152 -0
- snipara_memory/cli.py +163 -0
- snipara_memory/domain/__init__.py +47 -0
- snipara_memory/domain/models.py +240 -0
- snipara_memory/domain/service.py +589 -0
- snipara_memory/importers.py +302 -0
- snipara_memory/mcp_server.py +309 -0
- snipara_memory/ports/__init__.py +7 -0
- snipara_memory/ports/cache.py +17 -0
- snipara_memory/ports/embeddings.py +14 -0
- snipara_memory/ports/store.py +64 -0
- snipara_memory-0.1.0.dist-info/METADATA +454 -0
- snipara_memory-0.1.0.dist-info/RECORD +23 -0
- snipara_memory-0.1.0.dist-info/WHEEL +4 -0
- snipara_memory-0.1.0.dist-info/entry_points.txt +4 -0
- snipara_memory-0.1.0.dist-info/licenses/LICENSE +195 -0
|
@@ -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,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,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
|