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 ADDED
@@ -0,0 +1,33 @@
1
+ """MemKernel: an event-sourced memory kernel for multi-agent coding systems."""
2
+
3
+ from memkernel.core.events import EventEnvelope, EventType
4
+ from memkernel.core.locks import LockManager
5
+ from memkernel.core.store import AsyncEventStore, BranchExistsError, BranchNotFoundError, StaleHeadError
6
+ from memkernel.kernel import DEFAULT_BRANCH, FencingTokenError, LeaseGrant, MemKernel
7
+ from memkernel.memory.gc import GCConfig, GCResult, HeuristicSummarizer, LLMSummarizer, MemoryGCEngine
8
+ from memkernel.memory.state_view import WorkspaceState
9
+ from memkernel.memory.token_counter import TokenCounter
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "AsyncEventStore",
15
+ "BranchExistsError",
16
+ "BranchNotFoundError",
17
+ "DEFAULT_BRANCH",
18
+ "EventEnvelope",
19
+ "EventType",
20
+ "FencingTokenError",
21
+ "GCConfig",
22
+ "GCResult",
23
+ "HeuristicSummarizer",
24
+ "LLMSummarizer",
25
+ "LeaseGrant",
26
+ "LockManager",
27
+ "MemKernel",
28
+ "MemoryGCEngine",
29
+ "StaleHeadError",
30
+ "TokenCounter",
31
+ "WorkspaceState",
32
+ "__version__",
33
+ ]
memkernel/cli.py ADDED
@@ -0,0 +1,409 @@
1
+ """``memkernel`` command line: attach agents, check health, inspect memory.
2
+
3
+ memkernel attach claude-code # or cursor | codex | antigravity | all
4
+ memkernel doctor # end-to-end health check
5
+ memkernel status # what the agents currently remember
6
+ memkernel serve # run the MCP server (what agents launch)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import tomllib
18
+ from dataclasses import dataclass
19
+ from importlib import metadata
20
+ from pathlib import Path
21
+ from typing import Any, Callable
22
+
23
+ from memkernel.kernel import DEFAULT_BRANCH, MemKernel, default_db_path
24
+
25
+ SERVER_NAME = "memkernel"
26
+ SERVER_MODULE = "memkernel.server.mcp_server"
27
+ CODEX_STARTUP_TIMEOUT_SEC = 120
28
+
29
+
30
+ # ------------------------------------------------------------------ attach
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Client:
35
+ name: str
36
+ agent_id: str
37
+ project_scoped: bool
38
+ config_paths: Callable[[Path, Path], list[Path]] # (project, home) -> files to write
39
+ fmt: str # "json" | "toml"
40
+ hint: str
41
+
42
+
43
+ def _antigravity_paths(project: Path, home: Path) -> list[Path]:
44
+ # Antigravity builds have used several locations; update every one that exists.
45
+ candidates = [
46
+ home / ".gemini" / "antigravity" / "mcp_config.json",
47
+ home / ".gemini" / "antigravity-ide" / "mcp_config.json",
48
+ home / ".gemini" / "config" / "mcp_config.json",
49
+ ]
50
+ existing = [p for p in candidates if p.exists()]
51
+ return existing or candidates[:1]
52
+
53
+
54
+ CLIENTS: dict[str, Client] = {
55
+ "claude-code": Client(
56
+ "claude-code", "claude-code-cli", True, lambda p, h: [p / ".mcp.json"], "json",
57
+ "Restart Claude Code in this project and approve the 'memkernel' server when prompted (/mcp lists it). "
58
+ "On a slow machine, raise its 30 s server start limit: set MCP_TIMEOUT=120000 before launching claude.",
59
+ ),
60
+ "cursor": Client(
61
+ "cursor", "cursor-ide", True, lambda p, h: [p / ".cursor" / "mcp.json"], "json",
62
+ "Open Cursor Settings -> MCP and enable 'memkernel'.",
63
+ ),
64
+ "codex": Client(
65
+ "codex", "codex-cli", False, lambda p, h: [h / ".codex" / "config.toml"], "toml",
66
+ "Start a new Codex session; `codex mcp list` shows 'memkernel'.",
67
+ ),
68
+ "antigravity": Client(
69
+ "antigravity", "antigravity", False, _antigravity_paths, "json",
70
+ "Reload MCP servers in Antigravity (Manage MCP Servers -> Refresh).",
71
+ ),
72
+ }
73
+
74
+
75
+ class AttachError(RuntimeError):
76
+ pass
77
+
78
+
79
+ def server_launch(*, portable: bool) -> tuple[str, list[str]]:
80
+ """Command that starts the server. Absolute interpreter by default: GUI
81
+ apps often do not inherit the shell PATH. ``portable`` uses the
82
+ ``memkernel`` entry point on PATH (for configs committed to a repo)."""
83
+ if portable:
84
+ return "memkernel", ["serve"]
85
+ return sys.executable, ["-m", SERVER_MODULE]
86
+
87
+
88
+ def build_entry(client: Client, *, project: Path, db: Path | None, portable: bool, agent_id: str | None) -> dict[str, Any]:
89
+ command, args = server_launch(portable=portable)
90
+ env = {"MEMKERNEL_AGENT_ID": agent_id or client.agent_id}
91
+ if db is not None:
92
+ env["MEMKERNEL_DB"] = str(db)
93
+ elif client.project_scoped and not portable:
94
+ env["MEMKERNEL_DB"] = str(project / ".memkernel" / "memkernel.db")
95
+ return {"command": command, "args": args, "env": env}
96
+
97
+
98
+ def _atomic_write(path: Path, text: str) -> None:
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ tmp = path.with_name(path.name + ".memkernel-tmp")
101
+ tmp.write_text(text, encoding="utf-8")
102
+ os.replace(tmp, path)
103
+
104
+
105
+ def merge_json_config(path: Path, entry: dict[str, Any]) -> tuple[str, str]:
106
+ """Return ``(action, new_text)``; other servers in the file are preserved."""
107
+ data: dict[str, Any] = {}
108
+ if path.exists():
109
+ raw = path.read_text(encoding="utf-8")
110
+ if raw.strip():
111
+ try:
112
+ data = json.loads(raw)
113
+ except json.JSONDecodeError as exc:
114
+ raise AttachError(f"{path} is not valid JSON ({exc}); fix or remove it, then re-run") from exc
115
+ if not isinstance(data, dict) or not isinstance(data.get("mcpServers", {}), dict):
116
+ raise AttachError(f"{path}: expected an object with an 'mcpServers' object")
117
+ servers = data.setdefault("mcpServers", {})
118
+ action = "unchanged" if servers.get(SERVER_NAME) == entry else ("updated" if SERVER_NAME in servers else "added")
119
+ servers[SERVER_NAME] = entry
120
+ return action, json.dumps(data, indent=2) + "\n"
121
+
122
+
123
+ _OUR_TABLE = re.compile(r'^\s*\[\s*mcp_servers\s*\.\s*(?:memkernel|"memkernel")\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$')
124
+ _ANY_TABLE = re.compile(r"^\s*\[")
125
+
126
+
127
+ def _toml_str(value: str) -> str:
128
+ return json.dumps(value) # JSON string escapes are valid TOML basic strings
129
+
130
+
131
+ def merge_toml_config(path: Path, entry: dict[str, Any]) -> tuple[str, str]:
132
+ """Replace (or append) the ``[mcp_servers.memkernel]`` tables in a Codex
133
+ ``config.toml``. The result is re-parsed and verified before returning."""
134
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
135
+ try:
136
+ existing = tomllib.loads(original).get("mcp_servers", {}).get(SERVER_NAME)
137
+ except tomllib.TOMLDecodeError as exc:
138
+ raise AttachError(f"{path} is not valid TOML ({exc}); fix it, then re-run") from exc
139
+ expected = dict(entry, default_tools_approval_mode="approve", startup_timeout_sec=CODEX_STARTUP_TIMEOUT_SEC)
140
+ if existing == expected:
141
+ return "unchanged", original
142
+
143
+ kept, skipping = [], False
144
+ for line in original.splitlines():
145
+ if _ANY_TABLE.match(line):
146
+ skipping = bool(_OUR_TABLE.match(line))
147
+ if not skipping:
148
+ kept.append(line)
149
+ block = [
150
+ f"[mcp_servers.{SERVER_NAME}]",
151
+ f"command = {_toml_str(entry['command'])}",
152
+ "args = [" + ", ".join(_toml_str(a) for a in entry["args"]) + "]",
153
+ "# Lets non-interactive `codex exec` call MemKernel tools (memory writes only).",
154
+ 'default_tools_approval_mode = "approve"',
155
+ "# The MCP SDK import dominates cold start; allow for slow or heavily loaded machines.",
156
+ f"startup_timeout_sec = {CODEX_STARTUP_TIMEOUT_SEC}",
157
+ "",
158
+ f"[mcp_servers.{SERVER_NAME}.env]",
159
+ *(f"{k} = {_toml_str(v)}" for k, v in entry["env"].items()),
160
+ ]
161
+ head = "\n".join(kept).rstrip()
162
+ text = (head + "\n\n" if head else "") + "\n".join(block) + "\n"
163
+ try:
164
+ parsed = tomllib.loads(text).get("mcp_servers", {}).get(SERVER_NAME)
165
+ except tomllib.TOMLDecodeError as exc:
166
+ parsed, detail = None, str(exc)
167
+ else:
168
+ detail = "entry did not round-trip"
169
+ if parsed != expected:
170
+ raise AttachError(
171
+ f"could not safely update {path} ({detail}); 'memkernel' may be defined with dotted keys. "
172
+ "Remove it from the file and re-run, or use `codex mcp add` (see docs/integrations.md)."
173
+ )
174
+ return ("updated" if existing is not None else "added"), text
175
+
176
+
177
+ def _ensure_gitignored(project: Path, dry_run: bool) -> str | None:
178
+ if not (project / ".git").exists():
179
+ return None
180
+ gitignore = project / ".gitignore"
181
+ lines = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.exists() else []
182
+ if any(line.strip().rstrip("/") in {".memkernel", "/.memkernel"} for line in lines):
183
+ return None
184
+ if not dry_run:
185
+ prefix = "" if not lines or lines[-1] == "" else "\n"
186
+ with gitignore.open("a", encoding="utf-8") as fh:
187
+ fh.write(f"{prefix}# MemKernel shared agent memory (local database)\n.memkernel/\n")
188
+ return str(gitignore)
189
+
190
+
191
+ def attach(
192
+ clients: list[str],
193
+ *,
194
+ project: Path,
195
+ home: Path,
196
+ db: Path | None = None,
197
+ portable: bool = False,
198
+ agent_id: str | None = None,
199
+ dry_run: bool = False,
200
+ ) -> list[dict[str, str]]:
201
+ """Register the MemKernel server with each client. Returns a report."""
202
+ project = project.resolve()
203
+ report: list[dict[str, str]] = []
204
+ if not dry_run:
205
+ (project / ".memkernel").mkdir(parents=True, exist_ok=True) # anchors DB discovery
206
+ ignored = _ensure_gitignored(project, dry_run)
207
+ if ignored:
208
+ note = "added .memkernel/ to .gitignore" + (" (dry run)" if dry_run else "")
209
+ report.append({"client": "git", "path": ignored, "action": note})
210
+ for name in clients:
211
+ client = CLIENTS[name]
212
+ entry = build_entry(client, project=project, db=db, portable=portable, agent_id=agent_id)
213
+ for path in client.config_paths(project, home):
214
+ merge = merge_toml_config if client.fmt == "toml" else merge_json_config
215
+ action, text = merge(path, entry)
216
+ if action != "unchanged" and not dry_run:
217
+ if path.exists() and not client.project_scoped:
218
+ backup = path.with_name(path.name + ".memkernel.bak")
219
+ backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
220
+ _atomic_write(path, text)
221
+ report.append({"client": name, "path": str(path), "action": action + (" (dry run)" if dry_run else ""),
222
+ "hint": client.hint})
223
+ return report
224
+
225
+
226
+ # ------------------------------------------------------------------ doctor
227
+
228
+
229
+ async def _doctor(project: Path, home: Path) -> bool:
230
+ ok = True
231
+
232
+ def line(status: str, label: str, detail: str = "") -> None:
233
+ print(f" [{status}] {label}" + (f": {detail}" if detail else ""))
234
+
235
+ try:
236
+ from mcp.server.fastmcp import FastMCP # noqa: F401
237
+ flavor = "FastMCP (mcp 1.x)"
238
+ except ModuleNotFoundError:
239
+ flavor = "MCPServer (mcp 2.x)"
240
+ print("MemKernel doctor")
241
+ line("ok", "python", sys.version.split()[0] + f" ({sys.executable})")
242
+ line("ok", "memkernel", _version())
243
+ line("ok", "mcp sdk", f"{metadata.version('mcp')} via {flavor}")
244
+
245
+ db = default_db_path(project)
246
+ try:
247
+ kernel = await MemKernel.open(db, auto_gc=False)
248
+ try:
249
+ branches = await kernel.store.list_branches()
250
+ problems = await kernel.verify(DEFAULT_BRANCH)
251
+ line("ok", "database", f"{db} (journal={kernel.store.journal_mode}, {len(branches)} branch(es))")
252
+ line("ok" if not problems else "FAIL", "hash chain (main)", "intact" if not problems else "; ".join(problems[:3]))
253
+ line("ok", "tokenizer", kernel.gc_engine.counter.backend_name)
254
+ ok &= not problems
255
+ finally:
256
+ await kernel.close()
257
+ except Exception as exc:
258
+ line("FAIL", "database", f"{db}: {exc}")
259
+ return False
260
+
261
+ from memkernel.clients.adapter import McpStdioClient
262
+
263
+ try:
264
+ async with McpStdioClient(agent_id="memkernel-doctor", db_path=str(db), cwd=str(project)) as client:
265
+ tools = await asyncio.wait_for(client.list_tools(), 60)
266
+ state = await client.call("memkernel_query_state")
267
+ line("ok", "mcp server (stdio)", f"{len(tools)} tools; query_state head={state['head_event_id']}")
268
+ except Exception as exc:
269
+ line("FAIL", "mcp server (stdio)", repr(exc))
270
+ ok = False
271
+
272
+ print("Agent configurations:")
273
+ for name, client in CLIENTS.items():
274
+ for path in client.config_paths(project, home):
275
+ entry = _read_entry(path, client.fmt)
276
+ if entry is None:
277
+ line("--", name, f"not attached ({path}); run `memkernel attach {name}`")
278
+ continue
279
+ command = entry.get("command", "")
280
+ resolvable = Path(command).exists() or bool(_which(command))
281
+ line("ok" if resolvable else "FAIL", name, f"{path} -> {command}" + ("" if resolvable else " (command not found)"))
282
+ ok &= resolvable
283
+ return ok
284
+
285
+
286
+ def _which(command: str) -> str | None:
287
+ import shutil
288
+
289
+ return shutil.which(command)
290
+
291
+
292
+ def _read_entry(path: Path, fmt: str) -> dict[str, Any] | None:
293
+ if not path.exists():
294
+ return None
295
+ try:
296
+ text = path.read_text(encoding="utf-8")
297
+ data = tomllib.loads(text).get("mcp_servers", {}) if fmt == "toml" else json.loads(text).get("mcpServers", {})
298
+ except Exception:
299
+ return None
300
+ entry = data.get(SERVER_NAME)
301
+ return entry if isinstance(entry, dict) else None
302
+
303
+
304
+ def _version() -> str:
305
+ try:
306
+ return metadata.version("memkernel")
307
+ except metadata.PackageNotFoundError:
308
+ from memkernel import __version__
309
+
310
+ return __version__
311
+
312
+
313
+ # ------------------------------------------------------------------ status
314
+
315
+
316
+ async def _status(project: Path, branch: str, as_json: bool) -> None:
317
+ db = default_db_path(project)
318
+ if not db.exists():
319
+ print(f"No MemKernel database at {db}. Attach an agent (`memkernel attach ...`) and use it first.")
320
+ return
321
+ kernel = await MemKernel.open(db, auto_gc=False)
322
+ try:
323
+ state = await kernel.query_state(branch)
324
+ branches = await kernel.store.list_branches()
325
+ finally:
326
+ await kernel.close()
327
+ if as_json:
328
+ print(json.dumps({"db": str(db), "branches": [b.to_dict() for b in branches], "state": state}, indent=2))
329
+ return
330
+ s = state["state"]
331
+ print(f"database {db}")
332
+ print("branches " + ", ".join(f"{b.branch_id}(head #{b.head_event_id})" for b in branches))
333
+ print(f"branch {branch}: head #{state['head_event_id']}, context {state['context_tokens']} tokens, "
334
+ f"snapshot #{state['snapshot_event_id']}")
335
+ for title, items in (
336
+ ("tasks", [f"{a}: {t['directive']}" for a, t in s["tasks"].items()]),
337
+ ("decisions", [f"{k} = {d['value']} ({d['agent_id']})" for k, d in s["decisions"].items()]),
338
+ ("facts", [f"{k} = {f['value']}" for k, f in s["active_workspace_facts"].items()]),
339
+ ("blockers", [f"{k}: {b['description']}" for k, b in s["execution_blockers"].items()]),
340
+ ("leases", [f"{l['resource_key']} held by {l['holder_agent_id']} ({l['expires_in_seconds']}s left)"
341
+ for l in state["active_leases"]]),
342
+ ):
343
+ print(f"{title:<9} " + ("\n ".join(items) if items else "-"))
344
+
345
+
346
+ # -------------------------------------------------------------------- main
347
+
348
+
349
+ def main(argv: list[str] | None = None) -> int:
350
+ parser = argparse.ArgumentParser(prog="memkernel", description="Shared, branchable memory for coding agents.")
351
+ parser.add_argument("--version", action="version", version=f"memkernel {_version()}")
352
+ sub = parser.add_subparsers(dest="command", required=True)
353
+
354
+ p_attach = sub.add_parser("attach", help="register MemKernel with an agent's MCP configuration")
355
+ p_attach.add_argument("clients", nargs="+", choices=[*CLIENTS, "all"])
356
+ p_attach.add_argument("--project", type=Path, default=Path.cwd(), help="project root (default: current directory)")
357
+ p_attach.add_argument("--db", type=Path, help="pin a database path instead of per-project discovery")
358
+ p_attach.add_argument("--agent-id", help="override the agent identity written to the config")
359
+ p_attach.add_argument("--portable", action="store_true",
360
+ help="launch via `memkernel serve` from PATH with no absolute paths (for committed configs)")
361
+ p_attach.add_argument("--dry-run", action="store_true", help="show what would change without writing")
362
+
363
+ p_doctor = sub.add_parser("doctor", help="check installation, database, server and agent configs")
364
+ p_doctor.add_argument("--project", type=Path, default=Path.cwd())
365
+
366
+ p_status = sub.add_parser("status", help="show the memory agents share in this project")
367
+ p_status.add_argument("--project", type=Path, default=Path.cwd())
368
+ p_status.add_argument("--branch", default=DEFAULT_BRANCH)
369
+ p_status.add_argument("--json", action="store_true")
370
+
371
+ sub.add_parser("serve", help="run the MCP server over stdio (flags: see `memkernel serve --help`)", add_help=False)
372
+
373
+ argv = list(sys.argv[1:] if argv is None else argv)
374
+ if argv[:1] == ["serve"]:
375
+ from memkernel.server.mcp_server import main as serve
376
+
377
+ serve(argv[1:])
378
+ return 0
379
+ args = parser.parse_args(argv)
380
+ home = Path.home()
381
+
382
+ if args.command == "attach":
383
+ names = list(CLIENTS) if "all" in args.clients else list(dict.fromkeys(args.clients))
384
+ if args.agent_id and len(names) > 1:
385
+ parser.error("--agent-id applies to a single client")
386
+ try:
387
+ report = attach(names, project=args.project, home=home, db=args.db, portable=args.portable,
388
+ agent_id=args.agent_id, dry_run=args.dry_run)
389
+ except AttachError as exc:
390
+ print(f"error: {exc}", file=sys.stderr)
391
+ return 1
392
+ for item in report:
393
+ print(f"{item['client']:<12} {item['action']:<10} {item['path']}")
394
+ print("\nNext steps:")
395
+ for hint in dict.fromkeys(item["hint"] for item in report if "hint" in item):
396
+ print(f" - {hint}")
397
+ print(" - Run `memkernel doctor` to verify everything end to end.")
398
+ return 0
399
+ if args.command == "doctor":
400
+ return 0 if asyncio.run(_doctor(args.project.resolve(), home)) else 1
401
+ if args.command == "status":
402
+ asyncio.run(_status(args.project.resolve(), args.branch, args.json))
403
+ return 0
404
+ parser.error(f"unknown command {args.command!r}")
405
+ return 2
406
+
407
+
408
+ if __name__ == "__main__":
409
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Client adapters for agent harnesses (in-process and MCP-over-stdio)."""