memoryagent-lib 0.1.1__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.
- memoryagent/__init__.py +35 -0
- memoryagent/confidence.py +82 -0
- memoryagent/config.py +35 -0
- memoryagent/consolidation.py +5 -0
- memoryagent/examples/export_memory.py +110 -0
- memoryagent/examples/memory_api_server.py +223 -0
- memoryagent/examples/minimal.py +47 -0
- memoryagent/examples/openai_agent.py +137 -0
- memoryagent/indexers.py +61 -0
- memoryagent/models.py +156 -0
- memoryagent/policy.py +171 -0
- memoryagent/retrieval.py +154 -0
- memoryagent/storage/base.py +86 -0
- memoryagent/storage/in_memory.py +88 -0
- memoryagent/storage/local_disk.py +415 -0
- memoryagent/system.py +182 -0
- memoryagent/utils.py +35 -0
- memoryagent/workers.py +169 -0
- memoryagent_lib-0.1.1.dist-info/METADATA +186 -0
- memoryagent_lib-0.1.1.dist-info/RECORD +22 -0
- memoryagent_lib-0.1.1.dist-info/WHEEL +5 -0
- memoryagent_lib-0.1.1.dist-info/top_level.txt +1 -0
memoryagent/indexers.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from memoryagent.models import MemoryItem, MemoryType, StorageTier
|
|
6
|
+
from memoryagent.storage.base import FeatureStore, GraphStore, VectorIndex
|
|
7
|
+
from memoryagent.utils import tokenize
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EpisodicIndexer:
|
|
11
|
+
"""Indexes episodic content into the vector index."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, vector_index: VectorIndex) -> None:
|
|
14
|
+
self.vector_index = vector_index
|
|
15
|
+
|
|
16
|
+
async def index_hot(self, item: MemoryItem) -> None:
|
|
17
|
+
await self.vector_index.upsert(
|
|
18
|
+
item.id,
|
|
19
|
+
text=item.text(),
|
|
20
|
+
metadata={"owner": item.owner, "tier": StorageTier.HOT.value, "type": item.type.value, "item": item},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
async def index_archive(self, item: MemoryItem) -> None:
|
|
24
|
+
await self.vector_index.upsert(
|
|
25
|
+
item.id,
|
|
26
|
+
text=item.summary,
|
|
27
|
+
metadata={"owner": item.owner, "tier": StorageTier.ARCHIVE_INDEX.value, "type": item.type.value, "item": item},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SemanticGraphIndexer:
|
|
32
|
+
"""Extracts simple fact-like triples from tags for demo use."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, graph_store: GraphStore) -> None:
|
|
35
|
+
self.graph_store = graph_store
|
|
36
|
+
|
|
37
|
+
async def index(self, item: MemoryItem) -> None:
|
|
38
|
+
if item.type != MemoryType.SEMANTIC:
|
|
39
|
+
return
|
|
40
|
+
if len(item.tags) < 2:
|
|
41
|
+
return
|
|
42
|
+
subject = item.tags[0]
|
|
43
|
+
for tag in item.tags[1:]:
|
|
44
|
+
await self.graph_store.upsert_fact(item.owner, subject, "related_to", tag)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PerceptualIndexer:
|
|
48
|
+
"""Summarizes perceptual inputs into feature store entries."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, feature_store: FeatureStore) -> None:
|
|
51
|
+
self.feature_store = feature_store
|
|
52
|
+
|
|
53
|
+
async def index(self, item: MemoryItem) -> None:
|
|
54
|
+
if item.type != MemoryType.PERCEPTUAL:
|
|
55
|
+
return
|
|
56
|
+
payload = {
|
|
57
|
+
"summary": item.summary,
|
|
58
|
+
"tags": item.tags,
|
|
59
|
+
"confidence": item.confidence,
|
|
60
|
+
}
|
|
61
|
+
await self.feature_store.write_feature(item.owner, payload)
|
memoryagent/models.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def utc_now() -> datetime:
|
|
12
|
+
return datetime.now(timezone.utc)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryType(str, Enum):
|
|
16
|
+
WORKING = "working"
|
|
17
|
+
EPISODIC = "episodic"
|
|
18
|
+
SEMANTIC = "semantic"
|
|
19
|
+
PERCEPTUAL = "perceptual"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StorageTier(str, Enum):
|
|
23
|
+
HOT = "hot"
|
|
24
|
+
COLD = "cold"
|
|
25
|
+
ARCHIVE_INDEX = "archive_index"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MemoryItem(BaseModel):
|
|
29
|
+
"""Canonical memory item stored in metadata storage."""
|
|
30
|
+
|
|
31
|
+
id: UUID = Field(default_factory=uuid4)
|
|
32
|
+
type: MemoryType
|
|
33
|
+
owner: str
|
|
34
|
+
summary: str
|
|
35
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
36
|
+
updated_at: datetime = Field(default_factory=utc_now)
|
|
37
|
+
last_accessed: Optional[datetime] = None
|
|
38
|
+
tier: StorageTier = StorageTier.HOT
|
|
39
|
+
pointer: Dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
content: Optional[Any] = None
|
|
41
|
+
tags: List[str] = Field(default_factory=list)
|
|
42
|
+
ttl_seconds: Optional[int] = None
|
|
43
|
+
confidence: float = 0.5
|
|
44
|
+
authority: float = 0.5
|
|
45
|
+
stability: float = 0.5
|
|
46
|
+
|
|
47
|
+
def is_expired(self, now: Optional[datetime] = None) -> bool:
|
|
48
|
+
if self.ttl_seconds is None:
|
|
49
|
+
return False
|
|
50
|
+
now = now or utc_now()
|
|
51
|
+
return self.created_at + timedelta(seconds=self.ttl_seconds) <= now
|
|
52
|
+
|
|
53
|
+
def text(self) -> str:
|
|
54
|
+
if isinstance(self.content, str):
|
|
55
|
+
return self.content
|
|
56
|
+
return self.summary
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MemoryEvent(BaseModel):
|
|
60
|
+
"""Developer-facing input for memory writes."""
|
|
61
|
+
|
|
62
|
+
content: Any
|
|
63
|
+
type: MemoryType = MemoryType.WORKING
|
|
64
|
+
owner: str
|
|
65
|
+
summary: Optional[str] = None
|
|
66
|
+
tags: List[str] = Field(default_factory=list)
|
|
67
|
+
ttl_seconds: Optional[int] = None
|
|
68
|
+
confidence: float = 0.5
|
|
69
|
+
authority: float = 0.5
|
|
70
|
+
stability: float = 0.5
|
|
71
|
+
pointer: Dict[str, Any] = Field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
def to_item(self) -> MemoryItem:
|
|
74
|
+
summary = self.summary or (self.content if isinstance(self.content, str) else str(self.content))
|
|
75
|
+
return MemoryItem(
|
|
76
|
+
type=self.type,
|
|
77
|
+
owner=self.owner,
|
|
78
|
+
summary=summary,
|
|
79
|
+
content=self.content,
|
|
80
|
+
tags=self.tags,
|
|
81
|
+
ttl_seconds=self.ttl_seconds,
|
|
82
|
+
confidence=self.confidence,
|
|
83
|
+
authority=self.authority,
|
|
84
|
+
stability=self.stability,
|
|
85
|
+
pointer=self.pointer,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MemoryQuery(BaseModel):
|
|
90
|
+
text: str
|
|
91
|
+
owner: str
|
|
92
|
+
types: Optional[List[MemoryType]] = None
|
|
93
|
+
top_k: int = 10
|
|
94
|
+
time_range_seconds: Optional[int] = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RetrievalPlan(BaseModel):
|
|
98
|
+
"""Routing + budgets + thresholds for retrieval pipeline."""
|
|
99
|
+
|
|
100
|
+
hot_top_k: int = 30
|
|
101
|
+
archive_top_k: int = 30
|
|
102
|
+
cold_fetch_limit: int = 20
|
|
103
|
+
cold_fetch_min_score: float = 0.25
|
|
104
|
+
hot_confidence: float = 0.62
|
|
105
|
+
archive_confidence: float = 0.72
|
|
106
|
+
cold_fetch_confidence: float = 0.58
|
|
107
|
+
max_results: int = 50
|
|
108
|
+
max_context_tokens: int = 600
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ScoredMemory(BaseModel):
|
|
112
|
+
item: MemoryItem
|
|
113
|
+
score: float
|
|
114
|
+
tier: StorageTier
|
|
115
|
+
explanation: Optional[str] = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ConfidenceReport(BaseModel):
|
|
119
|
+
total: float
|
|
120
|
+
semantic_relevance: float
|
|
121
|
+
coverage: float
|
|
122
|
+
temporal_fit: float
|
|
123
|
+
authority: float
|
|
124
|
+
consistency: float
|
|
125
|
+
recommendation: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MemoryBlock(BaseModel):
|
|
129
|
+
text: str
|
|
130
|
+
item_id: UUID
|
|
131
|
+
memory_type: MemoryType
|
|
132
|
+
tier: StorageTier
|
|
133
|
+
score: float
|
|
134
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RetrievalTrace(BaseModel):
|
|
138
|
+
steps: List[str] = Field(default_factory=list)
|
|
139
|
+
escalations: List[str] = Field(default_factory=list)
|
|
140
|
+
sources: List[str] = Field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
def add_step(self, text: str) -> None:
|
|
143
|
+
self.steps.append(text)
|
|
144
|
+
|
|
145
|
+
def add_escalation(self, text: str) -> None:
|
|
146
|
+
self.escalations.append(text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class MemoryBundle(BaseModel):
|
|
150
|
+
query: str
|
|
151
|
+
results: List[ScoredMemory]
|
|
152
|
+
blocks: List[MemoryBlock]
|
|
153
|
+
confidence: ConfidenceReport
|
|
154
|
+
used_tiers: List[StorageTier]
|
|
155
|
+
trace: RetrievalTrace
|
|
156
|
+
warnings: List[str] = Field(default_factory=list)
|
memoryagent/policy.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable, List, Optional
|
|
5
|
+
|
|
6
|
+
from memoryagent.models import MemoryEvent, MemoryItem, MemoryType, StorageTier
|
|
7
|
+
from memoryagent.utils import tokenize
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MemoryDecision:
|
|
12
|
+
store: bool
|
|
13
|
+
memory_type: MemoryType
|
|
14
|
+
summary: Optional[str] = None
|
|
15
|
+
tags: Optional[List[str]] = None
|
|
16
|
+
reasons: Optional[List[str]] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConversationMemoryPolicy:
|
|
20
|
+
"""Decides if a turn should be persisted to memory."""
|
|
21
|
+
|
|
22
|
+
def should_store(
|
|
23
|
+
self,
|
|
24
|
+
owner: str,
|
|
25
|
+
history: List[str],
|
|
26
|
+
user_message: str,
|
|
27
|
+
assistant_message: str,
|
|
28
|
+
) -> MemoryDecision:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
def to_event(self, owner: str, decision: MemoryDecision) -> Optional[MemoryEvent]:
|
|
32
|
+
if not decision.store or not decision.summary:
|
|
33
|
+
return None
|
|
34
|
+
return MemoryEvent(
|
|
35
|
+
content=decision.summary,
|
|
36
|
+
type=decision.memory_type,
|
|
37
|
+
owner=owner,
|
|
38
|
+
tags=decision.tags or [],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HeuristicMemoryPolicy(ConversationMemoryPolicy):
|
|
43
|
+
"""Default heuristic policy for deciding what to store."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
min_tokens: int = 24,
|
|
48
|
+
novelty_threshold: float = 0.65,
|
|
49
|
+
short_turn_min_novelty: float = 0.8,
|
|
50
|
+
preference_keywords: Optional[Iterable[str]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.min_tokens = min_tokens
|
|
53
|
+
self.novelty_threshold = novelty_threshold
|
|
54
|
+
self.short_turn_min_novelty = short_turn_min_novelty
|
|
55
|
+
self.preference_keywords = set(
|
|
56
|
+
k.lower()
|
|
57
|
+
for k in (preference_keywords or ["prefer", "always", "never", "likes", "dislikes"])
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def should_store(
|
|
61
|
+
self,
|
|
62
|
+
owner: str,
|
|
63
|
+
history: List[str],
|
|
64
|
+
user_message: str,
|
|
65
|
+
assistant_message: str,
|
|
66
|
+
) -> MemoryDecision:
|
|
67
|
+
combined = f"{user_message} {assistant_message}"
|
|
68
|
+
tokens = tokenize(combined)
|
|
69
|
+
reasons: List[str] = []
|
|
70
|
+
memory_type = MemoryType.EPISODIC
|
|
71
|
+
|
|
72
|
+
is_preference = any(word in combined.lower() for word in self.preference_keywords)
|
|
73
|
+
if len(tokens) < self.min_tokens:
|
|
74
|
+
reasons.append("short_turn")
|
|
75
|
+
if is_preference:
|
|
76
|
+
memory_type = MemoryType.SEMANTIC
|
|
77
|
+
reasons.append("preference_signal")
|
|
78
|
+
|
|
79
|
+
if history:
|
|
80
|
+
recent_entries = history[-3:]
|
|
81
|
+
recent_text = " ".join(self._history_entry_text(entry) for entry in recent_entries)
|
|
82
|
+
novelty = 1.0 - self._overlap_ratio(tokens, tokenize(recent_text))
|
|
83
|
+
novelty_floor = self.short_turn_min_novelty if len(tokens) < self.min_tokens else self.novelty_threshold
|
|
84
|
+
if novelty < novelty_floor:
|
|
85
|
+
reasons.append("low_novelty")
|
|
86
|
+
|
|
87
|
+
if is_preference:
|
|
88
|
+
store = True
|
|
89
|
+
else:
|
|
90
|
+
store = "short_turn" not in reasons and "low_novelty" not in reasons
|
|
91
|
+
summary = self._summarize(user_message, assistant_message, memory_type)
|
|
92
|
+
tags = ["conversation", memory_type.value]
|
|
93
|
+
return MemoryDecision(store=store, memory_type=memory_type, summary=summary, tags=tags, reasons=reasons)
|
|
94
|
+
|
|
95
|
+
def _overlap_ratio(self, tokens_a: List[str], tokens_b: List[str]) -> float:
|
|
96
|
+
if not tokens_a or not tokens_b:
|
|
97
|
+
return 0.0
|
|
98
|
+
set_a = set(tokens_a)
|
|
99
|
+
set_b = set(tokens_b)
|
|
100
|
+
return len(set_a & set_b) / max(1, len(set_a | set_b))
|
|
101
|
+
|
|
102
|
+
def _summarize(self, user_message: str, assistant_message: str, memory_type: MemoryType) -> str:
|
|
103
|
+
if memory_type == MemoryType.SEMANTIC:
|
|
104
|
+
return f"User preference: {user_message.strip()}"
|
|
105
|
+
return f"User asked: {user_message.strip()} | Assistant replied: {assistant_message.strip()}"
|
|
106
|
+
|
|
107
|
+
def _history_entry_text(self, entry) -> str:
|
|
108
|
+
if isinstance(entry, str):
|
|
109
|
+
return entry
|
|
110
|
+
if isinstance(entry, dict):
|
|
111
|
+
if "user" in entry and "assistant" in entry:
|
|
112
|
+
return f"User: {entry['user']} Assistant: {entry['assistant']}"
|
|
113
|
+
if "role" in entry and "text" in entry:
|
|
114
|
+
return f"{entry['role']}: {entry['text']}"
|
|
115
|
+
return str(entry)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class RoutingDecision:
|
|
120
|
+
write_hot: bool
|
|
121
|
+
write_vector: bool
|
|
122
|
+
write_features: bool
|
|
123
|
+
archive_cold: bool
|
|
124
|
+
reasons: List[str]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MemoryRoutingPolicy:
|
|
128
|
+
"""Concrete policy for routing memory writes across tiers and indexes."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
hot_min_confidence: float = 0.4,
|
|
133
|
+
cold_min_confidence: float = 0.55,
|
|
134
|
+
vector_min_confidence: float = 0.5,
|
|
135
|
+
feature_min_confidence: float = 0.45,
|
|
136
|
+
) -> None:
|
|
137
|
+
self.hot_min_confidence = hot_min_confidence
|
|
138
|
+
self.cold_min_confidence = cold_min_confidence
|
|
139
|
+
self.vector_min_confidence = vector_min_confidence
|
|
140
|
+
self.feature_min_confidence = feature_min_confidence
|
|
141
|
+
|
|
142
|
+
def route(self, item: MemoryItem) -> RoutingDecision:
|
|
143
|
+
reasons: List[str] = []
|
|
144
|
+
confidence = item.confidence
|
|
145
|
+
|
|
146
|
+
write_hot = confidence >= self.hot_min_confidence
|
|
147
|
+
if not write_hot:
|
|
148
|
+
reasons.append("low_confidence_hot")
|
|
149
|
+
|
|
150
|
+
write_vector = confidence >= self.vector_min_confidence and item.type != MemoryType.WORKING
|
|
151
|
+
if not write_vector:
|
|
152
|
+
reasons.append("skip_vector")
|
|
153
|
+
|
|
154
|
+
write_features = item.type == MemoryType.PERCEPTUAL and confidence >= self.feature_min_confidence
|
|
155
|
+
if not write_features and item.type == MemoryType.PERCEPTUAL:
|
|
156
|
+
reasons.append("skip_features")
|
|
157
|
+
|
|
158
|
+
archive_cold = (
|
|
159
|
+
item.type in {MemoryType.EPISODIC, MemoryType.SEMANTIC, MemoryType.PERCEPTUAL}
|
|
160
|
+
and confidence >= self.cold_min_confidence
|
|
161
|
+
)
|
|
162
|
+
if not archive_cold:
|
|
163
|
+
reasons.append("skip_cold")
|
|
164
|
+
|
|
165
|
+
return RoutingDecision(
|
|
166
|
+
write_hot=write_hot,
|
|
167
|
+
write_vector=write_vector,
|
|
168
|
+
write_features=write_features,
|
|
169
|
+
archive_cold=archive_cold,
|
|
170
|
+
reasons=reasons,
|
|
171
|
+
)
|
memoryagent/retrieval.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Sequence
|
|
4
|
+
|
|
5
|
+
from memoryagent.confidence import evaluate_confidence
|
|
6
|
+
from memoryagent.models import (
|
|
7
|
+
MemoryBlock,
|
|
8
|
+
MemoryBundle,
|
|
9
|
+
MemoryQuery,
|
|
10
|
+
MemoryType,
|
|
11
|
+
RetrievalPlan,
|
|
12
|
+
RetrievalTrace,
|
|
13
|
+
ScoredMemory,
|
|
14
|
+
StorageTier,
|
|
15
|
+
)
|
|
16
|
+
from memoryagent.storage.base import MetadataStore, ObjectStore, VectorIndex
|
|
17
|
+
from memoryagent.utils import clamp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RetrievalOrchestrator:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
metadata_store: MetadataStore,
|
|
24
|
+
vector_index: VectorIndex,
|
|
25
|
+
object_store: ObjectStore,
|
|
26
|
+
plan: RetrievalPlan,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.metadata_store = metadata_store
|
|
29
|
+
self.vector_index = vector_index
|
|
30
|
+
self.object_store = object_store
|
|
31
|
+
self.plan = plan
|
|
32
|
+
|
|
33
|
+
async def retrieve(self, query: MemoryQuery) -> MemoryBundle:
|
|
34
|
+
used_tiers: List[StorageTier] = []
|
|
35
|
+
warnings: List[str] = []
|
|
36
|
+
trace = RetrievalTrace()
|
|
37
|
+
|
|
38
|
+
trace.add_step("hot search per type")
|
|
39
|
+
hot_results: List[ScoredMemory] = []
|
|
40
|
+
types = query.types or [MemoryType.WORKING, MemoryType.EPISODIC, MemoryType.SEMANTIC, MemoryType.PERCEPTUAL]
|
|
41
|
+
per_type_limit = max(1, self.plan.hot_top_k // max(1, len(types)))
|
|
42
|
+
for mem_type in types:
|
|
43
|
+
hot_results.extend(
|
|
44
|
+
await self.vector_index.query(
|
|
45
|
+
query,
|
|
46
|
+
filters={
|
|
47
|
+
"owner": query.owner,
|
|
48
|
+
"tier": StorageTier.HOT.value,
|
|
49
|
+
"types": [mem_type],
|
|
50
|
+
},
|
|
51
|
+
limit=per_type_limit,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
used_tiers.append(StorageTier.HOT)
|
|
55
|
+
confidence = evaluate_confidence(query, hot_results)
|
|
56
|
+
|
|
57
|
+
results = list(hot_results)
|
|
58
|
+
|
|
59
|
+
if confidence.total < self.plan.hot_confidence:
|
|
60
|
+
trace.add_escalation("hot confidence below threshold; searching archive")
|
|
61
|
+
archive_results = await self.vector_index.query(
|
|
62
|
+
query,
|
|
63
|
+
filters={"owner": query.owner, "tier": StorageTier.ARCHIVE_INDEX.value, "types": query.types},
|
|
64
|
+
limit=self.plan.archive_top_k,
|
|
65
|
+
)
|
|
66
|
+
if archive_results:
|
|
67
|
+
results.extend(archive_results)
|
|
68
|
+
used_tiers.append(StorageTier.ARCHIVE_INDEX)
|
|
69
|
+
confidence = evaluate_confidence(query, results)
|
|
70
|
+
|
|
71
|
+
if confidence.total < self.plan.cold_fetch_confidence:
|
|
72
|
+
trace.add_escalation("archive confidence low; fetching cold payloads")
|
|
73
|
+
cold_candidates = [
|
|
74
|
+
item for item in archive_results if item.score >= self.plan.cold_fetch_min_score
|
|
75
|
+
][: self.plan.cold_fetch_limit]
|
|
76
|
+
for item in cold_candidates:
|
|
77
|
+
pointer = item.item.pointer.get("object_key")
|
|
78
|
+
if not pointer:
|
|
79
|
+
continue
|
|
80
|
+
payload = await self.object_store.get(pointer)
|
|
81
|
+
if payload is None:
|
|
82
|
+
warnings.append(f"Missing cold object: {pointer}")
|
|
83
|
+
continue
|
|
84
|
+
if isinstance(payload, list):
|
|
85
|
+
payload = next((p for p in payload if p.get("id") == str(item.item.id)), None)
|
|
86
|
+
if payload is None:
|
|
87
|
+
warnings.append(f"Missing id {item.item.id} in daily notes: {pointer}")
|
|
88
|
+
continue
|
|
89
|
+
hydrated = item.item.model_copy(update={"content": payload, "tier": StorageTier.COLD})
|
|
90
|
+
results.append(
|
|
91
|
+
ScoredMemory(item=hydrated, score=item.score, tier=StorageTier.COLD, explanation="cold hydrate")
|
|
92
|
+
)
|
|
93
|
+
if cold_candidates:
|
|
94
|
+
used_tiers.append(StorageTier.COLD)
|
|
95
|
+
confidence = evaluate_confidence(query, results)
|
|
96
|
+
|
|
97
|
+
hydrated = await self._hydrate(results)
|
|
98
|
+
reranked = self._rerank(self._dedupe(hydrated))
|
|
99
|
+
blocks = self._to_blocks(reranked)
|
|
100
|
+
trace.sources = [f"{item.item.type}:{item.tier}" for item in reranked[:10]]
|
|
101
|
+
|
|
102
|
+
return MemoryBundle(
|
|
103
|
+
query=query.text,
|
|
104
|
+
results=reranked,
|
|
105
|
+
blocks=blocks,
|
|
106
|
+
confidence=confidence,
|
|
107
|
+
used_tiers=used_tiers,
|
|
108
|
+
trace=trace,
|
|
109
|
+
warnings=warnings,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _rerank(self, results: Sequence[ScoredMemory]) -> List[ScoredMemory]:
|
|
113
|
+
def score(item: ScoredMemory) -> float:
|
|
114
|
+
return clamp(0.75 * item.score + 0.25 * item.item.confidence)
|
|
115
|
+
|
|
116
|
+
reranked = sorted(results, key=score, reverse=True)
|
|
117
|
+
return reranked[: self.plan.max_results]
|
|
118
|
+
|
|
119
|
+
def _to_blocks(self, results: Sequence[ScoredMemory]) -> List[MemoryBlock]:
|
|
120
|
+
blocks: List[MemoryBlock] = []
|
|
121
|
+
for item in results:
|
|
122
|
+
text = item.item.text()
|
|
123
|
+
blocks.append(
|
|
124
|
+
MemoryBlock(
|
|
125
|
+
text=text,
|
|
126
|
+
item_id=item.item.id,
|
|
127
|
+
memory_type=item.item.type,
|
|
128
|
+
tier=item.tier,
|
|
129
|
+
score=item.score,
|
|
130
|
+
metadata={"owner": item.item.owner, "tags": item.item.tags},
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return blocks
|
|
134
|
+
|
|
135
|
+
async def _hydrate(self, results: Sequence[ScoredMemory]) -> List[ScoredMemory]:
|
|
136
|
+
hydrated: List[ScoredMemory] = []
|
|
137
|
+
for item in results:
|
|
138
|
+
if item.item.content is not None and item.item.tags:
|
|
139
|
+
hydrated.append(item)
|
|
140
|
+
continue
|
|
141
|
+
full_item = await self.metadata_store.get(item.item.id)
|
|
142
|
+
if full_item is None:
|
|
143
|
+
hydrated.append(item)
|
|
144
|
+
continue
|
|
145
|
+
hydrated.append(item.model_copy(update={"item": full_item}))
|
|
146
|
+
return hydrated
|
|
147
|
+
|
|
148
|
+
def _dedupe(self, results: Sequence[ScoredMemory]) -> List[ScoredMemory]:
|
|
149
|
+
best = {}
|
|
150
|
+
for item in results:
|
|
151
|
+
key = str(item.item.id)
|
|
152
|
+
if key not in best or item.score > best[key].score:
|
|
153
|
+
best[key] = item
|
|
154
|
+
return list(best.values())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Iterable, List, Optional
|
|
5
|
+
|
|
6
|
+
from memoryagent.models import MemoryItem, MemoryQuery, ScoredMemory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MetadataStore(ABC):
|
|
10
|
+
"""Stores canonical MemoryItem metadata."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def upsert(self, item: MemoryItem) -> None:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def get(self, item_id) -> Optional[MemoryItem]:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def delete(self, item_id) -> None:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def list_by_owner(self, owner: str) -> List[MemoryItem]:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def list_by_owner_and_type(self, owner: str, types: Iterable[str]) -> List[MemoryItem]:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def update_access(self, item_id) -> None:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VectorIndex(ABC):
|
|
38
|
+
"""Vector or lexical index supporting similarity search."""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def upsert(self, item_id, text: str, metadata: dict) -> None:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def delete(self, item_id) -> None:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def query(self, query: MemoryQuery, filters: dict, limit: int) -> List[ScoredMemory]:
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GraphStore(ABC):
|
|
54
|
+
"""Stores semantic relationships (facts, preferences, rules)."""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def upsert_fact(self, owner: str, subject: str, predicate: str, obj: str) -> None:
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def query_related(self, owner: str, subject: str, limit: int) -> List[str]:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ObjectStore(ABC):
|
|
66
|
+
"""Stores cold memory payloads."""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def put(self, key: str, payload: dict) -> str:
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def get(self, key: str) -> Optional[dict]:
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FeatureStore(ABC):
|
|
78
|
+
"""Stores perceptual aggregates (time-series or feature logs)."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def write_feature(self, owner: str, payload: dict) -> None:
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
async def query_features(self, owner: str, limit: int) -> List[dict]:
|
|
86
|
+
raise NotImplementedError
|