memkernel-mcp 0.1.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.
@@ -0,0 +1,296 @@
1
+ """Client adapters for agent harnesses.
2
+
3
+ * :class:`MemKernelClient` — in-process, agent-scoped handle on a
4
+ :class:`~memkernel.kernel.MemKernel` (lowest latency; for Python harnesses).
5
+ * :class:`McpStdioClient` — speaks MCP to a ``memkernel-server`` subprocess,
6
+ exactly like Cursor, Claude Code or Codex do. Many such clients (one per
7
+ process) can share a single database file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import sys
16
+ from contextlib import AsyncExitStack, asynccontextmanager
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, AsyncIterator
19
+
20
+ from memkernel.core.events import EventEnvelope, EventType, new_event, validate_agent_id, validate_branch_id
21
+ from memkernel.kernel import DEFAULT_BRANCH, LeaseGrant, MemKernel
22
+ from memkernel.memory.gc import GCResult
23
+
24
+
25
+ class LeaseUnavailableError(RuntimeError):
26
+ def __init__(self, grant: LeaseGrant) -> None:
27
+ super().__init__(
28
+ f"lease on {grant.resource!r} is held by {grant.holder_agent_id!r} "
29
+ f"for another {grant.expires_in_seconds}s"
30
+ )
31
+ self.grant = grant
32
+
33
+
34
+ @dataclass
35
+ class LeaseHandle:
36
+ """Yielded by :meth:`MemKernelClient.lease`. ``lost`` becomes true if a
37
+ heartbeat fails; stop writing and re-acquire when it does."""
38
+
39
+ grant: LeaseGrant
40
+ _lost: asyncio.Event = field(default_factory=asyncio.Event)
41
+
42
+ @property
43
+ def fencing_token(self) -> int:
44
+ assert self.grant.fencing_token is not None
45
+ return self.grant.fencing_token
46
+
47
+ @property
48
+ def lost(self) -> bool:
49
+ return self._lost.is_set()
50
+
51
+
52
+ class MemKernelClient:
53
+ """Agent-scoped view of a kernel: fixed ``agent_id``, switchable branch."""
54
+
55
+ def __init__(self, kernel: MemKernel, agent_id: str, branch: str = DEFAULT_BRANCH) -> None:
56
+ self.kernel = kernel
57
+ self.agent_id = validate_agent_id(agent_id)
58
+ self.branch = validate_branch_id(branch)
59
+
60
+ async def checkout(self, branch: str) -> None:
61
+ await self.kernel.store.get_branch(branch) # raises if missing
62
+ self.branch = branch
63
+
64
+ # ---------------------------------------------------------------- writes
65
+
66
+ async def init_task(self, directive: str, constraints: list[str] | None = None) -> EventEnvelope:
67
+ return await self.kernel.init_task(
68
+ branch_id=self.branch, agent_id=self.agent_id, directive=directive, constraints=constraints
69
+ )
70
+
71
+ async def decide(self, key: str, value: str, rationale: str) -> EventEnvelope:
72
+ return await self.kernel.append_decision(
73
+ branch_id=self.branch, agent_id=self.agent_id, key=key, value=value, rationale=rationale
74
+ )
75
+
76
+ async def log_execution(
77
+ self, command: str, exit_code: int, output: str = "", facts: dict[str, Any] | None = None
78
+ ) -> EventEnvelope:
79
+ return await self.kernel.log_execution(
80
+ branch_id=self.branch,
81
+ agent_id=self.agent_id,
82
+ command=command,
83
+ exit_code=exit_code,
84
+ output=output,
85
+ facts=facts,
86
+ )
87
+
88
+ async def note(self, text: str) -> EventEnvelope:
89
+ return await self.kernel.note(branch_id=self.branch, agent_id=self.agent_id, note=text)
90
+
91
+ async def fenced_write(
92
+ self, event_type: EventType, payload: dict[str, Any], *, resource: str, fencing_token: int
93
+ ) -> EventEnvelope:
94
+ """Append only while holding the current lease on ``resource``."""
95
+ return await self.kernel.fenced_append(
96
+ new_event(event_type, branch_id=self.branch, agent_id=self.agent_id, payload=payload),
97
+ resource=resource,
98
+ fencing_token=fencing_token,
99
+ )
100
+
101
+ # ----------------------------------------------------------------- reads
102
+
103
+ async def query_state(self, *, include_ephemeral: bool = False, branch: str | None = None) -> dict[str, Any]:
104
+ return await self.kernel.query_state(branch or self.branch, include_ephemeral=include_ephemeral)
105
+
106
+ async def context(self, branch: str | None = None) -> str:
107
+ return await self.kernel.context(branch or self.branch)
108
+
109
+ async def history(self, limit: int = 30) -> list[dict[str, Any]]:
110
+ return await self.kernel.history(self.branch, limit=limit)
111
+
112
+ # --------------------------------------------------------------- timeline
113
+
114
+ async def fork_investigation(self, name: str, *, at_event_id: int | None = None) -> EventEnvelope:
115
+ """Fork the current branch and switch to the fork."""
116
+ event = await self.kernel.fork(
117
+ source_branch=self.branch,
118
+ new_branch=name,
119
+ agent_id=self.agent_id,
120
+ at_event_id=at_event_id,
121
+ reason="investigation",
122
+ )
123
+ self.branch = name
124
+ return event
125
+
126
+ async def rollback(self, target_event_id: int, reason: str = "") -> EventEnvelope:
127
+ return await self.kernel.rollback(
128
+ branch_id=self.branch, target_event_id=target_event_id, agent_id=self.agent_id, reason=reason
129
+ )
130
+
131
+ async def diff(self, other_branch: str) -> dict[str, Any]:
132
+ return await self.kernel.diff(self.branch, other_branch)
133
+
134
+ async def gc(self, *, force: bool = True) -> GCResult:
135
+ return await self.kernel.gc(self.branch, force=force)
136
+
137
+ # ----------------------------------------------------------------- leases
138
+
139
+ async def acquire(self, resource: str, ttl_seconds: float = 30) -> LeaseGrant:
140
+ return await self.kernel.acquire_lease(
141
+ branch_id=self.branch, agent_id=self.agent_id, resource=resource, ttl_seconds=ttl_seconds
142
+ )
143
+
144
+ async def release(self, resource: str, fencing_token: int) -> bool:
145
+ return await self.kernel.release_lease(
146
+ branch_id=self.branch, agent_id=self.agent_id, resource=resource, fencing_token=fencing_token
147
+ )
148
+
149
+ @asynccontextmanager
150
+ async def lease(
151
+ self,
152
+ resource: str,
153
+ *,
154
+ ttl_seconds: float = 30,
155
+ wait_seconds: float = 0,
156
+ poll_interval: float = 0.25,
157
+ heartbeat: bool = True,
158
+ ) -> AsyncIterator[LeaseHandle]:
159
+ """Hold a lease for the duration of the block, heartbeating every
160
+ ``ttl_seconds / 3`` and releasing on exit. Waits up to
161
+ ``wait_seconds`` for a contended lease, then raises
162
+ :class:`LeaseUnavailableError`."""
163
+ loop = asyncio.get_running_loop()
164
+ deadline = loop.time() + max(0.0, wait_seconds)
165
+ while True:
166
+ grant = await self.acquire(resource, ttl_seconds)
167
+ if grant.acquired:
168
+ break
169
+ remaining = deadline - loop.time()
170
+ if remaining <= 0:
171
+ raise LeaseUnavailableError(grant)
172
+ await asyncio.sleep(max(0.01, min(poll_interval, remaining)))
173
+
174
+ handle = LeaseHandle(grant)
175
+
176
+ async def beat() -> None:
177
+ while True:
178
+ await asyncio.sleep(ttl_seconds / 3)
179
+ if not await self.kernel.heartbeat_lease(resource=grant.resource, fencing_token=handle.fencing_token):
180
+ handle._lost.set()
181
+ return
182
+
183
+ task = asyncio.create_task(beat()) if heartbeat else None
184
+ try:
185
+ yield handle
186
+ finally:
187
+ if task is not None:
188
+ task.cancel()
189
+ try:
190
+ await task
191
+ except asyncio.CancelledError:
192
+ pass
193
+ await self.release(grant.resource, handle.fencing_token)
194
+
195
+
196
+ # ------------------------------------------------------------ MCP over stdio
197
+
198
+
199
+ class McpToolError(RuntimeError):
200
+ def __init__(self, tool: str, message: str) -> None:
201
+ super().__init__(f"{tool}: {message}")
202
+ self.tool = tool
203
+
204
+
205
+ class McpStdioClient:
206
+ """MCP client that spawns ``python -m memkernel.server.mcp_server``.
207
+
208
+ Usage::
209
+
210
+ async with McpStdioClient(agent_id="cursor-ide", db_path="mem.db") as c:
211
+ await c.call("memkernel_append_decision", decision_key="db",
212
+ decision_value="postgres", rationale="...")
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ *,
218
+ agent_id: str,
219
+ db_path: str,
220
+ branch: str = DEFAULT_BRANCH,
221
+ gc_threshold: int | None = None,
222
+ command: str | None = None,
223
+ args: list[str] | None = None,
224
+ env: dict[str, str] | None = None,
225
+ cwd: str | None = None,
226
+ ) -> None:
227
+ self.agent_id = validate_agent_id(agent_id)
228
+ server_env = {
229
+ "MEMKERNEL_DB": os.path.abspath(db_path),
230
+ "MEMKERNEL_AGENT_ID": self.agent_id,
231
+ "MEMKERNEL_BRANCH": validate_branch_id(branch),
232
+ }
233
+ if gc_threshold is not None:
234
+ server_env["MEMKERNEL_GC_THRESHOLD"] = str(int(gc_threshold))
235
+ for passthrough in ("PYTHONPATH", "MEMKERNEL_TOKENIZER", "MEMKERNEL_LOG_LEVEL", "TIKTOKEN_CACHE_DIR"):
236
+ if passthrough in os.environ:
237
+ server_env[passthrough] = os.environ[passthrough]
238
+ server_env.update(env or {})
239
+ self._command = command or sys.executable
240
+ self._args = args if args is not None else ["-m", "memkernel.server.mcp_server"]
241
+ self._env = server_env
242
+ self._cwd = cwd
243
+ self._stack: AsyncExitStack | None = None
244
+ self._session: Any = None
245
+
246
+ async def __aenter__(self) -> "McpStdioClient":
247
+ from mcp import ClientSession, StdioServerParameters
248
+ from mcp.client.stdio import stdio_client
249
+
250
+ params = StdioServerParameters(command=self._command, args=self._args, env=self._env, cwd=self._cwd)
251
+ stack = AsyncExitStack()
252
+ try:
253
+ # Server logs go to our stderr, unless it is not a real file
254
+ # (pytest capture, Jupyter, some IDE consoles): then discard them.
255
+ errlog: Any = sys.stderr
256
+ try:
257
+ errlog.fileno()
258
+ except (AttributeError, OSError, ValueError):
259
+ errlog = stack.enter_context(open(os.devnull, "w", encoding="utf-8"))
260
+ read, write = await stack.enter_async_context(stdio_client(params, errlog=errlog))
261
+ session = await stack.enter_async_context(ClientSession(read, write))
262
+ await session.initialize()
263
+ except BaseException:
264
+ await stack.aclose()
265
+ raise
266
+ self._stack, self._session = stack, session
267
+ return self
268
+
269
+ async def __aexit__(self, *exc: object) -> None:
270
+ if self._stack is not None:
271
+ stack, self._stack, self._session = self._stack, None, None
272
+ await stack.aclose()
273
+
274
+ async def list_tools(self) -> list[str]:
275
+ result = await self._require_session().list_tools()
276
+ return sorted(tool.name for tool in result.tools)
277
+
278
+ async def call(self, tool: str, **arguments: Any) -> Any:
279
+ """Call a tool; returns decoded JSON when the result is JSON, else text.
280
+ Raises :class:`McpToolError` when the server reports a tool error."""
281
+ result = await self._require_session().call_tool(tool, arguments)
282
+ text = "\n".join(c.text for c in result.content if getattr(c, "type", None) == "text")
283
+ is_error = getattr(result, "is_error", None)
284
+ if is_error is None: # mcp 1.x field name
285
+ is_error = getattr(result, "isError", False)
286
+ if is_error:
287
+ raise McpToolError(tool, text)
288
+ try:
289
+ return json.loads(text)
290
+ except ValueError:
291
+ return text
292
+
293
+ def _require_session(self) -> Any:
294
+ if self._session is None:
295
+ raise RuntimeError("McpStdioClient is not connected; use 'async with'")
296
+ return self._session
@@ -0,0 +1 @@
1
+ """Core layer: event taxonomy, append-only store, lineage algebra and leases."""
@@ -0,0 +1,161 @@
1
+ """Git-like lineage algebra: live-timeline folding, rollback planning, diff
2
+ and hash-chain verification.
3
+
4
+ Everything here is a pure function over lists of envelopes. The store runs
5
+ these functions inside its transactions; keeping them pure makes the branch
6
+ semantics testable without a database and identical on every replica.
7
+
8
+ Definitions
9
+ -----------
10
+ * **Raw lineage** ``L(b)``: every event reachable from branch ``b`` in log
11
+ order — the parent's raw lineage up to the fork point, then ``b``'s own
12
+ events. Forks copy no rows (copy-on-write lineage).
13
+ * **Live timeline** ``live(L)``: left fold of ``L`` where a
14
+ ``ROLLBACK_TRIGGERED`` event targeting ``t`` truncates the live list to the
15
+ prefix ending at ``t`` and then appends itself (so rollbacks stay auditable).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from memkernel.core.events import GENESIS_HASH, EventEnvelope, EventType
23
+ from memkernel.memory.state_view import materialize_live
24
+
25
+
26
+ class LineageError(RuntimeError):
27
+ """The stored lineage violates a structural invariant."""
28
+
29
+
30
+ def fold_live(raw: list[EventEnvelope]) -> list[EventEnvelope]:
31
+ """Compute the live timeline of a raw lineage."""
32
+ live: list[EventEnvelope] = []
33
+ position: dict[int, int] = {}
34
+ for event in raw:
35
+ if event.event_type is EventType.ROLLBACK_TRIGGERED:
36
+ target = event.payload.get("target_event_id")
37
+ pos = position.get(target) if isinstance(target, int) else None
38
+ if pos is None:
39
+ raise LineageError(
40
+ f"rollback event {event.event_id} targets {target!r}, "
41
+ "which is not live at that point in the lineage"
42
+ )
43
+ for dropped in live[pos + 1 :]:
44
+ del position[dropped.event_id] # type: ignore[arg-type]
45
+ del live[pos + 1 :]
46
+ position[event.event_id] = len(live) # type: ignore[index]
47
+ live.append(event)
48
+ return live
49
+
50
+
51
+ def plan_rollback(live: list[EventEnvelope], target_event_id: int) -> tuple[EventEnvelope, list[int]]:
52
+ """Validate a rollback target and return ``(target, reverted_event_ids)``.
53
+
54
+ Only live events are valid targets: an event that is already reverted
55
+ cannot be restored by rolling "forward" to it — fork from it instead.
56
+ """
57
+ for index, event in enumerate(live):
58
+ if event.event_id == target_event_id:
59
+ return event, [e.event_id for e in live[index + 1 :]] # type: ignore[misc]
60
+ raise ValueError(
61
+ f"event {target_event_id} is not on the live timeline of this branch; "
62
+ "to recover a reverted or foreign event, fork a new branch from it"
63
+ )
64
+
65
+
66
+ def merge_base(raw_a: list[EventEnvelope], raw_b: list[EventEnvelope]) -> EventEnvelope | None:
67
+ """Last event shared by two raw lineages (their longest common prefix)."""
68
+ base = None
69
+ for a, b in zip(raw_a, raw_b):
70
+ if a.event_id != b.event_id:
71
+ break
72
+ base = a
73
+ return base
74
+
75
+
76
+ def _summary(e: EventEnvelope) -> dict[str, Any]:
77
+ return {
78
+ "event_id": e.event_id,
79
+ "sequence": e.sequence,
80
+ "agent_id": e.agent_id,
81
+ "event_type": e.event_type.value,
82
+ }
83
+
84
+
85
+ def diff_lineages(
86
+ branch_a: str, raw_a: list[EventEnvelope], branch_b: str, raw_b: list[EventEnvelope]
87
+ ) -> dict[str, Any]:
88
+ """Divergence report between two branches.
89
+
90
+ Returns the merge base, the live events each side has beyond it, and a
91
+ key-by-key comparison of materialized decisions and workspace facts.
92
+ """
93
+ base = merge_base(raw_a, raw_b)
94
+ base_seq = (base.sequence or 0) if base is not None else 0
95
+ live_a, live_b = fold_live(raw_a), fold_live(raw_b)
96
+ state_a, state_b = materialize_live(live_a), materialize_live(live_b)
97
+
98
+ def compare(left: dict[str, Any], right: dict[str, Any], field: str) -> dict[str, Any]:
99
+ conflicts, only_left, only_right = [], {}, {}
100
+ for key in sorted(left.keys() | right.keys()):
101
+ if key not in right:
102
+ only_left[key] = left[key]
103
+ elif key not in left:
104
+ only_right[key] = right[key]
105
+ elif left[key][field] != right[key][field]:
106
+ conflicts.append({"key": key, branch_a: left[key], branch_b: right[key]})
107
+ return {"conflicts": conflicts, "only_in_a": only_left, "only_in_b": only_right}
108
+
109
+ decisions = compare(state_a.sections()["decisions"], state_b.sections()["decisions"], "value")
110
+ facts = compare(
111
+ state_a.sections()["active_workspace_facts"],
112
+ state_b.sections()["active_workspace_facts"],
113
+ "value",
114
+ )
115
+ ahead_a = [_summary(e) for e in live_a if (e.sequence or 0) > base_seq]
116
+ ahead_b = [_summary(e) for e in live_b if (e.sequence or 0) > base_seq]
117
+ rollbacks = {
118
+ branch: [
119
+ {"event_id": e.event_id, "target_event_id": e.payload.get("target_event_id")}
120
+ for e in raw
121
+ if e.event_type is EventType.ROLLBACK_TRIGGERED and (e.sequence or 0) > base_seq
122
+ ]
123
+ for branch, raw in ((branch_a, raw_a), (branch_b, raw_b))
124
+ }
125
+ return {
126
+ "branch_a": branch_a,
127
+ "branch_b": branch_b,
128
+ "merge_base_event_id": base.event_id if base is not None else None,
129
+ "merge_base_sequence": base_seq,
130
+ "ahead_a": ahead_a,
131
+ "ahead_b": ahead_b,
132
+ "rollbacks_since_base": rollbacks,
133
+ "conflicting_decisions": decisions["conflicts"],
134
+ "decisions_only_in_a": decisions["only_in_a"],
135
+ "decisions_only_in_b": decisions["only_in_b"],
136
+ "conflicting_facts": facts["conflicts"],
137
+ "identical_state": state_a.core_sections() == state_b.core_sections(),
138
+ }
139
+
140
+
141
+ def verify_lineage(raw: list[EventEnvelope]) -> list[str]:
142
+ """Check sequence contiguity, parent pointers and the SHA-256 hash chain.
143
+
144
+ Returns a list of human-readable violations (empty means intact).
145
+ """
146
+ problems: list[str] = []
147
+ prev_hash = GENESIS_HASH
148
+ prev_id: int | None = None
149
+ for index, event in enumerate(raw, start=1):
150
+ if event.sequence != index:
151
+ problems.append(f"event {event.event_id}: sequence {event.sequence} != expected {index}")
152
+ if event.parent_event_id != prev_id:
153
+ problems.append(
154
+ f"event {event.event_id}: parent_event_id {event.parent_event_id} != predecessor {prev_id}"
155
+ )
156
+ expected = event.compute_hash(prev_hash)
157
+ if event.hash != expected:
158
+ problems.append(f"event {event.event_id}: hash mismatch (content or chain tampered)")
159
+ prev_hash = event.hash or ""
160
+ prev_id = event.event_id
161
+ return problems
@@ -0,0 +1,209 @@
1
+ """Immutable event taxonomy and envelope schema.
2
+
3
+ Every state change in MemKernel is an :class:`EventEnvelope` appended to a
4
+ branch's log. Envelopes are frozen pydantic models; the store assigns
5
+ ``event_id``, ``sequence`` and ``hash`` at append time and returns a new
6
+ envelope rather than mutating the caller's copy.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from datetime import datetime, timezone
15
+ from enum import Enum
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
19
+
20
+ #: Hard cap on the serialized size of an event's ``payload`` and, separately,
21
+ #: its ``metadata``. Protects the log and every agent's context from flooding.
22
+ MAX_EVENT_BYTES = 256 * 1024
23
+
24
+ BRANCH_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
25
+ AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
26
+
27
+ GENESIS_HASH = "0" * 64
28
+
29
+
30
+ class EventType(str, Enum):
31
+ """Canonical event taxonomy."""
32
+
33
+ AGENT_TASK_INIT = "AGENT_TASK_INIT"
34
+ """Initial task directive and constraints for an agent."""
35
+ ARCHITECTURAL_DECISION = "ARCHITECTURAL_DECISION"
36
+ """Canonical, durable design choice keyed by ``decision_key``."""
37
+ FILE_CLAIM_ACQUIRED = "FILE_CLAIM_ACQUIRED"
38
+ """A lease on a resource was granted (carries the fencing token)."""
39
+ FILE_CLAIM_RELEASED = "FILE_CLAIM_RELEASED"
40
+ """A lease on a resource was released."""
41
+ TOOL_EXECUTION_LOG = "TOOL_EXECUTION_LOG"
42
+ """Ephemeral diagnostic / terminal output. Eligible for compaction."""
43
+ AGENT_SCRATCHPAD = "AGENT_SCRATCHPAD"
44
+ """Ephemeral intermediate reasoning turn. Eligible for compaction."""
45
+ SEMANTIC_SNAPSHOT = "SEMANTIC_SNAPSHOT"
46
+ """GC checkpoint holding distilled facts, decisions and blockers."""
47
+ BRANCH_FORKED = "BRANCH_FORKED"
48
+ """Audit record written as the first event of a forked branch."""
49
+ ROLLBACK_TRIGGERED = "ROLLBACK_TRIGGERED"
50
+ """Compensating event: the live timeline is truncated to its target."""
51
+
52
+
53
+ #: Event types whose content is transient and may be compacted by GC.
54
+ EPHEMERAL_TYPES: frozenset[EventType] = frozenset(
55
+ {EventType.TOOL_EXECUTION_LOG, EventType.AGENT_SCRATCHPAD}
56
+ )
57
+
58
+ #: Event types that only describe timeline structure, not workspace state.
59
+ CONTROL_TYPES: frozenset[EventType] = frozenset(
60
+ {EventType.BRANCH_FORKED, EventType.ROLLBACK_TRIGGERED}
61
+ )
62
+
63
+
64
+ def utcnow() -> datetime:
65
+ return datetime.now(timezone.utc)
66
+
67
+
68
+ def canonical_json(value: Any) -> str:
69
+ """Deterministic JSON encoding used for storage and hashing."""
70
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
71
+
72
+
73
+ def validate_branch_id(branch_id: str) -> str:
74
+ if not isinstance(branch_id, str) or not BRANCH_ID_PATTERN.fullmatch(branch_id):
75
+ raise ValueError(
76
+ f"invalid branch_id {branch_id!r}: expected 1-128 chars of [A-Za-z0-9._/-], "
77
+ "starting with a letter or digit"
78
+ )
79
+ return branch_id
80
+
81
+
82
+ def validate_agent_id(agent_id: str) -> str:
83
+ if not isinstance(agent_id, str) or not AGENT_ID_PATTERN.fullmatch(agent_id):
84
+ raise ValueError(
85
+ f"invalid agent_id {agent_id!r}: expected 1-128 chars of [A-Za-z0-9._:@-], "
86
+ "starting with a letter or digit"
87
+ )
88
+ return agent_id
89
+
90
+
91
+ class EventEnvelope(BaseModel):
92
+ """Immutable event record.
93
+
94
+ Fields assigned by the store (``event_id``, ``sequence``, ``hash``) are
95
+ ``None`` on envelopes that have not been persisted yet.
96
+
97
+ ``parent_event_id`` is the lineage predecessor (the branch head at append
98
+ time). When a caller supplies it, the store treats it as an optimistic
99
+ concurrency precondition: the append fails with
100
+ :class:`~memkernel.core.store.StaleHeadError` if the head has moved.
101
+ """
102
+
103
+ model_config = ConfigDict(frozen=True, extra="forbid", use_enum_values=False)
104
+
105
+ event_id: int | None = Field(default=None, ge=1)
106
+ sequence: int | None = Field(default=None, ge=1)
107
+ timestamp: datetime = Field(default_factory=utcnow)
108
+ branch_id: str
109
+ agent_id: str
110
+ parent_event_id: int | None = Field(default=None, ge=1)
111
+ event_type: EventType
112
+ payload: dict[str, Any] = Field(default_factory=dict)
113
+ metadata: dict[str, Any] = Field(default_factory=dict)
114
+ hash: str | None = None
115
+
116
+ @field_validator("branch_id")
117
+ @classmethod
118
+ def _check_branch(cls, v: str) -> str:
119
+ return validate_branch_id(v)
120
+
121
+ @field_validator("agent_id")
122
+ @classmethod
123
+ def _check_agent(cls, v: str) -> str:
124
+ return validate_agent_id(v)
125
+
126
+ @field_validator("timestamp")
127
+ @classmethod
128
+ def _utc(cls, v: datetime) -> datetime:
129
+ if v.tzinfo is None:
130
+ raise ValueError("timestamp must be timezone-aware (UTC)")
131
+ return v.astimezone(timezone.utc)
132
+
133
+ @field_validator("payload", "metadata")
134
+ @classmethod
135
+ def _json_safe(cls, v: dict[str, Any]) -> dict[str, Any]:
136
+ try:
137
+ encoded = canonical_json(v)
138
+ except (TypeError, ValueError) as exc:
139
+ raise ValueError(f"payload/metadata must be JSON-serializable: {exc}") from exc
140
+ if len(encoded.encode("utf-8")) > MAX_EVENT_BYTES:
141
+ raise ValueError(
142
+ f"payload/metadata exceeds {MAX_EVENT_BYTES} bytes; store large artifacts "
143
+ "outside MemKernel and reference them by path or digest"
144
+ )
145
+ # Round-trip so the in-memory value is exactly what gets persisted
146
+ # (tuples become lists, etc.), keeping hashes reproducible.
147
+ return json.loads(encoded)
148
+
149
+ @property
150
+ def timestamp_iso(self) -> str:
151
+ return self.timestamp.isoformat(timespec="microseconds")
152
+
153
+ @property
154
+ def is_ephemeral(self) -> bool:
155
+ return self.event_type in EPHEMERAL_TYPES
156
+
157
+ def hash_material(self, prev_hash: str) -> str:
158
+ """Canonical byte string covered by this event's hash-chain link."""
159
+ return canonical_json(
160
+ {
161
+ "prev": prev_hash,
162
+ "event_id": self.event_id,
163
+ "sequence": self.sequence,
164
+ "timestamp": self.timestamp_iso,
165
+ "branch_id": self.branch_id,
166
+ "agent_id": self.agent_id,
167
+ "parent_event_id": self.parent_event_id,
168
+ "event_type": self.event_type.value,
169
+ "payload": self.payload,
170
+ "metadata": self.metadata,
171
+ }
172
+ )
173
+
174
+ def compute_hash(self, prev_hash: str) -> str:
175
+ return hashlib.sha256(self.hash_material(prev_hash).encode("utf-8")).hexdigest()
176
+
177
+ def to_dict(self) -> dict[str, Any]:
178
+ return {
179
+ "event_id": self.event_id,
180
+ "sequence": self.sequence,
181
+ "timestamp": self.timestamp_iso,
182
+ "branch_id": self.branch_id,
183
+ "agent_id": self.agent_id,
184
+ "parent_event_id": self.parent_event_id,
185
+ "event_type": self.event_type.value,
186
+ "payload": self.payload,
187
+ "metadata": self.metadata,
188
+ "hash": self.hash,
189
+ }
190
+
191
+
192
+ def new_event(
193
+ event_type: EventType,
194
+ *,
195
+ branch_id: str,
196
+ agent_id: str,
197
+ payload: dict[str, Any] | None = None,
198
+ metadata: dict[str, Any] | None = None,
199
+ parent_event_id: int | None = None,
200
+ ) -> EventEnvelope:
201
+ """Convenience constructor for an unpersisted envelope."""
202
+ return EventEnvelope(
203
+ event_type=event_type,
204
+ branch_id=branch_id,
205
+ agent_id=agent_id,
206
+ payload=payload or {},
207
+ metadata=metadata or {},
208
+ parent_event_id=parent_event_id,
209
+ )