codex-agent-framework 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.
codex_agent/memory.py ADDED
@@ -0,0 +1,318 @@
1
+ from datetime import datetime
2
+ import json
3
+ import os
4
+ from typing import List
5
+ from xml.sax.saxutils import escape, quoteattr
6
+
7
+ import numpy as np
8
+ from modict import modict
9
+
10
+ from .message import DeveloperMessage, Message
11
+ from .utils import short_id, token_count, timestamp
12
+
13
+
14
+ class MemoryEntry(DeveloperMessage):
15
+ title = ""
16
+ source = "manual"
17
+ metadata = modict.factory(dict)
18
+
19
+ def to_response_format(self):
20
+ return None
21
+
22
+
23
+ def format_memory_results_xml(results, root=None):
24
+ entries = "\n".join(
25
+ (
26
+ f"<memory id={quoteattr(str(entry.id))} "
27
+ f"title={quoteattr(str(entry.get('title') or ''))}>"
28
+ f"\n{escape(str(entry.content))}\n"
29
+ f"</memory>"
30
+ )
31
+ for entry in results
32
+ )
33
+ if root is None:
34
+ return entries
35
+ return f"<{root}>\n{entries}\n</{root}>"
36
+
37
+
38
+ class RAGMemoryConfig(modict):
39
+ _config = modict.config(enforce_json=True)
40
+
41
+ precision: int = 5
42
+ dimensions: int = 128
43
+ half_life_days: float = 2
44
+ half_life_steps: float = 2
45
+ moment_exponent: float = 3
46
+ max_tokens: int | None = None
47
+
48
+
49
+ class RAGMemory(modict):
50
+ """Timestamped embedding retrieval over an explicit message collection."""
51
+
52
+ settings: RAGMemoryConfig = modict.factory(RAGMemoryConfig)
53
+ messages: List[Message] = modict.factory(list)
54
+ file: str | None = None
55
+
56
+ def __init__(self, *args, agent=None, file=None, messages=None, **kwargs):
57
+ config_data = {}
58
+ for key in list(kwargs):
59
+ if key in RAGMemoryConfig.__fields__:
60
+ config_data[key] = kwargs.pop(key)
61
+ super().__init__(*args, **kwargs)
62
+ self.set_attr("_agent", agent)
63
+ if config_data:
64
+ self.settings.update(config_data)
65
+ if file is not None:
66
+ self.file = file
67
+ if messages is not None:
68
+ self.messages = list(messages)
69
+ elif self.file:
70
+ self.load()
71
+
72
+ @property
73
+ def agent(self):
74
+ return self.get_attr("_agent", None)
75
+
76
+ def add(self, message):
77
+ self.messages.append(message)
78
+ return message
79
+
80
+ def load(self, file=None):
81
+ self.file = file or self.file
82
+ if not self.file or not os.path.isfile(self.file):
83
+ return self
84
+ with open(self.file, "r", encoding="utf-8") as f:
85
+ data = json.load(f)
86
+
87
+ self.settings.update(data.get("settings") or {})
88
+ self.messages = [
89
+ MemoryEntry(entry)
90
+ for entry in data.get("messages", [])
91
+ ]
92
+ return self
93
+
94
+ def dump(self, file=None):
95
+ self.file = file or self.file
96
+ if not self.file:
97
+ return self
98
+ parent = os.path.dirname(self.file)
99
+ if parent:
100
+ os.makedirs(parent, exist_ok=True)
101
+ data = modict(
102
+ type="memory",
103
+ settings=self.settings,
104
+ messages=self.messages,
105
+ )
106
+ with open(self.file, "w", encoding="utf-8") as f:
107
+ json.dump(data, f, ensure_ascii=False, indent=2)
108
+ return self
109
+
110
+ def remember(self, content, title="", source="manual", metadata=None, memory_id=None):
111
+ entry = MemoryEntry(
112
+ id=memory_id or short_id(),
113
+ content=content,
114
+ title=title,
115
+ source=source,
116
+ metadata=metadata or {},
117
+ timestamp=timestamp(),
118
+ )
119
+ self.ensure_embedding(entry)
120
+ self.add(entry)
121
+ self.dump()
122
+ return entry
123
+
124
+ def get(self, memory_id):
125
+ for entry in self.messages:
126
+ if entry.get("id") == memory_id:
127
+ return entry
128
+ return None
129
+
130
+ def find(self, ref):
131
+ entry = self.get(ref)
132
+ if entry is not None:
133
+ return entry
134
+ for entry in self.messages:
135
+ if entry.get("title") == ref:
136
+ return entry
137
+ return None
138
+
139
+ def edit(self, memory_id, content=None, title=None, source=None, metadata=None):
140
+ entry = self.find(memory_id)
141
+ if entry is None:
142
+ raise KeyError(memory_id)
143
+ if content is not None and content != entry.get("content"):
144
+ entry.content = content
145
+ entry.embedding = None
146
+ self.ensure_embedding(entry)
147
+ if title is not None:
148
+ entry.title = title
149
+ if source is not None:
150
+ entry.source = source
151
+ if metadata is not None:
152
+ entry.metadata = metadata
153
+ entry.timestamp = timestamp()
154
+ self.dump()
155
+ return entry
156
+
157
+ def delete(self, memory_id):
158
+ entry = self.find(memory_id)
159
+ if entry is None:
160
+ return None
161
+ self.messages = [
162
+ message for message in self.messages
163
+ if message.get("id") != entry.get("id")
164
+ ]
165
+ self.dump()
166
+ return entry
167
+
168
+ def ensure_embedding(self, message):
169
+ if message.get("embedding") is not None:
170
+ return message.embedding
171
+ if self.agent is None:
172
+ raise ValueError("Message has no embedding and no agent was provided to embed it.")
173
+ return self.agent.ai.embed_message(
174
+ message,
175
+ precision=self.settings.precision,
176
+ dimensions=self.settings.dimensions,
177
+ )
178
+
179
+ def ensure_embeddings(self, messages):
180
+ for message in messages:
181
+ self.ensure_embedding(message)
182
+
183
+ def similarity(self, msg1, msg2):
184
+ self.ensure_embedding(msg1)
185
+ self.ensure_embedding(msg2)
186
+ return float((np.dot(msg1.embedding, msg2.embedding) + 1) / 2)
187
+
188
+ @staticmethod
189
+ def recency_weight(k: int, n: int, half_life: float) -> float:
190
+ if n == 1:
191
+ return 1
192
+ if not 0 <= k < n:
193
+ raise ValueError("k must be between in range(n)")
194
+ if half_life <= 0:
195
+ raise ValueError("half_life must be positive")
196
+
197
+ decay = np.log(2) / half_life
198
+ ratio = np.exp(-decay)
199
+ numerator = (1 - ratio) * ratio**k
200
+ denominator = 1 - ratio**n
201
+ return float(numerator / denominator)
202
+
203
+ def relevance_score(self, message, context, exponent=None):
204
+ if not context:
205
+ return 0
206
+ exponent = exponent or self.settings.moment_exponent
207
+ n = len(context)
208
+ similarities = [self.similarity(message, ctx_msg) for ctx_msg in reversed(context)]
209
+ weights = [self.recency_weight(k, n, self.settings.half_life_steps) for k in range(n)]
210
+ score = sum(
211
+ weight * similarity**exponent
212
+ for weight, similarity in zip(weights, similarities)
213
+ )
214
+ return float(score ** (1.0 / exponent))
215
+
216
+ def temporal_score(self, message, now=None):
217
+ now = now or datetime.now()
218
+ timestamp = message.get("timestamp")
219
+ msg_time = self.parse_timestamp(timestamp)
220
+ delta_days = (now - msg_time).total_seconds() / (3600 * 24)
221
+ decay = np.log(2) / self.settings.half_life_days
222
+ return float(np.exp(-decay * delta_days))
223
+
224
+ @staticmethod
225
+ def parse_timestamp(value):
226
+ if isinstance(value, datetime):
227
+ return value
228
+ return datetime.strptime(value, "%Y%m%dT%H%M%S.%f")
229
+
230
+ def filter_by_timestamp(self, messages, since=None, until=None):
231
+ if since is None and until is None:
232
+ return list(messages)
233
+ since_time = self.parse_timestamp(since) if since is not None else None
234
+ until_time = self.parse_timestamp(until) if until is not None else None
235
+ filtered = []
236
+ for message in messages:
237
+ msg_time = self.parse_timestamp(message.get("timestamp"))
238
+ if since_time is not None and msg_time < since_time:
239
+ continue
240
+ if until_time is not None and msg_time > until_time:
241
+ continue
242
+ filtered.append(message)
243
+ return filtered
244
+
245
+ def score(self, message, context, recent_is_better=True, now=None):
246
+ score = self.relevance_score(message, context=context)
247
+ if recent_is_better:
248
+ score += self.temporal_score(message, now=now)
249
+ return score
250
+
251
+ def rank(self, context, messages=None, recent_is_better=True, now=None):
252
+ messages = list(self.messages if messages is None else messages)
253
+ self.ensure_embeddings(context)
254
+ self.ensure_embeddings(messages)
255
+ return sorted(
256
+ messages,
257
+ key=lambda message: self.score(
258
+ message,
259
+ context=context,
260
+ recent_is_better=recent_is_better,
261
+ now=now,
262
+ ),
263
+ reverse=True,
264
+ )
265
+
266
+ def retrieve(
267
+ self,
268
+ context,
269
+ messages=None,
270
+ limit=None,
271
+ max_tokens=None,
272
+ recent_is_better=True,
273
+ exclude_session_id=None,
274
+ since=None,
275
+ until=None,
276
+ now=None,
277
+ ):
278
+ if messages is None:
279
+ messages = self.filter_by_timestamp(self.messages, since=since, until=until)
280
+ else:
281
+ messages = self.filter_by_timestamp(messages, since=since, until=until)
282
+ results = self.rank(
283
+ context,
284
+ messages=messages,
285
+ recent_is_better=recent_is_better,
286
+ now=now,
287
+ )
288
+ if exclude_session_id is not None:
289
+ results = [
290
+ entry for entry in results
291
+ if entry.get("session_id") != exclude_session_id
292
+ ]
293
+
294
+ selected = []
295
+ total = 0
296
+ token_budget = self.settings.max_tokens if max_tokens is None else max_tokens
297
+ for entry in results:
298
+ if limit is not None and len(selected) >= limit:
299
+ break
300
+ if token_budget is not None:
301
+ tokens = token_count(str(entry.get("content") or ""))
302
+ if total + tokens > token_budget:
303
+ break
304
+ total += tokens
305
+ selected.append(entry)
306
+ return selected
307
+
308
+ def search(self, query, limit=5, recent_is_better=False, since=None, until=None, **kwargs):
309
+ query_message = Message(content=query)
310
+ self.ensure_embedding(query_message)
311
+ return self.retrieve(
312
+ [query_message],
313
+ limit=limit,
314
+ recent_is_better=recent_is_better,
315
+ since=since,
316
+ until=until,
317
+ **kwargs,
318
+ )