keepm 0.2.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.
keepm/server.py ADDED
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import dataclasses
6
+ import datetime as dt
7
+ import hashlib
8
+ import sqlite3
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+ from collections.abc import Sequence
12
+ from typing import Any
13
+
14
+ from keepm import __version__
15
+ from keepm.config import ConfigStore, KeepMConfig, MemoryLanguage, default_data_root
16
+ from keepm.database import SCHEMA_VERSION, MemoryDatabase
17
+ from keepm.models import MemoryDocument
18
+ from keepm.projects import resolve_workspace
19
+ from keepm.service import MemoryService
20
+ from keepm.storage import MemoryStorage
21
+ from keepm.sync import SyncEngine
22
+ from keepm.watcher import WatcherService
23
+
24
+ try:
25
+ from fastmcp import Context, FastMCP
26
+ except ImportError:
27
+ Context = Any # type: ignore[misc,assignment]
28
+ FastMCP = None # type: ignore[assignment]
29
+
30
+
31
+ def _vault_id(config: KeepMConfig) -> str:
32
+ identity = f"{config.vault_path}\0{config.memory_root.resolve()}"
33
+ return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
34
+
35
+
36
+ def _jsonable(value: Any) -> Any:
37
+ if dataclasses.is_dataclass(value):
38
+ return _jsonable(dataclasses.asdict(value))
39
+ if isinstance(value, Path):
40
+ return str(value)
41
+ if isinstance(value, dict):
42
+ return {str(key): _jsonable(item) for key, item in value.items()}
43
+ if isinstance(value, (tuple, list, set)):
44
+ return [_jsonable(item) for item in value]
45
+ return value
46
+
47
+
48
+ def _document_result(
49
+ document: MemoryDocument, checksum: str, *, max_chars: int
50
+ ) -> dict[str, Any]:
51
+ if max_chars < 1:
52
+ raise ValueError("max_chars must be positive")
53
+ payload = _jsonable(document)
54
+ body = str(payload["body"])
55
+ truncated = len(body) > max_chars
56
+ payload["body"] = body[:max_chars]
57
+ return {
58
+ "memory": payload,
59
+ "checksum": checksum,
60
+ "output_chars": len(payload["body"]),
61
+ "truncated": truncated,
62
+ }
63
+
64
+
65
+ class Runtime:
66
+ def __init__(
67
+ self,
68
+ *,
69
+ config_path: Path | None = None,
70
+ data_root: Path | None = None,
71
+ ) -> None:
72
+ self.config_store = ConfigStore(config_path)
73
+ self.data_root = (data_root or default_data_root()).expanduser().resolve()
74
+ self.config: KeepMConfig | None = None
75
+ self.database: MemoryDatabase | None = None
76
+ self.storage: MemoryStorage | None = None
77
+ self.sync_engine: SyncEngine | None = None
78
+ self.service: MemoryService | None = None
79
+ self.watcher: WatcherService | None = None
80
+ self.last_error: str | None = None
81
+ self.recovered_index: str | None = None
82
+ self._lock = asyncio.Lock()
83
+
84
+ async def start(self) -> None:
85
+ async with self._lock:
86
+ if self.service is not None:
87
+ return
88
+ try:
89
+ config = self.config_store.load()
90
+ except FileNotFoundError:
91
+ return
92
+ except Exception as error:
93
+ self.last_error = f"cannot load KeepM configuration: {error}"
94
+ return
95
+ try:
96
+ await self._build_locked(config)
97
+ except Exception as error:
98
+ self.last_error = f"cannot initialize KeepM: {error}"
99
+
100
+ async def configure(
101
+ self,
102
+ *,
103
+ vault_path: str,
104
+ memory_dir: str = "Memory",
105
+ stale_after_seconds: int = 30,
106
+ language: MemoryLanguage | None = None,
107
+ ) -> dict[str, Any]:
108
+ vault = Path(vault_path).expanduser().resolve()
109
+ if not vault.is_dir():
110
+ raise ValueError(f"vault_path must be an existing directory: {vault}")
111
+ existing = self.config
112
+ if existing is None:
113
+ existing = self.config_store.load_optional()
114
+ config = KeepMConfig(
115
+ vault_path=vault,
116
+ memory_dir=memory_dir,
117
+ stale_after_seconds=stale_after_seconds,
118
+ language=language or (existing.language if existing is not None else "en"),
119
+ )
120
+ async with self._lock:
121
+ await self._teardown_locked()
122
+ self.config_store.save(config)
123
+ await self._build_locked(config)
124
+ self.last_error = None
125
+ return self.status()
126
+
127
+ async def _build_locked(self, config: KeepMConfig) -> None:
128
+ self.config = config
129
+ self.storage = MemoryStorage(config)
130
+ self.database = self._open_database(config)
131
+ self.sync_engine = SyncEngine(self.storage, self.database)
132
+ self.service = MemoryService(
133
+ config, self.storage, self.database, self.sync_engine
134
+ )
135
+ report = self.sync_engine.quick_reconcile()
136
+ self.database.set_state("dirty", "1" if report.errors else "0")
137
+ self.watcher = WatcherService(config, self.sync_engine)
138
+ await self.watcher.start()
139
+
140
+ def _open_database(self, config: KeepMConfig) -> MemoryDatabase:
141
+ database_path = self.data_root / "indexes" / f"{_vault_id(config)}.sqlite3"
142
+ database: MemoryDatabase | None = None
143
+ try:
144
+ database = MemoryDatabase(database_path)
145
+ if not database.quick_check():
146
+ raise sqlite3.DatabaseError("PRAGMA quick_check failed")
147
+ if database.get_state("schema_version") != SCHEMA_VERSION:
148
+ raise sqlite3.DatabaseError("incompatible KeepM index schema")
149
+ canonical_root = database.get_state("canonical_root")
150
+ expected_root = str(config.memory_root.resolve())
151
+ if canonical_root not in (None, expected_root):
152
+ raise sqlite3.DatabaseError("index belongs to a different Memory root")
153
+ except (OSError, RuntimeError, sqlite3.DatabaseError) as error:
154
+ if database is not None:
155
+ database.close()
156
+ if database_path.exists():
157
+ suffix = dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
158
+ preserved = database_path.with_name(
159
+ f"{database_path.name}.corrupt-{suffix}"
160
+ )
161
+ database_path.replace(preserved)
162
+ for sidecar_suffix in ("-wal", "-shm"):
163
+ sidecar = Path(str(database_path) + sidecar_suffix)
164
+ if sidecar.exists():
165
+ sidecar.replace(Path(str(preserved) + sidecar_suffix))
166
+ self.recovered_index = f"{preserved}: {error}"
167
+ database = MemoryDatabase(database_path)
168
+
169
+ database.set_state("canonical_root", str(config.memory_root.resolve()))
170
+ return database
171
+
172
+ async def _teardown_locked(self) -> None:
173
+ if self.watcher is not None:
174
+ await self.watcher.stop()
175
+ if self.database is not None:
176
+ self.database.close()
177
+ self.config = None
178
+ self.database = None
179
+ self.storage = None
180
+ self.sync_engine = None
181
+ self.service = None
182
+ self.watcher = None
183
+
184
+ async def close(self) -> None:
185
+ async with self._lock:
186
+ await self._teardown_locked()
187
+
188
+ def require_service(self) -> MemoryService:
189
+ if self.service is None:
190
+ raise RuntimeError(
191
+ "KeepM is not configured. Call memory_configure with your Obsidian Vault path."
192
+ )
193
+ return self.service
194
+
195
+ def status(self, workspace: Path | None = None) -> dict[str, Any]:
196
+ if self.service is None:
197
+ return {
198
+ "configured": False,
199
+ "config_path": str(self.config_store.path),
200
+ "error": self.last_error,
201
+ }
202
+ status = self.service.status(workspace=workspace)
203
+ status.update(
204
+ {
205
+ "watcher_running": bool(self.watcher and self.watcher.running),
206
+ "watcher_error": self.watcher.last_error if self.watcher else None,
207
+ "recovered_index": self.recovered_index,
208
+ "error": self.last_error,
209
+ }
210
+ )
211
+ return status
212
+
213
+
214
+ async def _workspace_from_context(
215
+ context: Any, workspace_path: str | None
216
+ ) -> Path:
217
+ client_roots: list[str] = []
218
+ list_roots = getattr(context, "list_roots", None)
219
+ if list_roots is not None:
220
+ try:
221
+ roots = await list_roots()
222
+ for root in roots or ():
223
+ client_roots.append(str(getattr(root, "uri", root)))
224
+ except Exception:
225
+ client_roots = []
226
+ return resolve_workspace(client_roots, workspace_path, Path.cwd())
227
+
228
+
229
+ def create_mcp(
230
+ *,
231
+ config_path: Path | None = None,
232
+ data_root: Path | None = None,
233
+ ) -> Any:
234
+ if FastMCP is None:
235
+ raise RuntimeError(
236
+ "FastMCP is not installed. Install KeepM with its declared dependencies."
237
+ )
238
+ runtime = Runtime(config_path=config_path, data_root=data_root)
239
+
240
+ @asynccontextmanager
241
+ async def lifespan(server: Any):
242
+ await runtime.start()
243
+ try:
244
+ yield {}
245
+ finally:
246
+ await runtime.close()
247
+
248
+ server = FastMCP("KeepM", lifespan=lifespan)
249
+ read_only = {"readOnlyHint": True, "openWorldHint": False}
250
+ local_write = {
251
+ "readOnlyHint": False,
252
+ "destructiveHint": False,
253
+ "openWorldHint": False,
254
+ }
255
+
256
+ @server.tool(annotations=local_write)
257
+ async def memory_configure(
258
+ vault_path: str,
259
+ memory_dir: str = "Memory",
260
+ stale_after_seconds: int = 30,
261
+ language: MemoryLanguage | None = None,
262
+ ) -> dict[str, Any]:
263
+ """Configure the one local Obsidian Vault used by KeepM."""
264
+ return await runtime.configure(
265
+ vault_path=vault_path,
266
+ memory_dir=memory_dir,
267
+ stale_after_seconds=stale_after_seconds,
268
+ language=language,
269
+ )
270
+
271
+ @server.tool(annotations=read_only)
272
+ async def memory_status(
273
+ ctx: Context, workspace_path: str | None = None
274
+ ) -> dict[str, Any]:
275
+ """Report configuration, project, watcher, database, and sync state."""
276
+ workspace = None
277
+ if runtime.service is not None:
278
+ workspace = await _workspace_from_context(ctx, workspace_path)
279
+ return runtime.status(workspace)
280
+
281
+ @server.tool(annotations=read_only)
282
+ async def memory_context(
283
+ ctx: Context,
284
+ query: str,
285
+ workspace_path: str | None = None,
286
+ limit: int = 10,
287
+ max_chars: int = 6_000,
288
+ ) -> dict[str, Any]:
289
+ """Return bounded pinned and query-relevant project/global memories."""
290
+ service = runtime.require_service()
291
+ workspace = await _workspace_from_context(ctx, workspace_path)
292
+ await service.ensure_fresh()
293
+ items = service.context(
294
+ workspace=workspace, query=query, limit=limit, max_chars=max_chars
295
+ )
296
+ output_chars = sum(len(item.snippet) for item in items)
297
+ return {
298
+ "items": _jsonable(items),
299
+ "count": len(items),
300
+ "output_chars": output_chars,
301
+ "truncated": len(items) >= limit or output_chars >= max_chars,
302
+ "language": service.config.language,
303
+ }
304
+
305
+ @server.tool(annotations=read_only)
306
+ async def memory_search(
307
+ ctx: Context,
308
+ query: str,
309
+ workspace_path: str | None = None,
310
+ scope: str | None = None,
311
+ limit: int = 10,
312
+ ) -> dict[str, Any]:
313
+ """Search the active project and global Markdown memory index."""
314
+ service = runtime.require_service()
315
+ workspace = await _workspace_from_context(ctx, workspace_path)
316
+ await service.ensure_fresh()
317
+ items = service.search(
318
+ workspace=workspace, query=query, scope=scope, limit=limit
319
+ )
320
+ return {"items": _jsonable(items), "count": len(items), "limit": limit}
321
+
322
+ @server.tool(annotations=read_only)
323
+ async def memory_read(
324
+ ctx: Context,
325
+ identifier: str,
326
+ workspace_path: str | None = None,
327
+ max_chars: int = 20_000,
328
+ ) -> dict[str, Any]:
329
+ """Read one authoritative Markdown memory by ID, name, or safe path."""
330
+ service = runtime.require_service()
331
+ workspace = await _workspace_from_context(ctx, workspace_path)
332
+ await service.ensure_fresh()
333
+ document, checksum = service.read_with_checksum(
334
+ workspace=workspace, identifier=identifier
335
+ )
336
+ return _document_result(document, checksum, max_chars=max_chars)
337
+
338
+ @server.tool(annotations=local_write)
339
+ async def memory_write(
340
+ ctx: Context,
341
+ name: str,
342
+ description: str,
343
+ memory_type: str,
344
+ body: str,
345
+ scope: str = "project",
346
+ tags: list[str] | None = None,
347
+ pinned: bool = False,
348
+ workspace_path: str | None = None,
349
+ expected_checksum: str | None = None,
350
+ ) -> dict[str, Any]:
351
+ """Create or update an atomic Markdown memory and its derived index."""
352
+ service = runtime.require_service()
353
+ workspace = await _workspace_from_context(ctx, workspace_path)
354
+ await service.ensure_fresh()
355
+ document = service.write(
356
+ workspace=workspace,
357
+ scope=scope,
358
+ name=name,
359
+ description=description,
360
+ memory_type=memory_type,
361
+ body=body,
362
+ tags=tuple(tags or ()),
363
+ pinned=pinned,
364
+ expected_checksum=expected_checksum,
365
+ )
366
+ record = service.database.get_by_id(document.id)
367
+ if record is None:
368
+ raise RuntimeError("memory was written but is absent from the index")
369
+ return _document_result(document, record.checksum, max_chars=2_000)
370
+
371
+ @server.tool(
372
+ annotations={
373
+ "readOnlyHint": False,
374
+ "destructiveHint": True,
375
+ "openWorldHint": False,
376
+ }
377
+ )
378
+ async def memory_delete(
379
+ ctx: Context,
380
+ identifier: str,
381
+ workspace_path: str | None = None,
382
+ ) -> dict[str, Any]:
383
+ """Soft-delete one memory into Memory/.trash."""
384
+ service = runtime.require_service()
385
+ workspace = await _workspace_from_context(ctx, workspace_path)
386
+ await service.ensure_fresh()
387
+ trashed = service.delete(workspace=workspace, identifier=identifier)
388
+ return {"deleted": True, "trash_path": str(trashed)}
389
+
390
+ @server.tool(annotations=read_only)
391
+ async def memory_links(
392
+ ctx: Context,
393
+ identifier: str,
394
+ direction: str = "both",
395
+ workspace_path: str | None = None,
396
+ ) -> dict[str, Any]:
397
+ """Return forward WikiLinks and/or backlinks for one memory."""
398
+ service = runtime.require_service()
399
+ workspace = await _workspace_from_context(ctx, workspace_path)
400
+ await service.ensure_fresh()
401
+ result = _jsonable(
402
+ service.links(
403
+ workspace=workspace, identifier=identifier, direction=direction
404
+ )
405
+ )
406
+ result["forward_count"] = len(result.get("forward", []))
407
+ result["backlink_count"] = len(result.get("backlinks", []))
408
+ result["count"] = result["forward_count"] + result["backlink_count"]
409
+ return result
410
+
411
+ @server.tool(annotations=local_write)
412
+ async def memory_sync(full: bool = False) -> dict[str, Any]:
413
+ """Run quick checksum reconciliation or a full derived-index rebuild."""
414
+ report = runtime.require_service().sync(full=full)
415
+ payload = _jsonable(report)
416
+ payload["error_count"] = len(payload["errors"])
417
+ return {"report": payload}
418
+
419
+ @server.tool(annotations=read_only)
420
+ async def memory_doctor() -> dict[str, Any]:
421
+ """Diagnose Markdown and SQLite consistency without modifying memories."""
422
+ findings = runtime.require_service().doctor()
423
+ return {"findings": _jsonable(findings), "count": len(findings)}
424
+
425
+ setattr(server, "_keepm_runtime", runtime)
426
+ return server
427
+
428
+
429
+ try:
430
+ mcp = create_mcp()
431
+ except RuntimeError:
432
+ mcp = None
433
+
434
+
435
+ def main(argv: Sequence[str] | None = None) -> None:
436
+ parser = argparse.ArgumentParser(
437
+ prog="keepm",
438
+ description="Run the KeepM local Markdown memory MCP over stdio.",
439
+ )
440
+ parser.add_argument("--version", action="version", version=f"keepm {__version__}")
441
+ parser.parse_args(argv)
442
+ if mcp is None:
443
+ parser.error(
444
+ "FastMCP is not installed; install KeepM with its declared dependencies"
445
+ )
446
+ mcp.run(transport="stdio")
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()