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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any