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 +24 -0
- memorykit/__main__.py +13 -0
- memorykit/mcp.py +373 -0
- memorykit/provider.py +2908 -0
- memorykit-0.6.0.dist-info/METADATA +292 -0
- memorykit-0.6.0.dist-info/RECORD +9 -0
- memorykit-0.6.0.dist-info/WHEEL +4 -0
- memorykit-0.6.0.dist-info/entry_points.txt +3 -0
- memorykit-0.6.0.dist-info/licenses/LICENSE +21 -0
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())
|