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.
- memkernel/__init__.py +33 -0
- memkernel/cli.py +409 -0
- memkernel/clients/__init__.py +1 -0
- memkernel/clients/adapter.py +296 -0
- memkernel/core/__init__.py +1 -0
- memkernel/core/branch.py +161 -0
- memkernel/core/events.py +209 -0
- memkernel/core/locks.py +188 -0
- memkernel/core/store.py +643 -0
- memkernel/kernel.py +352 -0
- memkernel/memory/__init__.py +1 -0
- memkernel/memory/gc.py +370 -0
- memkernel/memory/state_view.py +412 -0
- memkernel/memory/token_counter.py +67 -0
- memkernel/server/__init__.py +1 -0
- memkernel/server/mcp_server.py +379 -0
- memkernel_mcp-0.1.0.dist-info/METADATA +265 -0
- memkernel_mcp-0.1.0.dist-info/RECORD +22 -0
- memkernel_mcp-0.1.0.dist-info/WHEEL +5 -0
- memkernel_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- memkernel_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- memkernel_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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."""
|
memkernel/core/branch.py
ADDED
|
@@ -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
|
memkernel/core/events.py
ADDED
|
@@ -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
|
+
)
|