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/intent.py ADDED
@@ -0,0 +1,318 @@
1
+ """Intent graph — active goals with a status machine.
2
+
3
+ ``active ⇄ paused → completed / cancelled``. Invalid transitions raise
4
+ ``InvalidIntentTransitionError``. Same command → reducer → events pattern as the
5
+ memory graph, with its own reducer (``apply_intent_command``).
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
+ "IntentStatus",
20
+ "Intent",
21
+ "IntentState",
22
+ "IntentCommand",
23
+ "IntentLifecycleEvent",
24
+ "IntentFilter",
25
+ "IntentResult",
26
+ "IntentNotFoundError",
27
+ "DuplicateIntentError",
28
+ "InvalidIntentTransitionError",
29
+ "create_intent_state",
30
+ "create_intent",
31
+ "apply_intent_command",
32
+ "get_intents",
33
+ "get_intent_by_id",
34
+ "get_child_intents",
35
+ ]
36
+
37
+ IntentStatus = Literal["active", "paused", "completed", "cancelled"]
38
+
39
+
40
+ class Intent(BaseModel):
41
+ model_config = ConfigDict(frozen=True)
42
+
43
+ id: str
44
+ parent_id: str | None = None
45
+ label: str
46
+ description: str | None = None
47
+
48
+ priority: float = Field(ge=0, le=1)
49
+ owner: str
50
+ status: IntentStatus
51
+
52
+ context: dict[str, Any] | None = None
53
+ root_memory_ids: list[str] | None = None
54
+
55
+ meta: dict[str, Any] | None = None
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class IntentState:
60
+ intents: dict[str, Intent] = field(default_factory=dict)
61
+
62
+
63
+ def create_intent_state() -> IntentState:
64
+ return IntentState(intents={})
65
+
66
+
67
+ def create_intent(
68
+ *,
69
+ label: str,
70
+ priority: float,
71
+ owner: str,
72
+ id: str | None = None,
73
+ parent_id: str | None = None,
74
+ description: str | None = None,
75
+ status: IntentStatus | None = None,
76
+ context: dict[str, Any] | None = None,
77
+ root_memory_ids: list[str] | None = None,
78
+ meta: dict[str, Any] | None = None,
79
+ ) -> Intent:
80
+ return Intent(
81
+ id=id if id is not None else uuid7(),
82
+ parent_id=parent_id,
83
+ label=label,
84
+ description=description,
85
+ priority=priority,
86
+ owner=owner,
87
+ status=status if status is not None else "active",
88
+ context=context,
89
+ root_memory_ids=root_memory_ids,
90
+ meta=meta,
91
+ )
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Commands
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ class IntentCreate(BaseModel):
100
+ type: Literal["intent.create"] = "intent.create"
101
+ intent: Intent
102
+
103
+
104
+ class IntentUpdate(BaseModel):
105
+ type: Literal["intent.update"] = "intent.update"
106
+ intent_id: str
107
+ partial: dict[str, Any]
108
+ author: str
109
+ reason: str | None = None
110
+
111
+
112
+ class IntentComplete(BaseModel):
113
+ type: Literal["intent.complete"] = "intent.complete"
114
+ intent_id: str
115
+ author: str
116
+ reason: str | None = None
117
+
118
+
119
+ class IntentCancel(BaseModel):
120
+ type: Literal["intent.cancel"] = "intent.cancel"
121
+ intent_id: str
122
+ author: str
123
+ reason: str | None = None
124
+
125
+
126
+ class IntentPause(BaseModel):
127
+ type: Literal["intent.pause"] = "intent.pause"
128
+ intent_id: str
129
+ author: str
130
+ reason: str | None = None
131
+
132
+
133
+ class IntentResume(BaseModel):
134
+ type: Literal["intent.resume"] = "intent.resume"
135
+ intent_id: str
136
+ author: str
137
+ reason: str | None = None
138
+
139
+
140
+ IntentCommand = Annotated[
141
+ IntentCreate | IntentUpdate | IntentComplete | IntentCancel | IntentPause | IntentResume,
142
+ Field(discriminator="type"),
143
+ ]
144
+
145
+ _INTENT_COMMAND_ADAPTER: TypeAdapter[IntentCommand] = TypeAdapter(IntentCommand)
146
+
147
+
148
+ IntentEventType = Literal[
149
+ "intent.created",
150
+ "intent.updated",
151
+ "intent.completed",
152
+ "intent.cancelled",
153
+ "intent.paused",
154
+ "intent.resumed",
155
+ ]
156
+
157
+
158
+ class IntentLifecycleEvent(BaseModel):
159
+ namespace: Literal["intent"] = "intent"
160
+ type: IntentEventType
161
+ intent: Intent
162
+ cause_type: str
163
+
164
+
165
+ class IntentResult(NamedTuple):
166
+ state: IntentState
167
+ events: list[IntentLifecycleEvent]
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Errors
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ class IntentNotFoundError(MemexError):
176
+ def __init__(self, intent_id: str) -> None:
177
+ super().__init__(f"Intent not found: {intent_id}")
178
+ self.intent_id = intent_id
179
+
180
+
181
+ class DuplicateIntentError(MemexError):
182
+ def __init__(self, intent_id: str) -> None:
183
+ super().__init__(f"Intent already exists: {intent_id}")
184
+ self.intent_id = intent_id
185
+
186
+
187
+ class InvalidIntentTransitionError(MemexError):
188
+ def __init__(self, intent_id: str, from_status: str, to_status: str) -> None:
189
+ super().__init__(f"Invalid intent transition: {intent_id} from {from_status} to {to_status}")
190
+ self.intent_id = intent_id
191
+ self.from_status = from_status
192
+ self.to_status = to_status
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Reducer
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def _set_status(
201
+ state: IntentState,
202
+ intent_id: str,
203
+ target: IntentStatus,
204
+ valid_from: tuple[IntentStatus, ...],
205
+ cause_type: str,
206
+ event_type: IntentEventType,
207
+ ) -> IntentResult:
208
+ existing = state.intents.get(intent_id)
209
+ if existing is None:
210
+ raise IntentNotFoundError(intent_id)
211
+ if existing.status not in valid_from:
212
+ raise InvalidIntentTransitionError(intent_id, existing.status, target)
213
+ updated = existing.model_copy(update={"status": target})
214
+ intents = {**state.intents, intent_id: updated}
215
+ return IntentResult(
216
+ IntentState(intents),
217
+ [IntentLifecycleEvent(type=event_type, intent=updated, cause_type=cause_type)],
218
+ )
219
+
220
+
221
+ def apply_intent_command(state: IntentState, cmd: IntentCommand | dict[str, Any]) -> IntentResult:
222
+ command = cmd if isinstance(cmd, BaseModel) else _INTENT_COMMAND_ADAPTER.validate_python(cmd)
223
+
224
+ match command:
225
+ case IntentCreate(intent=intent):
226
+ if intent.id in state.intents:
227
+ raise DuplicateIntentError(intent.id)
228
+ intents = {**state.intents, intent.id: intent}
229
+ return IntentResult(
230
+ IntentState(intents),
231
+ [IntentLifecycleEvent(type="intent.created", intent=intent, cause_type="intent.create")],
232
+ )
233
+
234
+ case IntentUpdate(intent_id=intent_id, partial=partial):
235
+ existing = state.intents.get(intent_id)
236
+ if existing is None:
237
+ raise IntentNotFoundError(intent_id)
238
+ update = {k: v for k, v in partial.items() if k not in ("id", "status")}
239
+ updated = existing.model_copy(update=update)
240
+ intents = {**state.intents, intent_id: updated}
241
+ return IntentResult(
242
+ IntentState(intents),
243
+ [IntentLifecycleEvent(type="intent.updated", intent=updated, cause_type="intent.update")],
244
+ )
245
+
246
+ case IntentComplete(intent_id=intent_id):
247
+ return _set_status(state, intent_id, "completed", ("active", "paused"), "intent.complete", "intent.completed")
248
+
249
+ case IntentCancel(intent_id=intent_id):
250
+ return _set_status(state, intent_id, "cancelled", ("active", "paused"), "intent.cancel", "intent.cancelled")
251
+
252
+ case IntentPause(intent_id=intent_id):
253
+ return _set_status(state, intent_id, "paused", ("active",), "intent.pause", "intent.paused")
254
+
255
+ case IntentResume(intent_id=intent_id):
256
+ return _set_status(state, intent_id, "active", ("paused",), "intent.resume", "intent.resumed")
257
+
258
+ case _: # pragma: no cover
259
+ raise TypeError(f"Unknown intent command: {command!r}")
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Query
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ class IntentFilter(BaseModel):
268
+ owner: str | None = None
269
+ status: IntentStatus | None = None
270
+ statuses: list[IntentStatus] | None = None
271
+ min_priority: float | None = None
272
+ has_memory_id: str | None = None
273
+ parent_id: str | None = None
274
+ is_root: bool | None = None
275
+
276
+
277
+ def _coerce_intent_filter(f: IntentFilter | dict[str, Any] | None) -> IntentFilter | None:
278
+ if f is None or isinstance(f, IntentFilter):
279
+ return f
280
+ return IntentFilter.model_validate(f)
281
+
282
+
283
+ def get_intents(state: IntentState, filter: IntentFilter | dict[str, Any] | None = None) -> list[Intent]:
284
+ f = _coerce_intent_filter(filter)
285
+ if f is None:
286
+ return list(state.intents.values())
287
+
288
+ results: list[Intent] = []
289
+ for intent in state.intents.values():
290
+ if f.owner is not None and intent.owner != f.owner:
291
+ continue
292
+ if f.status is not None and intent.status != f.status:
293
+ continue
294
+ if f.statuses is not None and intent.status not in f.statuses:
295
+ continue
296
+ if f.min_priority is not None and intent.priority < f.min_priority:
297
+ continue
298
+ if f.has_memory_id is not None:
299
+ if not intent.root_memory_ids or f.has_memory_id not in intent.root_memory_ids:
300
+ continue
301
+ if f.parent_id is not None and intent.parent_id != f.parent_id:
302
+ continue
303
+ if f.is_root is not None:
304
+ has_parent = intent.parent_id is not None
305
+ if f.is_root and has_parent:
306
+ continue
307
+ if not f.is_root and not has_parent:
308
+ continue
309
+ results.append(intent)
310
+ return results
311
+
312
+
313
+ def get_intent_by_id(state: IntentState, id: str) -> Intent | None:
314
+ return state.intents.get(id)
315
+
316
+
317
+ def get_child_intents(state: IntentState, parent_id: str) -> list[Intent]:
318
+ return get_intents(state, {"parent_id": parent_id})
memex/models.py ADDED
@@ -0,0 +1,271 @@
1
+ """Pydantic models for the memory graph.
2
+
3
+ Design notes (see PLAN.md):
4
+ - Entities (`MemoryItem`, `Edge`) are ``frozen`` — immutability is enforced by
5
+ the type system; a "merge" produces a new instance via ``model_copy(update=)``.
6
+ - Numeric score bounds use ``Field(ge=0, le=1)`` so construction validates them
7
+ (D2: validation is always on; use ``Model.model_construct`` to bypass).
8
+ - Open string unions (kind / source_kind / edge kind / namespace) are plain
9
+ ``str``; the ``Known*`` ``Literal`` aliases document the canonical values.
10
+ - ``from`` / ``not`` / ``or`` are Python keywords, so the corresponding fields
11
+ are ``from_`` / ``not_`` / ``or_`` with JSON aliases. ``populate_by_name``
12
+ lets you pass either the field name or the alias.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Generic, Literal, TypeVar
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Open-string type families (known values documented via Literal aliases)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ KnownMemoryKind = Literal[
26
+ "observation",
27
+ "assertion",
28
+ "assumption",
29
+ "hypothesis",
30
+ "derivation",
31
+ "simulation",
32
+ "policy",
33
+ "trait",
34
+ ]
35
+
36
+ KnownSourceKind = Literal[
37
+ "user_explicit",
38
+ "observed",
39
+ "derived_deterministic",
40
+ "agent_inferred",
41
+ "simulated",
42
+ "imported",
43
+ ]
44
+
45
+ KnownEdgeKind = Literal[
46
+ "DERIVED_FROM",
47
+ "CONTRADICTS",
48
+ "SUPPORTS",
49
+ "ABOUT",
50
+ "SUPERSEDES",
51
+ "ALIAS",
52
+ ]
53
+
54
+ KnownNamespace = Literal[
55
+ "memory",
56
+ "task",
57
+ "agent",
58
+ "tool",
59
+ "net",
60
+ "app",
61
+ "chat",
62
+ "system",
63
+ "debug",
64
+ ]
65
+
66
+ LifecycleEventType = Literal[
67
+ "memory.created",
68
+ "memory.updated",
69
+ "memory.retracted",
70
+ "edge.created",
71
+ "edge.updated",
72
+ "edge.retracted",
73
+ ]
74
+
75
+ SortField = Literal["authority", "conviction", "importance", "recency"]
76
+ DecayInterval = Literal["hour", "day", "week"]
77
+ DecayType = Literal["exponential", "linear", "step"]
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # MemoryItem (core node)
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ class MemoryItem(BaseModel):
86
+ model_config = ConfigDict(frozen=True)
87
+
88
+ id: str
89
+ scope: str
90
+ kind: str
91
+ content: dict[str, Any]
92
+
93
+ author: str
94
+ source_kind: str
95
+ parents: list[str] | None = None
96
+
97
+ authority: float = Field(ge=0, le=1)
98
+ conviction: float | None = Field(default=None, ge=0, le=1)
99
+ importance: float | None = Field(default=None, ge=0, le=1)
100
+
101
+ created_at: int | None = None
102
+
103
+ intent_id: str | None = None
104
+ task_id: str | None = None
105
+
106
+ meta: dict[str, Any] | None = None
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Edge
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class Edge(BaseModel):
115
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
116
+
117
+ edge_id: str
118
+ from_: str = Field(alias="from")
119
+ to: str
120
+ kind: str
121
+
122
+ weight: float | None = Field(default=None, ge=0, le=1)
123
+
124
+ author: str
125
+ source_kind: str
126
+ authority: float = Field(ge=0, le=1)
127
+ active: bool
128
+
129
+ meta: dict[str, Any] | None = None
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Event envelope (generic over its payload)
134
+ # ---------------------------------------------------------------------------
135
+
136
+ T = TypeVar("T")
137
+
138
+
139
+ class EventEnvelope(BaseModel, Generic[T]):
140
+ id: str
141
+ namespace: str
142
+ type: str
143
+ ts: str
144
+ trace_id: str | None = None
145
+ payload: T
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Scoring / decay configuration
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ class DecayConfig(BaseModel):
154
+ rate: float = Field(ge=0, le=1)
155
+ interval: str # DecayInterval; runtime-checked in query.compute_decay_multiplier
156
+ type: str # DecayType; runtime-checked in query.compute_decay_multiplier
157
+
158
+
159
+ class ScoreWeights(BaseModel):
160
+ # Weights are multipliers, intentionally unbounded.
161
+ authority: float | None = None
162
+ conviction: float | None = None
163
+ importance: float | None = None
164
+ decay: DecayConfig | None = None
165
+
166
+
167
+ class ScoredItem(BaseModel):
168
+ # Not frozen: surface_contradictions annotates `contradicted_by` in place.
169
+ item: MemoryItem
170
+ score: float
171
+ contradicted_by: list[MemoryItem] | None = None
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Filters
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ class Range(BaseModel):
180
+ min: float | None = None
181
+ max: float | None = None
182
+
183
+
184
+ class ScoreRanges(BaseModel):
185
+ authority: Range | None = None
186
+ conviction: Range | None = None
187
+ importance: Range | None = None
188
+
189
+
190
+ class ParentsFilter(BaseModel):
191
+ includes: str | None = None
192
+ includes_any: list[str] | None = None
193
+ includes_all: list[str] | None = None
194
+ count: Range | None = None
195
+
196
+
197
+ class DecayFilter(BaseModel):
198
+ config: DecayConfig
199
+ min: float = Field(ge=0, le=1)
200
+
201
+
202
+ class CreatedFilter(BaseModel):
203
+ before: int | None = None
204
+ after: int | None = None
205
+
206
+
207
+ class MemoryFilter(BaseModel):
208
+ model_config = ConfigDict(populate_by_name=True)
209
+
210
+ ids: list[str] | None = None
211
+ scope: str | None = None
212
+ scope_prefix: str | None = None
213
+ author: str | None = None
214
+ kind: str | None = None
215
+ source_kind: str | None = None
216
+
217
+ range: ScoreRanges | None = None
218
+
219
+ intent_id: str | None = None
220
+ intent_ids: list[str] | None = None
221
+ task_id: str | None = None
222
+ task_ids: list[str] | None = None
223
+
224
+ has_parent: str | None = None
225
+ is_root: bool | None = None
226
+ parents: ParentsFilter | None = None
227
+
228
+ decay: DecayFilter | None = None
229
+ created: CreatedFilter | None = None
230
+
231
+ not_: MemoryFilter | None = Field(default=None, alias="not")
232
+ meta: dict[str, Any] | None = None
233
+ meta_has: list[str] | None = None
234
+ or_: list[MemoryFilter] | None = Field(default=None, alias="or")
235
+
236
+
237
+ class SortOption(BaseModel):
238
+ field: str # SortField; runtime-checked in query.get_sort_value
239
+ order: Literal["asc", "desc"]
240
+
241
+
242
+ class QueryOptions(BaseModel):
243
+ sort: SortOption | list[SortOption] | None = None
244
+ limit: int | None = Field(default=None, ge=0)
245
+ offset: int | None = Field(default=None, ge=0)
246
+
247
+
248
+ class EdgeFilter(BaseModel):
249
+ model_config = ConfigDict(populate_by_name=True)
250
+
251
+ from_: str | None = Field(default=None, alias="from")
252
+ to: str | None = None
253
+ kind: str | None = None
254
+ min_weight: float | None = None
255
+ active_only: bool | None = None
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Memory lifecycle event (emitted by the reducer)
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ class MemoryLifecycleEvent(BaseModel):
264
+ namespace: Literal["memory"] = "memory"
265
+ type: LifecycleEventType
266
+ item: MemoryItem | None = None
267
+ edge: Edge | None = None
268
+ cause_type: str | None = None
269
+
270
+
271
+ MemoryFilter.model_rebuild()