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/store.py ADDED
@@ -0,0 +1,222 @@
1
+ """``MemexStore`` — an optional stateful, OO facade over the functional core.
2
+
3
+ The functional API (``apply_command`` / ``get_items`` / ...) is the backbone and
4
+ stays pure. ``MemexStore`` holds the three graph states, rebinds them on each
5
+ mutation, and returns the emitted lifecycle events — convenient for agents and
6
+ daemons that don't want to thread ``state =`` through every call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from . import bulk, integrity, query, retrieval, serialization, stats
14
+ from .factories import create_edge, create_memory_item
15
+ from .graph import GraphState, create_graph_state
16
+ from .integrity import Contradiction, StaleItem
17
+ from .intent import (
18
+ Intent,
19
+ IntentLifecycleEvent,
20
+ IntentState,
21
+ apply_intent_command,
22
+ create_intent,
23
+ create_intent_state,
24
+ get_intents,
25
+ )
26
+ from .models import Edge, MemoryItem, MemoryLifecycleEvent, ScoredItem
27
+ from .reducer import apply_command
28
+ from .retrieval import SupportNode
29
+ from .stats import GraphStats
30
+ from .task import (
31
+ Task,
32
+ TaskLifecycleEvent,
33
+ TaskState,
34
+ apply_task_command,
35
+ create_task,
36
+ create_task_state,
37
+ get_tasks,
38
+ )
39
+ from .transplant import ImportReport, MemexExport, export_slice, import_slice
40
+
41
+ __all__ = ["MemexStore"]
42
+
43
+
44
+ class MemexStore:
45
+ """A mutable container over the Memory / Intent / Task graphs."""
46
+
47
+ def __init__(
48
+ self,
49
+ mem: GraphState | None = None,
50
+ intents: IntentState | None = None,
51
+ tasks: TaskState | None = None,
52
+ ) -> None:
53
+ self.mem = mem if mem is not None else create_graph_state()
54
+ self.intents = intents if intents is not None else create_intent_state()
55
+ self.tasks = tasks if tasks is not None else create_task_state()
56
+
57
+ # -- memory mutations ---------------------------------------------------
58
+
59
+ def apply(self, cmd: Any) -> list[MemoryLifecycleEvent]:
60
+ result = apply_command(self.mem, cmd)
61
+ self.mem = result.state
62
+ return result.events
63
+
64
+ def create(self, **kwargs: Any) -> MemoryItem:
65
+ item = create_memory_item(**kwargs)
66
+ self.apply({"type": "memory.create", "item": item})
67
+ return item
68
+
69
+ def add(self, item: MemoryItem) -> MemoryItem:
70
+ self.apply({"type": "memory.create", "item": item})
71
+ return item
72
+
73
+ def update(self, item_id: str, partial: dict[str, Any], author: str, reason: str | None = None) -> list[MemoryLifecycleEvent]:
74
+ return self.apply({"type": "memory.update", "item_id": item_id, "partial": partial, "author": author, "reason": reason})
75
+
76
+ def retract(self, item_id: str, author: str, reason: str | None = None) -> list[MemoryLifecycleEvent]:
77
+ return self.apply({"type": "memory.retract", "item_id": item_id, "author": author, "reason": reason})
78
+
79
+ def add_edge(self, **kwargs: Any) -> Edge:
80
+ edge = create_edge(**kwargs)
81
+ self.apply({"type": "edge.create", "edge": edge})
82
+ return edge
83
+
84
+ # -- memory queries -----------------------------------------------------
85
+
86
+ def items(self, filter: Any = None, options: Any = None) -> list[MemoryItem]:
87
+ return query.get_items(self.mem, filter, options)
88
+
89
+ def item(self, id: str) -> MemoryItem | None:
90
+ return query.get_item_by_id(self.mem, id)
91
+
92
+ def scored(self, weights: Any, options: Any = None) -> list[ScoredItem]:
93
+ return query.get_scored_items(self.mem, weights, options)
94
+
95
+ def edges(self, filter: Any = None) -> list[Edge]:
96
+ return query.get_edges(self.mem, filter)
97
+
98
+ def parents(self, item_id: str) -> list[MemoryItem]:
99
+ return query.get_parents(self.mem, item_id)
100
+
101
+ def children(self, item_id: str) -> list[MemoryItem]:
102
+ return query.get_children(self.mem, item_id)
103
+
104
+ def related(self, item_id: str, direction: str = "both") -> list[MemoryItem]:
105
+ return query.get_related_items(self.mem, item_id, direction)
106
+
107
+ def smart_retrieve(self, **kwargs: Any) -> list[ScoredItem]:
108
+ return retrieval.smart_retrieve(self.mem, **kwargs)
109
+
110
+ def support_tree(self, item_id: str) -> SupportNode | None:
111
+ return retrieval.get_support_tree(self.mem, item_id)
112
+
113
+ def support_set(self, item_id: str) -> list[MemoryItem]:
114
+ return retrieval.get_support_set(self.mem, item_id)
115
+
116
+ def stats(self) -> GraphStats:
117
+ return stats.get_stats(self.mem)
118
+
119
+ # -- integrity ----------------------------------------------------------
120
+
121
+ def mark_contradiction(self, a: str, b: str, author: str, meta: dict[str, Any] | None = None) -> list[MemoryLifecycleEvent]:
122
+ result = integrity.mark_contradiction(self.mem, a, b, author, meta)
123
+ self.mem = result.state
124
+ return result.events
125
+
126
+ def resolve_contradiction(self, winner: str, loser: str, author: str, reason: str | None = None) -> list[MemoryLifecycleEvent]:
127
+ result = integrity.resolve_contradiction(self.mem, winner, loser, author, reason)
128
+ self.mem = result.state
129
+ return result.events
130
+
131
+ def mark_alias(self, a: str, b: str, author: str, meta: dict[str, Any] | None = None) -> list[MemoryLifecycleEvent]:
132
+ result = integrity.mark_alias(self.mem, a, b, author, meta)
133
+ self.mem = result.state
134
+ return result.events
135
+
136
+ def cascade_retract(self, item_id: str, author: str, reason: str | None = None) -> list[str]:
137
+ result = integrity.cascade_retract(self.mem, item_id, author, reason)
138
+ self.mem = result.state
139
+ return result.retracted
140
+
141
+ def contradictions(self) -> list[Contradiction]:
142
+ return integrity.get_contradictions(self.mem)
143
+
144
+ def stale_items(self) -> list[StaleItem]:
145
+ return integrity.get_stale_items(self.mem)
146
+
147
+ def aliases(self, item_id: str) -> list[MemoryItem]:
148
+ return integrity.get_aliases(self.mem, item_id)
149
+
150
+ def alias_group(self, item_id: str) -> list[MemoryItem]:
151
+ return integrity.get_alias_group(self.mem, item_id)
152
+
153
+ # -- bulk ---------------------------------------------------------------
154
+
155
+ def apply_many(self, filter: Any, transform: Any, author: str, reason: str | None = None, options: Any = None) -> list[MemoryLifecycleEvent]:
156
+ result = bulk.apply_many(self.mem, filter, transform, author, reason, options)
157
+ self.mem = result.state
158
+ return result.events
159
+
160
+ def bulk_adjust_scores(self, criteria: Any, delta: Any, author: str, reason: str | None = None) -> list[MemoryLifecycleEvent]:
161
+ result = bulk.bulk_adjust_scores(self.mem, criteria, delta, author, reason)
162
+ self.mem = result.state
163
+ return result.events
164
+
165
+ def decay_importance(self, older_than_ms: int, factor: float, author: str, reason: str | None = None) -> list[MemoryLifecycleEvent]:
166
+ result = bulk.decay_importance(self.mem, older_than_ms, factor, author, reason)
167
+ self.mem = result.state
168
+ return result.events
169
+
170
+ # -- intent graph -------------------------------------------------------
171
+
172
+ def apply_intent(self, cmd: Any) -> list[IntentLifecycleEvent]:
173
+ result = apply_intent_command(self.intents, cmd)
174
+ self.intents = result.state
175
+ return result.events
176
+
177
+ def create_intent(self, **kwargs: Any) -> Intent:
178
+ intent = create_intent(**kwargs)
179
+ self.apply_intent({"type": "intent.create", "intent": intent})
180
+ return intent
181
+
182
+ def get_intents(self, filter: Any = None) -> list[Intent]:
183
+ return get_intents(self.intents, filter)
184
+
185
+ # -- task graph ---------------------------------------------------------
186
+
187
+ def apply_task(self, cmd: Any) -> list[TaskLifecycleEvent]:
188
+ result = apply_task_command(self.tasks, cmd)
189
+ self.tasks = result.state
190
+ return result.events
191
+
192
+ def create_task(self, **kwargs: Any) -> Task:
193
+ task = create_task(**kwargs)
194
+ self.apply_task({"type": "task.create", "task": task})
195
+ return task
196
+
197
+ def get_tasks(self, filter: Any = None) -> list[Task]:
198
+ return get_tasks(self.tasks, filter)
199
+
200
+ # -- transplant ---------------------------------------------------------
201
+
202
+ def export_slice(self, **kwargs: Any) -> MemexExport:
203
+ return export_slice(self.mem, self.intents, self.tasks, **kwargs)
204
+
205
+ def import_slice(self, slice: MemexExport | dict[str, Any], **kwargs: Any) -> ImportReport:
206
+ result = import_slice(self.mem, self.intents, self.tasks, slice, **kwargs)
207
+ self.mem = result.mem_state
208
+ self.intents = result.intent_state
209
+ self.tasks = result.task_state
210
+ return result.report
211
+
212
+ # -- serialization (memory graph) --------------------------------------
213
+
214
+ def to_json(self) -> serialization.SerializedGraphState:
215
+ return serialization.to_json(self.mem)
216
+
217
+ def dumps(self, pretty: bool = False) -> str:
218
+ return serialization.stringify(self.mem, pretty)
219
+
220
+ @classmethod
221
+ def loads(cls, json_str: str) -> MemexStore:
222
+ return cls(mem=serialization.parse(json_str))
memex/task.py ADDED
@@ -0,0 +1,361 @@
1
+ """Task graph — units of work tied to intents, with a status machine.
2
+
3
+ ``pending → running → completed`` (``running → failed → running`` retry,
4
+ ``cancel`` from any non-terminal state). Invalid transitions raise
5
+ ``InvalidTaskTransitionError``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Annotated, Any, Literal, NamedTuple
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
14
+
15
+ from ._uuid import uuid7
16
+ from .errors import MemexError
17
+
18
+ __all__ = [
19
+ "TaskStatus",
20
+ "Task",
21
+ "TaskState",
22
+ "TaskCommand",
23
+ "TaskLifecycleEvent",
24
+ "TaskFilter",
25
+ "TaskResult",
26
+ "TaskNotFoundError",
27
+ "DuplicateTaskError",
28
+ "InvalidTaskTransitionError",
29
+ "create_task_state",
30
+ "create_task",
31
+ "apply_task_command",
32
+ "get_tasks",
33
+ "get_task_by_id",
34
+ "get_tasks_by_intent",
35
+ "get_child_tasks",
36
+ ]
37
+
38
+ TaskStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
39
+
40
+
41
+ class Task(BaseModel):
42
+ model_config = ConfigDict(frozen=True)
43
+
44
+ id: str
45
+ intent_id: str
46
+ parent_id: str | None = None
47
+
48
+ action: str
49
+ label: str | None = None
50
+
51
+ status: TaskStatus
52
+ priority: float = Field(ge=0, le=1)
53
+
54
+ context: dict[str, Any] | None = None
55
+ result: dict[str, Any] | None = None
56
+ error: str | None = None
57
+
58
+ input_memory_ids: list[str] | None = None
59
+ output_memory_ids: list[str] | None = None
60
+
61
+ agent_id: str | None = None
62
+ attempt: int | None = None
63
+
64
+ meta: dict[str, Any] | None = None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class TaskState:
69
+ tasks: dict[str, Task] = field(default_factory=dict)
70
+
71
+
72
+ def create_task_state() -> TaskState:
73
+ return TaskState(tasks={})
74
+
75
+
76
+ def create_task(
77
+ *,
78
+ intent_id: str,
79
+ action: str,
80
+ priority: float,
81
+ id: str | None = None,
82
+ parent_id: str | None = None,
83
+ label: str | None = None,
84
+ status: TaskStatus | None = None,
85
+ context: dict[str, Any] | None = None,
86
+ result: dict[str, Any] | None = None,
87
+ error: str | None = None,
88
+ input_memory_ids: list[str] | None = None,
89
+ output_memory_ids: list[str] | None = None,
90
+ agent_id: str | None = None,
91
+ attempt: int | None = None,
92
+ meta: dict[str, Any] | None = None,
93
+ ) -> Task:
94
+ return Task(
95
+ id=id if id is not None else uuid7(),
96
+ intent_id=intent_id,
97
+ parent_id=parent_id,
98
+ action=action,
99
+ label=label,
100
+ status=status if status is not None else "pending",
101
+ priority=priority,
102
+ context=context,
103
+ result=result,
104
+ error=error,
105
+ input_memory_ids=input_memory_ids,
106
+ output_memory_ids=output_memory_ids,
107
+ agent_id=agent_id,
108
+ attempt=attempt if attempt is not None else 0,
109
+ meta=meta,
110
+ )
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Commands
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ class TaskCreate(BaseModel):
119
+ type: Literal["task.create"] = "task.create"
120
+ task: Task
121
+
122
+
123
+ class TaskUpdate(BaseModel):
124
+ type: Literal["task.update"] = "task.update"
125
+ task_id: str
126
+ partial: dict[str, Any]
127
+ author: str
128
+
129
+
130
+ class TaskStart(BaseModel):
131
+ type: Literal["task.start"] = "task.start"
132
+ task_id: str
133
+ agent_id: str | None = None
134
+
135
+
136
+ class TaskComplete(BaseModel):
137
+ type: Literal["task.complete"] = "task.complete"
138
+ task_id: str
139
+ result: dict[str, Any] | None = None
140
+ output_memory_ids: list[str] | None = None
141
+
142
+
143
+ class TaskFail(BaseModel):
144
+ type: Literal["task.fail"] = "task.fail"
145
+ task_id: str
146
+ error: str
147
+ retryable: bool | None = None
148
+
149
+
150
+ class TaskCancel(BaseModel):
151
+ type: Literal["task.cancel"] = "task.cancel"
152
+ task_id: str
153
+ reason: str | None = None
154
+
155
+
156
+ TaskCommand = Annotated[
157
+ TaskCreate | TaskUpdate | TaskStart | TaskComplete | TaskFail | TaskCancel,
158
+ Field(discriminator="type"),
159
+ ]
160
+
161
+ _TASK_COMMAND_ADAPTER: TypeAdapter[TaskCommand] = TypeAdapter(TaskCommand)
162
+
163
+
164
+ TaskEventType = Literal[
165
+ "task.created",
166
+ "task.updated",
167
+ "task.started",
168
+ "task.completed",
169
+ "task.failed",
170
+ "task.cancelled",
171
+ ]
172
+
173
+
174
+ class TaskLifecycleEvent(BaseModel):
175
+ namespace: Literal["task"] = "task"
176
+ type: TaskEventType
177
+ task: Task
178
+ cause_type: str
179
+
180
+
181
+ class TaskResult(NamedTuple):
182
+ state: TaskState
183
+ events: list[TaskLifecycleEvent]
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Errors
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ class TaskNotFoundError(MemexError):
192
+ def __init__(self, task_id: str) -> None:
193
+ super().__init__(f"Task not found: {task_id}")
194
+ self.task_id = task_id
195
+
196
+
197
+ class DuplicateTaskError(MemexError):
198
+ def __init__(self, task_id: str) -> None:
199
+ super().__init__(f"Task already exists: {task_id}")
200
+ self.task_id = task_id
201
+
202
+
203
+ class InvalidTaskTransitionError(MemexError):
204
+ def __init__(self, task_id: str, from_status: str, to_status: str) -> None:
205
+ super().__init__(f"Invalid task transition: {task_id} from {from_status} to {to_status}")
206
+ self.task_id = task_id
207
+ self.from_status = from_status
208
+ self.to_status = to_status
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Reducer
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def _event(task: Task, event_type: TaskEventType, cause_type: str) -> TaskLifecycleEvent:
217
+ return TaskLifecycleEvent(type=event_type, task=task, cause_type=cause_type)
218
+
219
+
220
+ def apply_task_command(state: TaskState, cmd: TaskCommand | dict[str, Any]) -> TaskResult:
221
+ command = cmd if isinstance(cmd, BaseModel) else _TASK_COMMAND_ADAPTER.validate_python(cmd)
222
+
223
+ match command:
224
+ case TaskCreate(task=task):
225
+ if task.id in state.tasks:
226
+ raise DuplicateTaskError(task.id)
227
+ tasks = {**state.tasks, task.id: task}
228
+ return TaskResult(TaskState(tasks), [_event(task, "task.created", "task.create")])
229
+
230
+ case TaskUpdate(task_id=task_id, partial=partial):
231
+ existing = state.tasks.get(task_id)
232
+ if existing is None:
233
+ raise TaskNotFoundError(task_id)
234
+ update = {k: v for k, v in partial.items() if k not in ("id", "status")}
235
+ updated = existing.model_copy(update=update)
236
+ tasks = {**state.tasks, task_id: updated}
237
+ return TaskResult(TaskState(tasks), [_event(updated, "task.updated", "task.update")])
238
+
239
+ case TaskStart(task_id=task_id, agent_id=agent_id):
240
+ existing = state.tasks.get(task_id)
241
+ if existing is None:
242
+ raise TaskNotFoundError(task_id)
243
+ if existing.status not in ("pending", "failed"):
244
+ raise InvalidTaskTransitionError(task_id, existing.status, "running")
245
+ updated = existing.model_copy(update={
246
+ "status": "running",
247
+ "agent_id": agent_id if agent_id is not None else existing.agent_id,
248
+ "attempt": (existing.attempt or 0) + 1,
249
+ })
250
+ tasks = {**state.tasks, task_id: updated}
251
+ return TaskResult(TaskState(tasks), [_event(updated, "task.started", "task.start")])
252
+
253
+ case TaskComplete(task_id=task_id, result=result, output_memory_ids=output_memory_ids):
254
+ existing = state.tasks.get(task_id)
255
+ if existing is None:
256
+ raise TaskNotFoundError(task_id)
257
+ if existing.status != "running":
258
+ raise InvalidTaskTransitionError(task_id, existing.status, "completed")
259
+ updated = existing.model_copy(update={
260
+ "status": "completed",
261
+ "result": result if result is not None else existing.result,
262
+ "output_memory_ids": output_memory_ids if output_memory_ids is not None else existing.output_memory_ids,
263
+ })
264
+ tasks = {**state.tasks, task_id: updated}
265
+ return TaskResult(TaskState(tasks), [_event(updated, "task.completed", "task.complete")])
266
+
267
+ case TaskFail(task_id=task_id, error=error):
268
+ existing = state.tasks.get(task_id)
269
+ if existing is None:
270
+ raise TaskNotFoundError(task_id)
271
+ if existing.status != "running":
272
+ raise InvalidTaskTransitionError(task_id, existing.status, "failed")
273
+ updated = existing.model_copy(update={"status": "failed", "error": error})
274
+ tasks = {**state.tasks, task_id: updated}
275
+ return TaskResult(TaskState(tasks), [_event(updated, "task.failed", "task.fail")])
276
+
277
+ case TaskCancel(task_id=task_id):
278
+ existing = state.tasks.get(task_id)
279
+ if existing is None:
280
+ raise TaskNotFoundError(task_id)
281
+ if existing.status in ("completed", "cancelled"):
282
+ raise InvalidTaskTransitionError(task_id, existing.status, "cancelled")
283
+ updated = existing.model_copy(update={"status": "cancelled"})
284
+ tasks = {**state.tasks, task_id: updated}
285
+ return TaskResult(TaskState(tasks), [_event(updated, "task.cancelled", "task.cancel")])
286
+
287
+ case _: # pragma: no cover
288
+ raise TypeError(f"Unknown task command: {command!r}")
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Query
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ class TaskFilter(BaseModel):
297
+ intent_id: str | None = None
298
+ action: str | None = None
299
+ status: TaskStatus | None = None
300
+ statuses: list[TaskStatus] | None = None
301
+ agent_id: str | None = None
302
+ min_priority: float | None = None
303
+ has_input_memory_id: str | None = None
304
+ has_output_memory_id: str | None = None
305
+ parent_id: str | None = None
306
+ is_root: bool | None = None
307
+
308
+
309
+ def _coerce_task_filter(f: TaskFilter | dict[str, Any] | None) -> TaskFilter | None:
310
+ if f is None or isinstance(f, TaskFilter):
311
+ return f
312
+ return TaskFilter.model_validate(f)
313
+
314
+
315
+ def get_tasks(state: TaskState, filter: TaskFilter | dict[str, Any] | None = None) -> list[Task]:
316
+ f = _coerce_task_filter(filter)
317
+ if f is None:
318
+ return list(state.tasks.values())
319
+
320
+ results: list[Task] = []
321
+ for task in state.tasks.values():
322
+ if f.intent_id is not None and task.intent_id != f.intent_id:
323
+ continue
324
+ if f.action is not None and task.action != f.action:
325
+ continue
326
+ if f.status is not None and task.status != f.status:
327
+ continue
328
+ if f.statuses is not None and task.status not in f.statuses:
329
+ continue
330
+ if f.agent_id is not None and task.agent_id != f.agent_id:
331
+ continue
332
+ if f.min_priority is not None and task.priority < f.min_priority:
333
+ continue
334
+ if f.has_input_memory_id is not None:
335
+ if not task.input_memory_ids or f.has_input_memory_id not in task.input_memory_ids:
336
+ continue
337
+ if f.has_output_memory_id is not None:
338
+ if not task.output_memory_ids or f.has_output_memory_id not in task.output_memory_ids:
339
+ continue
340
+ if f.parent_id is not None and task.parent_id != f.parent_id:
341
+ continue
342
+ if f.is_root is not None:
343
+ has_parent = task.parent_id is not None
344
+ if f.is_root and has_parent:
345
+ continue
346
+ if not f.is_root and not has_parent:
347
+ continue
348
+ results.append(task)
349
+ return results
350
+
351
+
352
+ def get_task_by_id(state: TaskState, id: str) -> Task | None:
353
+ return state.tasks.get(id)
354
+
355
+
356
+ def get_tasks_by_intent(state: TaskState, intent_id: str) -> list[Task]:
357
+ return get_tasks(state, {"intent_id": intent_id})
358
+
359
+
360
+ def get_child_tasks(state: TaskState, parent_id: str) -> list[Task]:
361
+ return get_tasks(state, {"parent_id": parent_id})