memorykit 0.6.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.
memorykit/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """memorykit: provenance-bound durable memory records, validator, and MCP server.
2
+
3
+ Standard library only, by design. That property is what makes this unit
4
+ separable from the `memory` plugin at all (ADR-0002, ADR-0009): it installs and
5
+ runs with no plugin host, no bootstrap step, and nothing to resolve at runtime
6
+ except the interpreter it was invoked with.
7
+
8
+ Two entry points, one implementation:
9
+
10
+ ``memorykit`` the CLI — validate, capture, search, review, record-state,
11
+ audit, doctor, and provider reconciliation.
12
+ ``memorykit-mcp`` a stdio MCP server exposing recall/capture/review to an
13
+ agent harness. It shells out to the CLI rather than reusing
14
+ its internals in-process, so the two surfaces cannot drift.
15
+
16
+ # @adr 0006
17
+ # @adr 0009
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ __version__ = "0.6.0"
23
+
24
+ __all__ = ["__version__"]
memorykit/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """`python -m memorykit` — the same entry point as the `memorykit` console script.
2
+
3
+ Present so the package stays usable when a console script is not on PATH, which
4
+ is the normal situation inside a virtual environment invoked by absolute
5
+ interpreter path, or when a launcher runs `sys.executable -m memorykit`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from memorykit.provider import main
11
+
12
+ if __name__ == "__main__":
13
+ raise SystemExit(main())
memorykit/mcp.py ADDED
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """Stdio MCP server exposing context-kit durable memory.
3
+
4
+ Standard library only. Every operation shells out to the `memorykit` provider
5
+ CLI with exact argv and no shell, so contract validation, project isolation,
6
+ review state, and reconciliation have exactly one implementation and the CLI and
7
+ MCP surfaces cannot drift apart.
8
+
9
+ The surface is deliberately small. A connected MCP server advertises its tool
10
+ schemas into the model's context on every turn, so each tool has a standing
11
+ cost; only operations that genuinely need live local state or an action are
12
+ exposed. `sync-provider`, `record-state` promotion, backup pruning, and session
13
+ mining stay explicit CLI operations.
14
+
15
+ # @adr 0009
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ SERVER_NAME = "context-kit-memory"
29
+ SERVER_VERSION = "0.3.0"
30
+ # Newest first. The client's requested version is echoed when supported.
31
+ SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
32
+ # The provider is this package's sibling module, so one path is correct in both
33
+ # deployment shapes (ADR-0009): `plugins/memory/src/memorykit/` in the catalog
34
+ # and `site-packages/memorykit/` from a `pip install`. It is invoked as a script
35
+ # rather than imported so a provider crash cannot take down the server loop, and
36
+ # by path rather than `-m memorykit.provider` so an in-tree run needs no
37
+ # PYTHONPATH.
38
+ PROVIDER = Path(__file__).resolve().with_name("provider.py")
39
+ CALL_TIMEOUT_SECONDS = 120.0
40
+ MAX_RECORD_BYTES = 32 * 1024
41
+
42
+ PARSE_ERROR = -32700
43
+ INVALID_REQUEST = -32600
44
+ METHOD_NOT_FOUND = -32601
45
+ INVALID_PARAMS = -32602
46
+ INTERNAL_ERROR = -32603
47
+
48
+ TOOLS: list[dict[str, Any]] = [
49
+ {
50
+ "name": "memory_recall",
51
+ "description": (
52
+ "Search reviewed durable memory for this project. Returns "
53
+ "accepted/current records only, as candidate leads whose cited "
54
+ "source must still be checked against current code."
55
+ ),
56
+ "inputSchema": {
57
+ "type": "object",
58
+ "properties": {
59
+ "query": {
60
+ "type": "string",
61
+ "description": "What you are trying to remember.",
62
+ },
63
+ "results": {
64
+ "type": "integer",
65
+ "minimum": 1,
66
+ "maximum": 50,
67
+ "description": "Maximum records to return (default 8).",
68
+ },
69
+ },
70
+ "required": ["query"],
71
+ "additionalProperties": False,
72
+ },
73
+ },
74
+ {
75
+ "name": "memory_capture",
76
+ "description": (
77
+ "Persist a context-kit/memory-v1 record as review: proposed. The "
78
+ "record must cite real evidence: its source_hash is verified "
79
+ "against the source file. Proposed records are inert until a human "
80
+ "accepts them with the record-state CLI."
81
+ ),
82
+ "inputSchema": {
83
+ "type": "object",
84
+ "properties": {
85
+ "record": {
86
+ "type": "string",
87
+ "description": (
88
+ "Complete memory-v1 markdown: flat YAML frontmatter "
89
+ "plus Primary Memory, Cue Anchors, Evidence, "
90
+ "Supersedes, and Review Notes sections."
91
+ ),
92
+ }
93
+ },
94
+ "required": ["record"],
95
+ "additionalProperties": False,
96
+ },
97
+ },
98
+ {
99
+ "name": "memory_review",
100
+ "description": (
101
+ "List this project's memory records with their effective review "
102
+ "and freshness state, including inactive ones. Read-only."
103
+ ),
104
+ "inputSchema": {
105
+ "type": "object",
106
+ "properties": {},
107
+ "additionalProperties": False,
108
+ },
109
+ },
110
+ ]
111
+
112
+
113
+ class ToolError(Exception):
114
+ """A tool-level failure reported to the model, not a protocol error."""
115
+
116
+
117
+ def _log(message: str) -> None:
118
+ # stdout carries only JSON-RPC frames.
119
+ print(f"{SERVER_NAME}: {message}", file=sys.stderr, flush=True)
120
+
121
+
122
+ def _project_scope() -> str:
123
+ for name in ("CONTEXT_KIT_MEMORY_PROJECT", "CLAUDE_PLUGIN_OPTION_PROJECT"):
124
+ # Portable first, then the Claude userConfig fallback the CLI accepts.
125
+ # Checking only the portable name would make every tool refuse on a
126
+ # normal Claude install configured through the plugin's option.
127
+ project = os.environ.get(name, "").strip()
128
+ if project:
129
+ return project
130
+ raise ToolError(
131
+ "no memory project is configured; set CONTEXT_KIT_MEMORY_PROJECT to "
132
+ "an explicit owner/repository. Memory is never read from or written "
133
+ "to an inferred global store."
134
+ )
135
+
136
+
137
+ def _run_provider(argv: list[str]) -> str:
138
+ if not PROVIDER.is_file():
139
+ raise ToolError(f"memory provider script is missing: {PROVIDER}")
140
+ project = _project_scope()
141
+ command = [sys.executable, str(PROVIDER), *argv, "--project", project]
142
+ try:
143
+ result = subprocess.run(
144
+ command,
145
+ capture_output=True,
146
+ check=False,
147
+ timeout=CALL_TIMEOUT_SECONDS,
148
+ )
149
+ except subprocess.TimeoutExpired as exc:
150
+ raise ToolError(
151
+ f"memory command timed out after {CALL_TIMEOUT_SECONDS:g}s"
152
+ ) from exc
153
+ except OSError as exc:
154
+ raise ToolError(f"memory command could not run: {exc}") from exc
155
+ if result.returncode != 0:
156
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
157
+ raise ToolError(detail or f"memory command exited {result.returncode}")
158
+ return result.stdout.decode("utf-8", errors="replace").strip()
159
+
160
+
161
+ def _frontmatter_value(record: str, field: str) -> str | None:
162
+ lines = record.splitlines()
163
+ if not lines or lines[0].strip() != "---":
164
+ return None
165
+ for line in lines[1:]:
166
+ if line.strip() == "---":
167
+ break
168
+ key, separator, value = line.partition(":")
169
+ if separator and key.strip() == field:
170
+ return value.strip()
171
+ return None
172
+
173
+
174
+ def _tool_memory_recall(arguments: dict[str, Any]) -> str:
175
+ query = arguments.get("query")
176
+ if not isinstance(query, str) or not query.strip():
177
+ raise ToolError("`query` must be a non-empty string")
178
+ results = arguments.get("results", 8)
179
+ if not isinstance(results, int) or isinstance(results, bool):
180
+ raise ToolError("`results` must be an integer")
181
+ if not 1 <= results <= 50:
182
+ raise ToolError("`results` must be between 1 and 50")
183
+ return _run_provider(["search", query, "--results", str(results)])
184
+
185
+
186
+ def _tool_memory_capture(arguments: dict[str, Any]) -> str:
187
+ record = arguments.get("record")
188
+ if not isinstance(record, str) or not record.strip():
189
+ raise ToolError("`record` must be a non-empty memory-v1 document")
190
+ raw = record.encode("utf-8")
191
+ if len(raw) > MAX_RECORD_BYTES:
192
+ raise ToolError(
193
+ f"record exceeds {MAX_RECORD_BYTES} bytes; keep a memory atomic"
194
+ )
195
+ review = _frontmatter_value(record, "review")
196
+ if review is None:
197
+ raise ToolError("record is missing flat YAML frontmatter with a `review` field")
198
+ if review != "proposed":
199
+ # `capture` takes the initial state from frontmatter, so without this
200
+ # guard an agent could write `review: accepted` and activate a memory
201
+ # with no human review at all.
202
+ raise ToolError(
203
+ "this surface can only propose memory, but the record declares "
204
+ f"review: {review}. Set `review: proposed` and promote it later "
205
+ "with `memory-provider.py record-state <id> --review accepted "
206
+ "--reason ...` after the evidence has been checked."
207
+ )
208
+ # `validate_memory` verifies `source_hash` only when the source is a
209
+ # *regular file* (`source.is_file()`), so `exists()` here would be a weaker
210
+ # gate than the one it exists to mirror: a record citing a directory would
211
+ # pass this check, skip hash verification entirely, and persist with any
212
+ # 64-character hash and unverifiable provenance. Match the provider.
213
+ source = _frontmatter_value(record, "source")
214
+ if not source:
215
+ raise ToolError("record is missing a `source` field citing its evidence")
216
+ if not Path(source).expanduser().is_file():
217
+ raise ToolError(
218
+ f"the cited source is not a readable file: {source}. A memory must "
219
+ "point at evidence that can be re-read and hashed."
220
+ )
221
+ handle, temporary = tempfile.mkstemp(prefix="memory-capture-", suffix=".md")
222
+ path = Path(temporary)
223
+ try:
224
+ with os.fdopen(handle, "wb") as stream:
225
+ stream.write(raw)
226
+ return _run_provider(["capture", str(path)])
227
+ finally:
228
+ path.unlink(missing_ok=True)
229
+
230
+
231
+ def _tool_memory_review(arguments: dict[str, Any]) -> str:
232
+ if arguments:
233
+ raise ToolError("`memory_review` takes no arguments")
234
+ return _run_provider(["review"])
235
+
236
+
237
+ HANDLERS = {
238
+ "memory_recall": _tool_memory_recall,
239
+ "memory_capture": _tool_memory_capture,
240
+ "memory_review": _tool_memory_review,
241
+ }
242
+
243
+
244
+ def _negotiate(requested: Any) -> str:
245
+ if isinstance(requested, str) and requested in SUPPORTED_PROTOCOLS:
246
+ return requested
247
+ return SUPPORTED_PROTOCOLS[0]
248
+
249
+
250
+ def _tool_result(request_id: Any, text: str, *, is_error: bool) -> dict[str, Any]:
251
+ return {
252
+ "jsonrpc": "2.0",
253
+ "id": request_id,
254
+ "result": {
255
+ "content": [{"type": "text", "text": text}],
256
+ "isError": is_error,
257
+ },
258
+ }
259
+
260
+
261
+ def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
262
+ return {
263
+ "jsonrpc": "2.0",
264
+ "id": request_id,
265
+ "error": {"code": code, "message": message},
266
+ }
267
+
268
+
269
+ def _handle(request: dict[str, Any]) -> dict[str, Any] | None:
270
+ method = request.get("method")
271
+ request_id = request.get("id")
272
+ is_notification = "id" not in request
273
+
274
+ if method == "initialize":
275
+ params = request.get("params") or {}
276
+ return {
277
+ "jsonrpc": "2.0",
278
+ "id": request_id,
279
+ "result": {
280
+ "protocolVersion": _negotiate(params.get("protocolVersion")),
281
+ "capabilities": {"tools": {"listChanged": False}},
282
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
283
+ },
284
+ }
285
+
286
+ if isinstance(method, str) and method.startswith("notifications/"):
287
+ return None
288
+
289
+ if is_notification:
290
+ return None
291
+
292
+ if method == "ping":
293
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
294
+
295
+ if method == "tools/list":
296
+ return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": TOOLS}}
297
+
298
+ if method == "tools/call":
299
+ params = request.get("params") or {}
300
+ name = params.get("name")
301
+ arguments = params.get("arguments") or {}
302
+ if not isinstance(arguments, dict):
303
+ return _error(request_id, INVALID_PARAMS, "`arguments` must be an object")
304
+ handler = HANDLERS.get(name) if isinstance(name, str) else None
305
+ if handler is None:
306
+ return _error(request_id, INVALID_PARAMS, f"unknown tool: {name!r}")
307
+ try:
308
+ output = handler(arguments)
309
+ except ToolError as exc:
310
+ # Tool failures are results, not protocol errors, so the model can
311
+ # read the refusal and act on it.
312
+ return _tool_result(request_id, str(exc), is_error=True)
313
+ except Exception as exc: # noqa: BLE001 - never kill the server loop
314
+ _log(f"unexpected failure in {name}: {exc}")
315
+ return _tool_result(request_id, f"unexpected failure: {exc}", is_error=True)
316
+ return _tool_result(request_id, output or "{}", is_error=False)
317
+
318
+ return _error(request_id, METHOD_NOT_FOUND, f"unknown method: {method!r}")
319
+
320
+
321
+ def _write(sink: Any, payload: dict[str, Any]) -> None:
322
+ sink.write(json.dumps(payload) + "\n")
323
+ sink.flush()
324
+
325
+
326
+ def serve(stdin: Any = None, stdout: Any = None) -> int:
327
+ source = stdin if stdin is not None else sys.stdin
328
+ sink = stdout if stdout is not None else sys.stdout
329
+ for line in source:
330
+ line = line.strip()
331
+ if not line:
332
+ continue
333
+ try:
334
+ request = json.loads(line)
335
+ except json.JSONDecodeError as exc:
336
+ _write(sink, _error(None, PARSE_ERROR, f"invalid JSON: {exc}"))
337
+ continue
338
+ if not isinstance(request, dict) or request.get("jsonrpc") != "2.0":
339
+ _write(
340
+ sink, _error(None, INVALID_REQUEST, "expected a JSON-RPC 2.0 object")
341
+ )
342
+ continue
343
+ try:
344
+ response = _handle(request)
345
+ except Exception as exc: # noqa: BLE001 - a bad frame must not end the loop
346
+ _log(f"internal error: {exc}")
347
+ response = _error(request.get("id"), INTERNAL_ERROR, str(exc))
348
+ if response is not None:
349
+ _write(sink, response)
350
+ return 0
351
+
352
+
353
+ def main(argv: list[str] | None = None) -> int:
354
+ """Console-script entry point for `memorykit-mcp`.
355
+
356
+ Takes no arguments: an MCP server is configured by the client that spawns
357
+ it, over stdio. Anything on argv is a caller mistake — most likely a CLI
358
+ subcommand aimed at the wrong entry point — so it is refused rather than
359
+ silently ignored, which would hang the caller on a stdio read.
360
+ """
361
+ arguments = sys.argv[1:] if argv is None else argv
362
+ if arguments:
363
+ print(
364
+ f"{SERVER_NAME}: this is a stdio MCP server and takes no arguments; "
365
+ f"got {arguments!r}. For the CLI, run `memorykit --help`.",
366
+ file=sys.stderr,
367
+ )
368
+ return 2
369
+ return serve()
370
+
371
+
372
+ if __name__ == "__main__":
373
+ raise SystemExit(main())