memex-python 0.13.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.
- memex/__init__.py +336 -0
- memex/_time.py +26 -0
- memex/_uuid.py +62 -0
- memex/bulk.py +138 -0
- memex/commands.py +75 -0
- memex/envelope.py +69 -0
- memex/errors.py +51 -0
- memex/factories.py +97 -0
- memex/graph.py +30 -0
- memex/integrity.py +317 -0
- memex/intent.py +318 -0
- memex/models.py +271 -0
- memex/query.py +435 -0
- memex/reducer.py +151 -0
- memex/replay.py +144 -0
- memex/retrieval.py +266 -0
- memex/schemas.py +67 -0
- memex/serialization.py +47 -0
- memex/stats.py +71 -0
- memex/store.py +222 -0
- memex/task.py +361 -0
- memex/transplant.py +480 -0
- memex_python-0.13.0.dist-info/METADATA +150 -0
- memex_python-0.13.0.dist-info/RECORD +26 -0
- memex_python-0.13.0.dist-info/WHEEL +4 -0
- memex_python-0.13.0.dist-info/licenses/LICENSE +190 -0
memex/transplant.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Transplant — export a slice of the three graphs and import it elsewhere.
|
|
2
|
+
|
|
3
|
+
The import side optionally re-ids colliding entities (``re_id_on_difference``),
|
|
4
|
+
and a per-graph re-id *pre-pass* populates the id maps before anything is
|
|
5
|
+
processed so cross-references (``parents`` / ``parent_id`` / ``intent_id`` /
|
|
6
|
+
memory id lists) remap correctly regardless of slice ordering.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, NamedTuple
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from ._uuid import uuid7
|
|
17
|
+
from .errors import InvalidTimestampError
|
|
18
|
+
from .graph import GraphState
|
|
19
|
+
from .intent import Intent, IntentState, apply_intent_command
|
|
20
|
+
from .models import Edge, MemoryItem
|
|
21
|
+
from .query import extract_timestamp, get_children, get_edges
|
|
22
|
+
from .reducer import apply_command
|
|
23
|
+
from .task import Task, TaskState, apply_task_command
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ExportOptions",
|
|
27
|
+
"MemexExport",
|
|
28
|
+
"ImportOptions",
|
|
29
|
+
"ImportBucket",
|
|
30
|
+
"ImportReport",
|
|
31
|
+
"ImportResult",
|
|
32
|
+
"export_slice",
|
|
33
|
+
"import_slice",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Re-id helpers
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _uuid_from_ms(ms: int) -> str:
|
|
43
|
+
"""Build a UUIDv7-shaped id from a given ms timestamp + random suffix."""
|
|
44
|
+
hex_ms = format(ms, "x").rjust(12, "0")
|
|
45
|
+
rand = uuid7().replace("-", "")
|
|
46
|
+
return "-".join([
|
|
47
|
+
hex_ms[0:8],
|
|
48
|
+
hex_ms[8:12],
|
|
49
|
+
"7" + rand[13:16],
|
|
50
|
+
rand[16:20],
|
|
51
|
+
rand[20:32],
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _re_id_for(original_id: str, existing_ids: set[str], created_at: int | None = None) -> str:
|
|
56
|
+
"""Generate a fresh id 1ms after the original, incrementing on collision."""
|
|
57
|
+
if created_at is None:
|
|
58
|
+
try:
|
|
59
|
+
created_at = extract_timestamp(original_id)
|
|
60
|
+
except InvalidTimestampError as err:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f'Cannot re-id "{original_id}": provide created_at or use a UUIDv7 id'
|
|
63
|
+
) from err
|
|
64
|
+
ms = created_at + 1
|
|
65
|
+
new_id = _uuid_from_ms(ms)
|
|
66
|
+
while new_id in existing_ids:
|
|
67
|
+
ms += 1
|
|
68
|
+
new_id = _uuid_from_ms(ms)
|
|
69
|
+
return new_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rewrite_id(id_: str, id_map: dict[str, str]) -> str:
|
|
73
|
+
return id_map.get(id_, id_)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _rewrite_ids(ids: list[str] | None, id_map: dict[str, str]) -> list[str] | None:
|
|
77
|
+
if ids is None:
|
|
78
|
+
return None
|
|
79
|
+
return [id_map.get(i, i) for i in ids]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Export
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ExportOptions(BaseModel):
|
|
88
|
+
memory_ids: list[str] | None = None
|
|
89
|
+
intent_ids: list[str] | None = None
|
|
90
|
+
task_ids: list[str] | None = None
|
|
91
|
+
include_parents: bool = False
|
|
92
|
+
include_children: bool = False
|
|
93
|
+
include_aliases: bool = False
|
|
94
|
+
include_related_tasks: bool = False
|
|
95
|
+
include_related_intents: bool = False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MemexExport(BaseModel):
|
|
99
|
+
memories: list[MemoryItem] = []
|
|
100
|
+
edges: list[Edge] = []
|
|
101
|
+
intents: list[Intent] = []
|
|
102
|
+
tasks: list[Task] = []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def export_slice(
|
|
106
|
+
mem_state: GraphState,
|
|
107
|
+
intent_state: IntentState,
|
|
108
|
+
task_state: TaskState,
|
|
109
|
+
*,
|
|
110
|
+
memory_ids: list[str] | None = None,
|
|
111
|
+
intent_ids: list[str] | None = None,
|
|
112
|
+
task_ids: list[str] | None = None,
|
|
113
|
+
include_parents: bool = False,
|
|
114
|
+
include_children: bool = False,
|
|
115
|
+
include_aliases: bool = False,
|
|
116
|
+
include_related_tasks: bool = False,
|
|
117
|
+
include_related_intents: bool = False,
|
|
118
|
+
) -> MemexExport:
|
|
119
|
+
memory_id_set: set[str] = set(memory_ids or [])
|
|
120
|
+
intent_id_set: set[str] = set(intent_ids or [])
|
|
121
|
+
task_id_set: set[str] = set(task_ids or [])
|
|
122
|
+
edge_id_set: set[str] = set()
|
|
123
|
+
|
|
124
|
+
# walk parents up-graph
|
|
125
|
+
if include_parents:
|
|
126
|
+
queue = list(memory_id_set)
|
|
127
|
+
while queue:
|
|
128
|
+
id_ = queue.pop()
|
|
129
|
+
item = mem_state.items.get(id_)
|
|
130
|
+
if item and item.parents:
|
|
131
|
+
for pid in item.parents:
|
|
132
|
+
if pid not in memory_id_set:
|
|
133
|
+
memory_id_set.add(pid)
|
|
134
|
+
queue.append(pid)
|
|
135
|
+
|
|
136
|
+
# walk children down-graph
|
|
137
|
+
if include_children:
|
|
138
|
+
queue = list(memory_id_set)
|
|
139
|
+
while queue:
|
|
140
|
+
id_ = queue.pop()
|
|
141
|
+
for child in get_children(mem_state, id_):
|
|
142
|
+
if child.id not in memory_id_set:
|
|
143
|
+
memory_id_set.add(child.id)
|
|
144
|
+
queue.append(child.id)
|
|
145
|
+
|
|
146
|
+
# walk aliases (both directions)
|
|
147
|
+
if include_aliases:
|
|
148
|
+
queue = list(memory_id_set)
|
|
149
|
+
visited: set[str] = set()
|
|
150
|
+
while queue:
|
|
151
|
+
id_ = queue.pop()
|
|
152
|
+
if id_ in visited:
|
|
153
|
+
continue
|
|
154
|
+
visited.add(id_)
|
|
155
|
+
for edge in get_edges(mem_state, {"from": id_, "kind": "ALIAS", "active_only": True}):
|
|
156
|
+
edge_id_set.add(edge.edge_id)
|
|
157
|
+
if edge.to not in memory_id_set:
|
|
158
|
+
memory_id_set.add(edge.to)
|
|
159
|
+
queue.append(edge.to)
|
|
160
|
+
for edge in get_edges(mem_state, {"to": id_, "kind": "ALIAS", "active_only": True}):
|
|
161
|
+
edge_id_set.add(edge.edge_id)
|
|
162
|
+
if edge.from_ not in memory_id_set:
|
|
163
|
+
memory_id_set.add(edge.from_)
|
|
164
|
+
queue.append(edge.from_)
|
|
165
|
+
|
|
166
|
+
# collect edges between included memories
|
|
167
|
+
for edge in mem_state.edges.values():
|
|
168
|
+
if edge.from_ in memory_id_set and edge.to in memory_id_set:
|
|
169
|
+
edge_id_set.add(edge.edge_id)
|
|
170
|
+
|
|
171
|
+
# walk related intents
|
|
172
|
+
if include_related_intents:
|
|
173
|
+
for intent in intent_state.intents.values():
|
|
174
|
+
if intent.root_memory_ids:
|
|
175
|
+
for mid in intent.root_memory_ids:
|
|
176
|
+
if mid in memory_id_set:
|
|
177
|
+
intent_id_set.add(intent.id)
|
|
178
|
+
break
|
|
179
|
+
for mid in memory_id_set:
|
|
180
|
+
item = mem_state.items.get(mid)
|
|
181
|
+
if item and item.intent_id:
|
|
182
|
+
intent_id_set.add(item.intent_id)
|
|
183
|
+
|
|
184
|
+
# walk related tasks
|
|
185
|
+
if include_related_tasks:
|
|
186
|
+
for task in task_state.tasks.values():
|
|
187
|
+
if task.intent_id in intent_id_set:
|
|
188
|
+
task_id_set.add(task.id)
|
|
189
|
+
continue
|
|
190
|
+
input_match = task.input_memory_ids and any(mid in memory_id_set for mid in task.input_memory_ids)
|
|
191
|
+
output_match = task.output_memory_ids and any(mid in memory_id_set for mid in task.output_memory_ids)
|
|
192
|
+
if input_match or output_match:
|
|
193
|
+
task_id_set.add(task.id)
|
|
194
|
+
for mid in memory_id_set:
|
|
195
|
+
item = mem_state.items.get(mid)
|
|
196
|
+
if item and item.task_id:
|
|
197
|
+
task_id_set.add(item.task_id)
|
|
198
|
+
|
|
199
|
+
memories = [mem_state.items[i] for i in memory_id_set if i in mem_state.items]
|
|
200
|
+
edges = [mem_state.edges[i] for i in edge_id_set if i in mem_state.edges]
|
|
201
|
+
intents = [intent_state.intents[i] for i in intent_id_set if i in intent_state.intents]
|
|
202
|
+
tasks = [task_state.tasks[i] for i in task_id_set if i in task_state.tasks]
|
|
203
|
+
|
|
204
|
+
return MemexExport(memories=memories, edges=edges, intents=intents, tasks=tasks)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Import
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ImportOptions(BaseModel):
|
|
213
|
+
skip_existing_ids: bool = True
|
|
214
|
+
shallow_compare_existing: bool = False
|
|
215
|
+
re_id_on_difference: bool = False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class ImportBucket:
|
|
220
|
+
memories: list[str] = field(default_factory=list)
|
|
221
|
+
intents: list[str] = field(default_factory=list)
|
|
222
|
+
tasks: list[str] = field(default_factory=list)
|
|
223
|
+
edges: list[str] = field(default_factory=list)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class ImportReport:
|
|
228
|
+
created: ImportBucket = field(default_factory=ImportBucket)
|
|
229
|
+
updated: ImportBucket = field(default_factory=ImportBucket)
|
|
230
|
+
skipped: ImportBucket = field(default_factory=ImportBucket)
|
|
231
|
+
conflicts: ImportBucket = field(default_factory=ImportBucket)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class ImportResult(NamedTuple):
|
|
235
|
+
mem_state: GraphState
|
|
236
|
+
intent_state: IntentState
|
|
237
|
+
task_state: TaskState
|
|
238
|
+
report: ImportReport
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _shallow_equal(a: BaseModel, b: BaseModel) -> bool:
|
|
242
|
+
"""Deep structural equality (order-independent), matching the TS helper."""
|
|
243
|
+
return a.model_dump() == b.model_dump()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def import_slice(
|
|
247
|
+
mem_state: GraphState,
|
|
248
|
+
intent_state: IntentState,
|
|
249
|
+
task_state: TaskState,
|
|
250
|
+
slice: MemexExport | dict[str, Any],
|
|
251
|
+
*,
|
|
252
|
+
skip_existing_ids: bool = True,
|
|
253
|
+
shallow_compare_existing: bool = False,
|
|
254
|
+
re_id_on_difference: bool = False,
|
|
255
|
+
) -> ImportResult:
|
|
256
|
+
sl = slice if isinstance(slice, MemexExport) else MemexExport.model_validate(slice)
|
|
257
|
+
|
|
258
|
+
skip_existing = skip_existing_ids
|
|
259
|
+
shallow_compare = shallow_compare_existing
|
|
260
|
+
do_re_id = re_id_on_difference
|
|
261
|
+
|
|
262
|
+
report = ImportReport()
|
|
263
|
+
|
|
264
|
+
mem_id_map: dict[str, str] = {}
|
|
265
|
+
intent_id_map: dict[str, str] = {}
|
|
266
|
+
task_id_map: dict[str, str] = {}
|
|
267
|
+
|
|
268
|
+
all_mem_ids = set(mem_state.items.keys())
|
|
269
|
+
all_intent_ids = set(intent_state.intents.keys())
|
|
270
|
+
all_task_ids = set(task_state.tasks.keys())
|
|
271
|
+
|
|
272
|
+
current_mem = mem_state
|
|
273
|
+
current_intent = intent_state
|
|
274
|
+
current_task = task_state
|
|
275
|
+
|
|
276
|
+
# --- memory re-id pre-pass ---
|
|
277
|
+
if skip_existing and shallow_compare and do_re_id:
|
|
278
|
+
for item in sl.memories:
|
|
279
|
+
existing = mem_state.items.get(item.id)
|
|
280
|
+
if existing is not None and not _shallow_equal(existing, item):
|
|
281
|
+
new_id = _re_id_for(item.id, all_mem_ids, item.created_at)
|
|
282
|
+
all_mem_ids.add(new_id)
|
|
283
|
+
mem_id_map[item.id] = new_id
|
|
284
|
+
|
|
285
|
+
# --- import memories ---
|
|
286
|
+
for item in sl.memories:
|
|
287
|
+
existing = current_mem.items.get(item.id)
|
|
288
|
+
if existing is not None:
|
|
289
|
+
if skip_existing:
|
|
290
|
+
if shallow_compare and not _shallow_equal(existing, item):
|
|
291
|
+
if do_re_id:
|
|
292
|
+
new_id = mem_id_map[item.id]
|
|
293
|
+
remapped = item.model_copy(update={
|
|
294
|
+
"id": new_id,
|
|
295
|
+
"parents": _rewrite_ids(item.parents, mem_id_map),
|
|
296
|
+
})
|
|
297
|
+
current_mem = apply_command(current_mem, {"type": "memory.create", "item": remapped}).state
|
|
298
|
+
report.created.memories.append(new_id)
|
|
299
|
+
else:
|
|
300
|
+
report.conflicts.memories.append(item.id)
|
|
301
|
+
else:
|
|
302
|
+
report.skipped.memories.append(item.id)
|
|
303
|
+
continue
|
|
304
|
+
# skip_existing False — update existing item
|
|
305
|
+
partial = item.model_dump(exclude_none=True)
|
|
306
|
+
partial.pop("id", None)
|
|
307
|
+
rewritten = _rewrite_ids(item.parents, mem_id_map)
|
|
308
|
+
if rewritten is not None:
|
|
309
|
+
partial["parents"] = rewritten
|
|
310
|
+
current_mem = apply_command(
|
|
311
|
+
current_mem,
|
|
312
|
+
{"type": "memory.update", "item_id": item.id, "partial": partial, "author": item.author},
|
|
313
|
+
).state
|
|
314
|
+
report.updated.memories.append(item.id)
|
|
315
|
+
continue
|
|
316
|
+
# no collision — create
|
|
317
|
+
remapped = item.model_copy(update={"parents": _rewrite_ids(item.parents, mem_id_map)})
|
|
318
|
+
current_mem = apply_command(current_mem, {"type": "memory.create", "item": remapped}).state
|
|
319
|
+
report.created.memories.append(item.id)
|
|
320
|
+
|
|
321
|
+
all_edge_ids = set(current_mem.edges.keys())
|
|
322
|
+
|
|
323
|
+
# --- import edges ---
|
|
324
|
+
for edge in sl.edges:
|
|
325
|
+
existing_edge = current_mem.edges.get(edge.edge_id)
|
|
326
|
+
if existing_edge is not None:
|
|
327
|
+
if skip_existing:
|
|
328
|
+
if shallow_compare and not _shallow_equal(existing_edge, edge):
|
|
329
|
+
if do_re_id:
|
|
330
|
+
new_id = _re_id_for(edge.edge_id, all_edge_ids)
|
|
331
|
+
all_edge_ids.add(new_id)
|
|
332
|
+
remapped_edge = edge.model_copy(update={
|
|
333
|
+
"edge_id": new_id,
|
|
334
|
+
"from_": _rewrite_id(edge.from_, mem_id_map),
|
|
335
|
+
"to": _rewrite_id(edge.to, mem_id_map),
|
|
336
|
+
})
|
|
337
|
+
current_mem = apply_command(current_mem, {"type": "edge.create", "edge": remapped_edge}).state
|
|
338
|
+
report.created.edges.append(new_id)
|
|
339
|
+
else:
|
|
340
|
+
report.conflicts.edges.append(edge.edge_id)
|
|
341
|
+
else:
|
|
342
|
+
report.skipped.edges.append(edge.edge_id)
|
|
343
|
+
continue
|
|
344
|
+
partial = edge.model_dump(exclude_none=True)
|
|
345
|
+
for k in ("edge_id", "from", "from_", "to"):
|
|
346
|
+
partial.pop(k, None)
|
|
347
|
+
current_mem = apply_command(
|
|
348
|
+
current_mem,
|
|
349
|
+
{"type": "edge.update", "edge_id": edge.edge_id, "partial": partial, "author": edge.author},
|
|
350
|
+
).state
|
|
351
|
+
report.updated.edges.append(edge.edge_id)
|
|
352
|
+
continue
|
|
353
|
+
remapped_edge = edge.model_copy(update={
|
|
354
|
+
"from_": _rewrite_id(edge.from_, mem_id_map),
|
|
355
|
+
"to": _rewrite_id(edge.to, mem_id_map),
|
|
356
|
+
})
|
|
357
|
+
current_mem = apply_command(current_mem, {"type": "edge.create", "edge": remapped_edge}).state
|
|
358
|
+
report.created.edges.append(edge.edge_id)
|
|
359
|
+
|
|
360
|
+
# --- intent re-id pre-pass ---
|
|
361
|
+
if skip_existing and shallow_compare and do_re_id:
|
|
362
|
+
for intent in sl.intents:
|
|
363
|
+
existing_intent = current_intent.intents.get(intent.id)
|
|
364
|
+
if existing_intent is not None and not _shallow_equal(existing_intent, intent):
|
|
365
|
+
new_id = _re_id_for(intent.id, all_intent_ids)
|
|
366
|
+
all_intent_ids.add(new_id)
|
|
367
|
+
intent_id_map[intent.id] = new_id
|
|
368
|
+
|
|
369
|
+
# --- import intents ---
|
|
370
|
+
for intent in sl.intents:
|
|
371
|
+
existing_intent = current_intent.intents.get(intent.id)
|
|
372
|
+
if existing_intent is not None:
|
|
373
|
+
if skip_existing:
|
|
374
|
+
if shallow_compare and not _shallow_equal(existing_intent, intent):
|
|
375
|
+
if do_re_id:
|
|
376
|
+
new_id = intent_id_map[intent.id]
|
|
377
|
+
remapped_intent = intent.model_copy(update={
|
|
378
|
+
"id": new_id,
|
|
379
|
+
"parent_id": _rewrite_id(intent.parent_id, intent_id_map) if intent.parent_id else None,
|
|
380
|
+
"root_memory_ids": _rewrite_ids(intent.root_memory_ids, mem_id_map),
|
|
381
|
+
})
|
|
382
|
+
current_intent = apply_intent_command(current_intent, {"type": "intent.create", "intent": remapped_intent}).state
|
|
383
|
+
report.created.intents.append(new_id)
|
|
384
|
+
else:
|
|
385
|
+
report.conflicts.intents.append(intent.id)
|
|
386
|
+
else:
|
|
387
|
+
report.skipped.intents.append(intent.id)
|
|
388
|
+
continue
|
|
389
|
+
partial = intent.model_dump(exclude_none=True)
|
|
390
|
+
for k in ("id", "status"):
|
|
391
|
+
partial.pop(k, None)
|
|
392
|
+
partial["parent_id"] = _rewrite_id(intent.parent_id, intent_id_map) if intent.parent_id else None
|
|
393
|
+
partial["root_memory_ids"] = _rewrite_ids(intent.root_memory_ids, mem_id_map)
|
|
394
|
+
current_intent = apply_intent_command(
|
|
395
|
+
current_intent,
|
|
396
|
+
{"type": "intent.update", "intent_id": intent.id, "partial": partial, "author": intent.owner},
|
|
397
|
+
).state
|
|
398
|
+
report.updated.intents.append(intent.id)
|
|
399
|
+
continue
|
|
400
|
+
remapped_intent = intent.model_copy(update={
|
|
401
|
+
"parent_id": _rewrite_id(intent.parent_id, intent_id_map) if intent.parent_id else None,
|
|
402
|
+
"root_memory_ids": _rewrite_ids(intent.root_memory_ids, mem_id_map),
|
|
403
|
+
})
|
|
404
|
+
current_intent = apply_intent_command(current_intent, {"type": "intent.create", "intent": remapped_intent}).state
|
|
405
|
+
report.created.intents.append(intent.id)
|
|
406
|
+
|
|
407
|
+
# --- task re-id pre-pass ---
|
|
408
|
+
if skip_existing and shallow_compare and do_re_id:
|
|
409
|
+
for task in sl.tasks:
|
|
410
|
+
existing_task = current_task.tasks.get(task.id)
|
|
411
|
+
if existing_task is not None and not _shallow_equal(existing_task, task):
|
|
412
|
+
new_id = _re_id_for(task.id, all_task_ids)
|
|
413
|
+
all_task_ids.add(new_id)
|
|
414
|
+
task_id_map[task.id] = new_id
|
|
415
|
+
|
|
416
|
+
# --- import tasks ---
|
|
417
|
+
for task in sl.tasks:
|
|
418
|
+
existing_task = current_task.tasks.get(task.id)
|
|
419
|
+
if existing_task is not None:
|
|
420
|
+
if skip_existing:
|
|
421
|
+
if shallow_compare and not _shallow_equal(existing_task, task):
|
|
422
|
+
if do_re_id:
|
|
423
|
+
new_id = task_id_map[task.id]
|
|
424
|
+
remapped_task = task.model_copy(update={
|
|
425
|
+
"id": new_id,
|
|
426
|
+
"intent_id": _rewrite_id(task.intent_id, intent_id_map),
|
|
427
|
+
"parent_id": _rewrite_id(task.parent_id, task_id_map) if task.parent_id else None,
|
|
428
|
+
"input_memory_ids": _rewrite_ids(task.input_memory_ids, mem_id_map),
|
|
429
|
+
"output_memory_ids": _rewrite_ids(task.output_memory_ids, mem_id_map),
|
|
430
|
+
})
|
|
431
|
+
current_task = apply_task_command(current_task, {"type": "task.create", "task": remapped_task}).state
|
|
432
|
+
report.created.tasks.append(new_id)
|
|
433
|
+
else:
|
|
434
|
+
report.conflicts.tasks.append(task.id)
|
|
435
|
+
else:
|
|
436
|
+
report.skipped.tasks.append(task.id)
|
|
437
|
+
continue
|
|
438
|
+
partial = task.model_dump(exclude_none=True)
|
|
439
|
+
for k in ("id", "status"):
|
|
440
|
+
partial.pop(k, None)
|
|
441
|
+
partial["intent_id"] = _rewrite_id(task.intent_id, intent_id_map)
|
|
442
|
+
partial["parent_id"] = _rewrite_id(task.parent_id, task_id_map) if task.parent_id else None
|
|
443
|
+
partial["input_memory_ids"] = _rewrite_ids(task.input_memory_ids, mem_id_map)
|
|
444
|
+
partial["output_memory_ids"] = _rewrite_ids(task.output_memory_ids, mem_id_map)
|
|
445
|
+
current_task = apply_task_command(
|
|
446
|
+
current_task,
|
|
447
|
+
{"type": "task.update", "task_id": task.id, "partial": partial, "author": task.agent_id or "system:import"},
|
|
448
|
+
).state
|
|
449
|
+
report.updated.tasks.append(task.id)
|
|
450
|
+
continue
|
|
451
|
+
remapped_task = task.model_copy(update={
|
|
452
|
+
"intent_id": _rewrite_id(task.intent_id, intent_id_map),
|
|
453
|
+
"parent_id": _rewrite_id(task.parent_id, task_id_map) if task.parent_id else None,
|
|
454
|
+
"input_memory_ids": _rewrite_ids(task.input_memory_ids, mem_id_map),
|
|
455
|
+
"output_memory_ids": _rewrite_ids(task.output_memory_ids, mem_id_map),
|
|
456
|
+
})
|
|
457
|
+
current_task = apply_task_command(current_task, {"type": "task.create", "task": remapped_task}).state
|
|
458
|
+
report.created.tasks.append(task.id)
|
|
459
|
+
|
|
460
|
+
# --- second pass: remap intent_id / task_id on imported memories ---
|
|
461
|
+
if intent_id_map or task_id_map:
|
|
462
|
+
imported_mem_ids = [*report.created.memories, *report.updated.memories]
|
|
463
|
+
for mem_id in imported_mem_ids:
|
|
464
|
+
stored_item = current_mem.items.get(mem_id)
|
|
465
|
+
if stored_item is None:
|
|
466
|
+
continue
|
|
467
|
+
new_intent_id = intent_id_map.get(stored_item.intent_id) if stored_item.intent_id else None
|
|
468
|
+
new_task_id = task_id_map.get(stored_item.task_id) if stored_item.task_id else None
|
|
469
|
+
if new_intent_id or new_task_id:
|
|
470
|
+
partial = {}
|
|
471
|
+
if new_intent_id:
|
|
472
|
+
partial["intent_id"] = new_intent_id
|
|
473
|
+
if new_task_id:
|
|
474
|
+
partial["task_id"] = new_task_id
|
|
475
|
+
current_mem = apply_command(
|
|
476
|
+
current_mem,
|
|
477
|
+
{"type": "memory.update", "item_id": mem_id, "partial": partial, "author": "system:import"},
|
|
478
|
+
).state
|
|
479
|
+
|
|
480
|
+
return ImportResult(current_mem, current_intent, current_task, report)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memex-python
|
|
3
|
+
Version: 0.13.0
|
|
4
|
+
Summary: MemEX memory layer (Pydantic port) — Multi-session continuity for AI systems
|
|
5
|
+
Author: ai2070
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agents,ai,knowledge-graph,memory,pydantic
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pydantic>=2.6
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
16
|
+
Provides-Extra: fast-uuid
|
|
17
|
+
Requires-Dist: uuid-utils>=0.7; extra == 'fast-uuid'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# memex-python
|
|
21
|
+
|
|
22
|
+
**Structured, provenance-tracked memory for AI agents — a faithful Pydantic port of [`@ai2070/memex`](https://www.npmjs.com/package/@ai2070/memex).**
|
|
23
|
+
|
|
24
|
+
MemEX stores beliefs, evidence, conflicts, and updates — not just retrieved text. It separates three graphs (what is *believed* / *wanted* / *done*) and makes retrieval, contradiction, decay, and identity first-class. This is the Python implementation: every typed structure is a Pydantic v2 model, and behavior matches the TypeScript original (the full upstream test suite is ported — **575 tests**).
|
|
25
|
+
|
|
26
|
+
- **Pure & immutable** — every mutation is `apply_command(state, cmd) -> (new_state, events)`.
|
|
27
|
+
- **Pydantic-native** — models validate on construction; the discriminated-union commands *are* the schema.
|
|
28
|
+
- **Wire-compatible** — command tags, enum values, and JSON keys are byte-identical to the TS library, so a Python service and a TS service can share one event log.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install memex-python # import name: `memex`
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Only runtime dependency: `pydantic>=2.6`.
|
|
37
|
+
|
|
38
|
+
## Quickstart (functional core)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from memex import create_graph_state, create_memory_item, apply_command, get_scored_items
|
|
42
|
+
|
|
43
|
+
state = create_graph_state()
|
|
44
|
+
|
|
45
|
+
obs = create_memory_item(
|
|
46
|
+
scope="user:laz/general",
|
|
47
|
+
kind="observation",
|
|
48
|
+
content={"key": "login_count", "value": 42},
|
|
49
|
+
author="agent:monitor",
|
|
50
|
+
source_kind="observed",
|
|
51
|
+
authority=0.9,
|
|
52
|
+
importance=0.7,
|
|
53
|
+
)
|
|
54
|
+
state, events = apply_command(state, {"type": "memory.create", "item": obs})
|
|
55
|
+
|
|
56
|
+
top = get_scored_items(state, {"authority": 1.0, "importance": 0.5})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Filters, options, and weights accept plain dicts *or* the typed models:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from memex import get_items, smart_retrieve
|
|
63
|
+
|
|
64
|
+
recent = get_items(state, {"kind": "observation", "range": {"authority": {"min": 0.5}}})
|
|
65
|
+
|
|
66
|
+
packed = smart_retrieve(
|
|
67
|
+
state,
|
|
68
|
+
budget=2000,
|
|
69
|
+
cost_fn=lambda item: len(str(item.content)),
|
|
70
|
+
weights={"authority": 0.6, "importance": 0.4},
|
|
71
|
+
contradictions="surface", # or "filter"
|
|
72
|
+
diversity={"author_penalty": 0.2},
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Quickstart (`MemexStore` facade)
|
|
77
|
+
|
|
78
|
+
For a stateful, object-oriented surface that rebinds state for you:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from memex import MemexStore
|
|
82
|
+
|
|
83
|
+
store = MemexStore()
|
|
84
|
+
a = store.create(scope="user:laz", kind="observation", content={"v": 1},
|
|
85
|
+
author="agent:x", source_kind="observed", authority=0.9)
|
|
86
|
+
b = store.create(scope="user:laz", kind="assertion", content={"v": 2},
|
|
87
|
+
author="agent:y", source_kind="user_explicit", authority=0.4)
|
|
88
|
+
|
|
89
|
+
store.mark_contradiction(a.id, b.id, "system:detector")
|
|
90
|
+
store.resolve_contradiction(a.id, b.id, "system:resolver") # b's authority drops
|
|
91
|
+
|
|
92
|
+
intent = store.create_intent(label="find target", priority=0.9, owner="user:laz")
|
|
93
|
+
task = store.create_task(intent_id=intent.id, action="search", priority=0.8)
|
|
94
|
+
|
|
95
|
+
snapshot = store.dumps(pretty=True) # JSON; MemexStore.loads(...) restores
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## The three graphs
|
|
99
|
+
|
|
100
|
+
| Graph | Reducer | Core type | Holds |
|
|
101
|
+
|--------|-------------------------|--------------|-----------------------------------------|
|
|
102
|
+
| Memory | `apply_command` | `MemoryItem` | beliefs, evidence, contradictions, edges|
|
|
103
|
+
| Intent | `apply_intent_command` | `Intent` | active goals with a status machine |
|
|
104
|
+
| Task | `apply_task_command` | `Task` | units of work tied to intents |
|
|
105
|
+
|
|
106
|
+
Each item carries three orthogonal `0..1` scores — **authority** (trust), **conviction** (author confidence), **importance** (current salience) — plus `kind`, `source_kind`, `parents` (provenance), and typed `edges` (`DERIVED_FROM`, `CONTRADICTS`, `SUPPORTS`, `ABOUT`, `SUPERSEDES`, `ALIAS`).
|
|
107
|
+
|
|
108
|
+
## Validating external input
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from memex.schemas import validate_command
|
|
112
|
+
from memex import apply_command
|
|
113
|
+
|
|
114
|
+
cmd = validate_command(raw) # raises pydantic.ValidationError on a bad shape
|
|
115
|
+
state = apply_command(state, cmd).state
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Intentional divergences from the TS library
|
|
119
|
+
|
|
120
|
+
1. **Always-on validation** — constructing any model with an out-of-range score raises `pydantic.ValidationError` (TS only validated inside the factories). Use `Model.model_construct(...)` to bypass for deliberately-invalid fixtures.
|
|
121
|
+
2. **Error types** — score-bound violations surface as `ValidationError`; `cost_fn` contract violations and unknown sort/decay enums as `ValueError` (the TS `RangeError`).
|
|
122
|
+
3. **Frozen entities** — `MemoryItem` / `Edge` / `Intent` / `Task` are immutable; "edits" produce new instances (`model_copy(update=...)`).
|
|
123
|
+
4. **`from_` field** — `from` is a Python keyword, so `Edge.from_` is the attribute; it serializes to `"from"` via its alias.
|
|
124
|
+
|
|
125
|
+
Everything else — command tags, JSON shape, scoring/decay math, contradiction determinism, replay tolerance, transplant re-id semantics — matches the TS library exactly.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
CI uses [uv](https://docs.astral.sh/uv/) (see `.github/workflows/`):
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
uv sync --all-extras
|
|
133
|
+
uv run ruff check .
|
|
134
|
+
uv run mypy
|
|
135
|
+
uv run pytest # 582 tests
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Or with plain pip:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
pip install -e ".[dev]"
|
|
142
|
+
pytest
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Releases publish to PyPI via `release.yml` (trusted publishing) on a published
|
|
146
|
+
GitHub release, or manually via `workflow_dispatch`.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
Apache-2.0 (matching upstream).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
memex/__init__.py,sha256=gjFi-oJ0VkC4plkpQAGLRbYeH47e_-6RwRb7lpGP4cE,7049
|
|
2
|
+
memex/_time.py,sha256=vL2v-X1V_bfBk6YmA1odOLB3enD8B93YHVpoEjRxl5E,813
|
|
3
|
+
memex/_uuid.py,sha256=CMwTk0mal31OaxDV2mDvvYZzNKmrpfBw3y8L2b1b270,1802
|
|
4
|
+
memex/bulk.py,sha256=qsNycVKTPX9y42GE7ppCtcDWavps53Mf8vErJID0G_8,4977
|
|
5
|
+
memex/commands.py,sha256=oxucr0T0I5-zL2uQX40NbLFYFXvTRtqd54TiQI_l7G4,1820
|
|
6
|
+
memex/envelope.py,sha256=Hawb4WwKJCkAvuLd0Yy2fUw7RRcWcIiWADM0EKmTs3Y,1957
|
|
7
|
+
memex/errors.py,sha256=SeWkp-LS480G335Tf9YFkDzHj40SOjCMVK1ZlVLkOLE,1496
|
|
8
|
+
memex/factories.py,sha256=doUQVtHZMcYFGhTB8SDATY9vARkE-_gUhVJ0c-2we8A,2481
|
|
9
|
+
memex/graph.py,sha256=cOKy4aHyGHpvpW01PQN9oSXi90dljtlCVsk_xtOm-Wo,990
|
|
10
|
+
memex/integrity.py,sha256=6ag3aWBu6D5tZF42CzBX5d3PTgJ_jrqeba0G0ZouH-o,9970
|
|
11
|
+
memex/intent.py,sha256=0fC5psB4XL4bmTwKcESwatWtJM-lxzcLAcV4w7NbIwU,9760
|
|
12
|
+
memex/models.py,sha256=y-21g1yaYDqHGJhhJxZ2sglzvmPurhZHxGQ86Bw5u6I,7165
|
|
13
|
+
memex/query.py,sha256=viA0VUfwb9T6h_P_kSXDcRQ8TKXXEWbb2v_nxkxkipA,13997
|
|
14
|
+
memex/reducer.py,sha256=LizTv0Sq0KrKI3rCdRfxqYFI5FlCjmJ9kh9VHzVKP6o,5850
|
|
15
|
+
memex/replay.py,sha256=n5STHAph9X0InwjgHbhaB6yc3lw4WH9SosS_CNQJO_4,4906
|
|
16
|
+
memex/retrieval.py,sha256=I6yGL4-k5yAcprcSH1pGat4btdI_jroo8ml_Dp_SNKw,9478
|
|
17
|
+
memex/schemas.py,sha256=Y615cHv0q-vq5HYwSZt9009Sux65G1G7eBuwsA9m_fk,1945
|
|
18
|
+
memex/serialization.py,sha256=qxDq6JUatW0MWNPBwyuceA23cUmDlY9I2BdIRvsJLLs,1540
|
|
19
|
+
memex/stats.py,sha256=FQIx22V6sukTR75lUms49q0qaw-9_DS1F6DVbyhfge4,1726
|
|
20
|
+
memex/store.py,sha256=RRQ8C4MT3J1KJBowvwI5nfloZ7HK22vZbe_DFb6EFcg,8907
|
|
21
|
+
memex/task.py,sha256=rxx5kqyVHE-R2WdytIXS8SMnDVsJ6dGK040_6CL3PMU,11863
|
|
22
|
+
memex/transplant.py,sha256=DPlEyGCXEvR4fq4QMaX_Cq2wm7z7VTiSaRTwWcQIVHM,19911
|
|
23
|
+
memex_python-0.13.0.dist-info/METADATA,sha256=ZyUKfx_hBsB0mCGiVNOQUzmX9aFMUag6ptZfz4liNHQ,5902
|
|
24
|
+
memex_python-0.13.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
25
|
+
memex_python-0.13.0.dist-info/licenses/LICENSE,sha256=oc05WPTsmK3_T41pcWhsaIIB0l3hwgn5Yaku9Iqkkv4,10757
|
|
26
|
+
memex_python-0.13.0.dist-info/RECORD,,
|