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.
@@ -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
+ )
@@ -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