lorekeeper-mcp 2.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.
Files changed (57) hide show
  1. lorekeeper/__init__.py +0 -0
  2. lorekeeper/__main__.py +14 -0
  3. lorekeeper/config.py +78 -0
  4. lorekeeper/dashboard/__init__.py +40 -0
  5. lorekeeper/dashboard/__main__.py +3 -0
  6. lorekeeper/dashboard/app.py +79 -0
  7. lorekeeper/dashboard/routes/__init__.py +0 -0
  8. lorekeeper/dashboard/routes/backup.py +57 -0
  9. lorekeeper/dashboard/routes/config.py +97 -0
  10. lorekeeper/dashboard/routes/links.py +68 -0
  11. lorekeeper/dashboard/routes/memories.py +70 -0
  12. lorekeeper/dashboard/routes/metrics.py +32 -0
  13. lorekeeper/dashboard/routes/reflections.py +52 -0
  14. lorekeeper/dashboard/routes/search.py +33 -0
  15. lorekeeper/dashboard/static/css/styles.css +789 -0
  16. lorekeeper/dashboard/static/index.html +336 -0
  17. lorekeeper/dashboard/static/js/api.js +23 -0
  18. lorekeeper/dashboard/static/js/app.js +123 -0
  19. lorekeeper/dashboard/static/js/backup.js +150 -0
  20. lorekeeper/dashboard/static/js/config.js +269 -0
  21. lorekeeper/dashboard/static/js/detail.js +358 -0
  22. lorekeeper/dashboard/static/js/links.js +107 -0
  23. lorekeeper/dashboard/static/js/memories.js +254 -0
  24. lorekeeper/dashboard/static/js/metrics.js +300 -0
  25. lorekeeper/dashboard/static/js/query.js +57 -0
  26. lorekeeper/dashboard/static/js/reflections.js +133 -0
  27. lorekeeper/dashboard/static/js/runs.js +105 -0
  28. lorekeeper/dashboard/static/js/sessions.js +227 -0
  29. lorekeeper/dashboard/static/js/state.js +50 -0
  30. lorekeeper/dashboard/static/js/tab-registry.js +17 -0
  31. lorekeeper/dashboard/static/js/tab.js +140 -0
  32. lorekeeper/dashboard/static/js/utils.js +86 -0
  33. lorekeeper/logging_setup.py +73 -0
  34. lorekeeper/models.py +69 -0
  35. lorekeeper/serializers.py +186 -0
  36. lorekeeper/server.py +415 -0
  37. lorekeeper/services/__init__.py +0 -0
  38. lorekeeper/services/chromadb_engine.py +205 -0
  39. lorekeeper/services/config_store.py +49 -0
  40. lorekeeper/services/database.py +414 -0
  41. lorekeeper/services/dedup.py +9 -0
  42. lorekeeper/services/engine_factory.py +18 -0
  43. lorekeeper/services/feedback.py +29 -0
  44. lorekeeper/services/keyword_index.py +41 -0
  45. lorekeeper/services/lancedb_engine.py +152 -0
  46. lorekeeper/services/link_candidate.py +290 -0
  47. lorekeeper/services/link_store.py +152 -0
  48. lorekeeper/services/memory_engine.py +55 -0
  49. lorekeeper/services/memory_store.py +177 -0
  50. lorekeeper/services/metrics_store.py +84 -0
  51. lorekeeper/services/orchestrator.py +987 -0
  52. lorekeeper/services/reflection_store.py +140 -0
  53. lorekeeper/services/search.py +107 -0
  54. lorekeeper_mcp-2.1.0.dist-info/METADATA +716 -0
  55. lorekeeper_mcp-2.1.0.dist-info/RECORD +57 -0
  56. lorekeeper_mcp-2.1.0.dist-info/WHEEL +4 -0
  57. lorekeeper_mcp-2.1.0.dist-info/entry_points.txt +3 -0
lorekeeper/__init__.py ADDED
File without changes
lorekeeper/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ from lorekeeper.config import Settings
2
+ from lorekeeper.logging_setup import configure_logging
3
+ from lorekeeper.server import init_service, mcp
4
+
5
+
6
+ def main() -> None:
7
+ settings = Settings()
8
+ configure_logging(log_dir=settings.log_dir)
9
+ init_service(settings)
10
+ mcp.run(transport="stdio")
11
+
12
+
13
+ if __name__ == "__main__":
14
+ main()
lorekeeper/config.py ADDED
@@ -0,0 +1,78 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_prefix="LORE_", case_sensitive=False)
9
+
10
+ data_dir: Path = Field(default=Path.home() / ".lorekeeper")
11
+ log_dir: Path = Field(default=Path.home() / ".lorekeeper" / "logs")
12
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
13
+ vector_store: str = "lancedb" # "chroma" or "lancedb"
14
+ duplicate_threshold: float = 0.85
15
+
16
+ # Hybrid search weights (must sum to 1.0)
17
+ w_semantic: float = 0.45
18
+ w_keyword: float = 0.30
19
+ w_memory: float = 0.15
20
+ w_usage: float = 0.10
21
+
22
+ # Score delta
23
+ score_bump_up: float = 0.1
24
+ score_bump_down: float = 0.05
25
+ score_min: float = 0.0
26
+ score_max: float = 10.0
27
+ soft_delete_confidence_threshold: int = 2
28
+
29
+ # EMA
30
+ confidence_window_size: int = 20
31
+
32
+ search_limit: int = 5
33
+ max_links_per_memory: int = 5
34
+ max_search_ids: int = 50 # max IDs for lore_search(ids=[...]) — LORE_MAX_SEARCH_IDS
35
+ max_refine_from_ids: int = 200 # max IDs for refine_from — LORE_MAX_REFINE_FROM_IDS
36
+ usage_normalisation_cap: int = 100
37
+ decay_lambda: float = 0.0077 # time-decay λ; 0 disables decay (LORE_DECAY_LAMBDA)
38
+ new_memory_default_score: float = 5.0 # default score for new memories
39
+
40
+ namespace: str = Field(default="shared") # LORE_NAMESPACE — agent write namespace + read scope
41
+
42
+ # Auto-link
43
+ auto_link_enabled: bool = True
44
+ auto_link_k: int = 5
45
+ auto_link_threshold: float = 0.85
46
+
47
+ # Link candidate pipeline (LKPR-58)
48
+ link_top_k: int = Field(default=50, description="Cosine pre-filter: top-K before scoring")
49
+ link_top_m: int = Field(default=10, description="Max candidates returned by lore_recommend_links") # noqa: E501
50
+ link_score_threshold: float = Field(default=0.3, description="Min Stage 1 weighted score to pass") # noqa: E501
51
+
52
+ # Stage 1 scorer weights
53
+ link_weight_cosine: float = Field(default=0.5)
54
+ link_weight_bm25: float = Field(default=0.3)
55
+ link_weight_entity: float = Field(default=0.1)
56
+ link_weight_temporal: float = Field(default=0.1)
57
+
58
+ # Temporal scorer
59
+ link_temporal_tau_days: float = Field(
60
+ default=30.0, description="Decay half-life in days for temporal scorer"
61
+ )
62
+
63
+ # spaCy entity overlap scorer
64
+ link_spacy_model: str = Field(
65
+ default="en_core_web_sm", description="spaCy model for entity overlap"
66
+ )
67
+
68
+ @property
69
+ def chroma_path(self) -> Path:
70
+ return self.data_dir / "chroma"
71
+
72
+ @property
73
+ def lancedb_path(self) -> str:
74
+ return str(self.data_dir / "lancedb")
75
+
76
+ @property
77
+ def sqlite_path(self) -> Path:
78
+ return self.data_dir / "lorekeeper.db"
@@ -0,0 +1,40 @@
1
+ import argparse
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import uvicorn
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(description="Lorekeeper Dashboard")
11
+ parser.add_argument(
12
+ "--data-dir",
13
+ type=str,
14
+ default=None,
15
+ help="Memory data directory (default: $LORE_DATA_DIR or ~/.lorekeeper)",
16
+ )
17
+ parser.add_argument(
18
+ "--port",
19
+ type=int,
20
+ default=int(os.environ.get("LORE_DASH_PORT", "7777")),
21
+ help="HTTP port (default: 7777, or $LORE_DASH_PORT)",
22
+ )
23
+ args = parser.parse_args()
24
+
25
+ port = args.port
26
+ reload = os.environ.get("LORE_DASH_RELOAD", "1").lower() not in ("0", "false", "no")
27
+
28
+ # --data-dir sets LORE_DATA_DIR so Settings() picks it up in both
29
+ # reload and no-reload modes (uvicorn reload spawns a child process).
30
+ if args.data_dir:
31
+ os.environ["LORE_DATA_DIR"] = str(Path(args.data_dir).resolve())
32
+
33
+ app_ref = "lorekeeper.dashboard.app:app" if reload else _import_app()
34
+ uvicorn.run(app_ref, host="127.0.0.1", port=port, reload=reload)
35
+
36
+
37
+ def _import_app() -> Any:
38
+ from lorekeeper.dashboard.app import app
39
+
40
+ return app
@@ -0,0 +1,3 @@
1
+ from lorekeeper.dashboard import main
2
+
3
+ main()
@@ -0,0 +1,79 @@
1
+ import subprocess
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ import structlog
7
+ from fastapi import FastAPI
8
+ from fastapi.responses import Response
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from lorekeeper.dashboard.routes import (
12
+ backup,
13
+ config,
14
+ links,
15
+ memories,
16
+ metrics,
17
+ reflections,
18
+ search,
19
+ )
20
+ from lorekeeper.server import init_service
21
+
22
+ log = structlog.get_logger()
23
+ STATIC_DIR = Path(__file__).parent / "static"
24
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
25
+
26
+ # Computed once at startup — not on every request
27
+ _APP_VERSION: str = "unknown"
28
+
29
+
30
+ def _resolve_version() -> str:
31
+ try:
32
+ result = subprocess.run(
33
+ ["git", "describe", "--always", "--dirty", "--tags"],
34
+ capture_output=True, text=True, cwd=REPO_ROOT, timeout=5,
35
+ )
36
+ return result.stdout.strip() or "unknown"
37
+ except Exception:
38
+ log.exception("version_resolve_failed")
39
+ return "unknown"
40
+
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
44
+ global _APP_VERSION
45
+ log.info("dashboard_startup")
46
+ _APP_VERSION = _resolve_version()
47
+ log.info("version_resolved", version=_APP_VERSION)
48
+ init_service()
49
+ log.info("dashboard_ready")
50
+ yield
51
+
52
+
53
+ app = FastAPI(lifespan=lifespan, title="Lorekeeper Dashboard")
54
+
55
+ # Serve css/, js/ subdirectories as static assets
56
+ app.mount("/css", StaticFiles(directory=STATIC_DIR / "css"), name="css")
57
+ app.mount("/js", StaticFiles(directory=STATIC_DIR / "js"), name="js")
58
+
59
+
60
+ # ── Serve UI ──────────────────────────────────────────────────────────────────
61
+
62
+ @app.get("/", include_in_schema=False)
63
+ def index() -> Response:
64
+ html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
65
+ return Response(
66
+ content=html.replace("{%VERSION%}", _APP_VERSION),
67
+ media_type="text/html",
68
+ )
69
+
70
+
71
+ # ── Route modules ─────────────────────────────────────────────────────────────
72
+
73
+ app.include_router(memories.router)
74
+ app.include_router(links.router)
75
+ app.include_router(search.router)
76
+ app.include_router(config.router)
77
+ app.include_router(reflections.router)
78
+ app.include_router(backup.router)
79
+ app.include_router(metrics.router)
File without changes
@@ -0,0 +1,57 @@
1
+ import json
2
+ from datetime import UTC, datetime
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, File, HTTPException, UploadFile
6
+ from fastapi.responses import Response
7
+
8
+ from lorekeeper.models import Memory
9
+ from lorekeeper.serializers import serialize_memory, serialize_memory_link
10
+ from lorekeeper.server import get_service
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ def _parse_dump(raw: bytes) -> tuple[list[Any], list[Any]]:
16
+ try:
17
+ data = json.loads(raw)
18
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
19
+ raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") from e
20
+ if not isinstance(data.get("memories"), list) or not isinstance(data.get("links"), list):
21
+ raise HTTPException(status_code=422, detail='File must have "memories" and "links" arrays')
22
+ return data["memories"], data["links"]
23
+
24
+
25
+ @router.get("/api/export")
26
+ def export_dump(include_deleted: bool = False) -> Response:
27
+ svc = get_service()
28
+ now = datetime.now(UTC)
29
+ memories = [
30
+ serialize_memory(Memory(**dict(r)))
31
+ for r in svc.memories.all_memory_rows(include_deleted=include_deleted)
32
+ ]
33
+ links = [serialize_memory_link(lnk) for lnk in svc.links.all_links()]
34
+ payload = {
35
+ "version": "2",
36
+ "exported_at": now.isoformat(),
37
+ "memories": memories,
38
+ "links": links,
39
+ }
40
+ filename = f"lorekeeper-{now.strftime('%Y-%m-%d')}.json"
41
+ return Response(
42
+ content=json.dumps(payload, indent=2),
43
+ media_type="application/json",
44
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
45
+ )
46
+
47
+
48
+ @router.post("/api/import/preview")
49
+ async def import_preview(file: UploadFile = File(...)) -> dict[str, Any]:
50
+ memories, links = _parse_dump(await file.read())
51
+ return get_service().import_dump(memories, links, dry_run=True)
52
+
53
+
54
+ @router.post("/api/import/confirm")
55
+ async def import_confirm(file: UploadFile = File(...)) -> dict[str, Any]:
56
+ memories, links = _parse_dump(await file.read())
57
+ return get_service().import_dump(memories, links, dry_run=False)
@@ -0,0 +1,97 @@
1
+ import types
2
+ from typing import Any, Union, get_args, get_origin
3
+
4
+ from fastapi import APIRouter, HTTPException
5
+ from pydantic import BaseModel
6
+
7
+ from lorekeeper.server import get_service
8
+
9
+ router = APIRouter()
10
+
11
+ _READONLY_KEYS = {"data_dir", "embedding_model"}
12
+
13
+
14
+ def _unwrap_optional(tp: Any) -> Any:
15
+ """Unwrap Optional[T] / Union[T, None] | T | None to T."""
16
+ origin = get_origin(tp)
17
+ if origin is Union or origin is types.UnionType:
18
+ args = get_args(tp)
19
+ non_none = [a for a in args if a is not type(None)]
20
+ return non_none[0] if len(non_none) == 1 else tp
21
+ return tp
22
+
23
+
24
+ @router.get("/api/config")
25
+ def get_config() -> dict[str, Any]:
26
+ s = get_service().settings
27
+ overridden_keys = set(get_service().config.get_overrides().keys())
28
+ return {
29
+ "data_dir": str(s.data_dir),
30
+ "embedding_model": s.embedding_model,
31
+ "duplicate_threshold": s.duplicate_threshold,
32
+ "w_semantic": s.w_semantic,
33
+ "w_keyword": s.w_keyword,
34
+ "w_memory": s.w_memory,
35
+ "w_usage": s.w_usage,
36
+ "score_bump_up": s.score_bump_up,
37
+ "score_bump_down": s.score_bump_down,
38
+ "score_min": s.score_min,
39
+ "score_max": s.score_max,
40
+ "soft_delete_confidence_threshold": s.soft_delete_confidence_threshold,
41
+ "confidence_window_size": s.confidence_window_size,
42
+ "search_limit": s.search_limit,
43
+ "max_links_per_memory": s.max_links_per_memory,
44
+ "usage_normalisation_cap": s.usage_normalisation_cap,
45
+ "decay_lambda": s.decay_lambda,
46
+ "new_memory_default_score": s.new_memory_default_score,
47
+ "auto_link_enabled": s.auto_link_enabled,
48
+ "auto_link_k": s.auto_link_k,
49
+ "auto_link_threshold": s.auto_link_threshold,
50
+ "_overridden_keys": sorted(overridden_keys),
51
+ }
52
+
53
+
54
+ class ConfigUpdate(BaseModel):
55
+ duplicate_threshold: float | None = None
56
+ w_semantic: float | None = None
57
+ w_keyword: float | None = None
58
+ w_memory: float | None = None
59
+ w_usage: float | None = None
60
+ score_bump_up: float | None = None
61
+ score_bump_down: float | None = None
62
+ score_min: float | None = None
63
+ score_max: float | None = None
64
+ soft_delete_confidence_threshold: int | None = None
65
+ confidence_window_size: int | None = None
66
+ search_limit: int | None = None
67
+ max_links_per_memory: int | None = None
68
+ usage_normalisation_cap: int | None = None
69
+ decay_lambda: float | None = None
70
+ new_memory_default_score: float | None = None
71
+ auto_link_enabled: bool | None = None
72
+ auto_link_k: int | None = None
73
+ auto_link_threshold: float | None = None
74
+
75
+
76
+ @router.patch("/api/config")
77
+ def update_config(body: ConfigUpdate) -> dict[str, bool]:
78
+ """Update config overrides with type validation.
79
+ Read-only keys (data_dir, embedding_model) are silently skipped.
80
+ Returns 422 with detail on type mismatch.
81
+ """
82
+ _TYPE_MAP = {k: _unwrap_optional(v.annotation) for k, v in ConfigUpdate.model_fields.items()}
83
+ svc = get_service()
84
+ s = svc.settings
85
+ for key, value in body.model_dump(exclude_none=True).items():
86
+ if key in _READONLY_KEYS:
87
+ continue
88
+ expected = _TYPE_MAP.get(key)
89
+ if expected is not None and not isinstance(value, expected):
90
+ raise HTTPException(
91
+ status_code=422,
92
+ detail=f"Config '{key}' expects {expected.__name__}, got {type(value).__name__}",
93
+ )
94
+ setattr(s, key, value)
95
+ svc.config.set_override(key, value)
96
+ svc.commit()
97
+ return {"ok": True}
@@ -0,0 +1,68 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+ from lorekeeper.models import RelationType
7
+ from lorekeeper.serializers import serialize_memory_link
8
+ from lorekeeper.server import get_service
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class LinkCreate(BaseModel):
14
+ source_memory_id: str
15
+ target_memory_id: str
16
+ relation_type: RelationType
17
+ reason: str
18
+ score: float = 1.0
19
+
20
+
21
+ @router.get("/api/links")
22
+ def list_all_links(include_deleted: bool = False) -> list[dict[str, Any]]:
23
+ svc = get_service()
24
+ links = svc.links.all_links()
25
+ all_rows = svc.memories.all_memory_rows(include_deleted=True)
26
+ title_map = {r["id"]: r["title"] for r in all_rows}
27
+ deleted_ids = {r["id"] for r in all_rows if r["soft_deleted"]}
28
+ if not include_deleted:
29
+ links = [
30
+ lnk for lnk in links
31
+ if lnk.source_memory_id not in deleted_ids and lnk.target_memory_id not in deleted_ids
32
+ ]
33
+ return [
34
+ {
35
+ **serialize_memory_link(lnk),
36
+ "source_title": title_map.get(lnk.source_memory_id, lnk.source_memory_id[:12] + "…"),
37
+ "target_title": title_map.get(lnk.target_memory_id, lnk.target_memory_id[:12] + "…"),
38
+ }
39
+ for lnk in links
40
+ ]
41
+
42
+
43
+ @router.post("/api/links", status_code=201)
44
+ def create_link(body: LinkCreate) -> dict[str, Any]:
45
+ svc = get_service()
46
+ if svc.memories.get_memory_row(body.source_memory_id) is None:
47
+ raise HTTPException(status_code=404, detail="Source memory not found")
48
+ if svc.memories.get_memory_row(body.target_memory_id) is None:
49
+ raise HTTPException(status_code=404, detail="Target memory not found")
50
+ link = svc.links.insert_link(
51
+ source_memory_id=body.source_memory_id,
52
+ target_memory_id=body.target_memory_id,
53
+ relation_type=body.relation_type,
54
+ reason=body.reason,
55
+ score=body.score,
56
+ )
57
+ svc.commit()
58
+ return serialize_memory_link(link)
59
+
60
+
61
+ @router.delete("/api/links/{link_id}")
62
+ def delete_link(link_id: str) -> dict[str, bool]:
63
+ svc = get_service()
64
+ if svc.links.get_link(link_id) is None:
65
+ raise HTTPException(status_code=404, detail="Link not found")
66
+ svc.links.delete_link(link_id)
67
+ svc.commit()
68
+ return {"ok": True}
@@ -0,0 +1,70 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+ from lorekeeper.models import Memory
7
+ from lorekeeper.serializers import serialize_memory, serialize_memory_link
8
+ from lorekeeper.server import get_service
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/api/memories")
14
+ def list_memories(include_deleted: bool = False) -> list[dict[str, Any]]:
15
+ svc = get_service()
16
+ rows = svc.memories.all_memory_rows(include_deleted=include_deleted)
17
+ link_counts: dict[str, int] = {}
18
+ for lnk in svc.links.all_links():
19
+ link_counts[lnk.source_memory_id] = link_counts.get(lnk.source_memory_id, 0) + 1
20
+ link_counts[lnk.target_memory_id] = link_counts.get(lnk.target_memory_id, 0) + 1
21
+ result = []
22
+ for r in rows:
23
+ mem = serialize_memory(Memory(**r))
24
+ mem["link_count"] = link_counts.get(r["id"], 0)
25
+ result.append(mem)
26
+ return result
27
+
28
+
29
+ @router.get("/api/memories/{memory_id}")
30
+ def get_memory(memory_id: str) -> dict[str, Any]:
31
+ svc = get_service()
32
+ row = svc.memories.get_memory_row(memory_id)
33
+ if row is None:
34
+ raise HTTPException(status_code=404, detail="Memory not found")
35
+ links = svc.links.links_for_memory(memory_id)
36
+ return {
37
+ "memory": serialize_memory(Memory(**dict(row))),
38
+ "links": [serialize_memory_link(lnk) for lnk in links],
39
+ }
40
+
41
+
42
+ class MemoryUpdate(BaseModel):
43
+ title: str | None = None
44
+ description: str | None = None
45
+ content: str | None = None
46
+ score: float | None = None
47
+ soft_deleted: bool | None = None
48
+
49
+
50
+ @router.patch("/api/memories/{memory_id}")
51
+ def update_memory(memory_id: str, body: MemoryUpdate) -> dict[str, bool]:
52
+ svc = get_service()
53
+ if svc.memories.get_memory_row(memory_id) is None:
54
+ raise HTTPException(status_code=404, detail="Memory not found")
55
+ fields = body.model_dump(exclude_none=True)
56
+ if "soft_deleted" in fields:
57
+ fields["soft_deleted"] = int(fields["soft_deleted"])
58
+ svc.memories.update_memory_fields(memory_id, **fields)
59
+ svc.commit()
60
+ return {"ok": True}
61
+
62
+
63
+ @router.delete("/api/memories/{memory_id}")
64
+ def delete_memory(memory_id: str) -> dict[str, bool]:
65
+ svc = get_service()
66
+ if svc.memories.get_memory_row(memory_id) is None:
67
+ raise HTTPException(status_code=404, detail="Memory not found")
68
+ svc.memories.delete_memory_row(memory_id)
69
+ svc.commit()
70
+ return {"ok": True}
@@ -0,0 +1,32 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from lorekeeper.server import get_service
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/api/metrics")
11
+ def get_metrics(hours: int = 24) -> dict[str, Any]:
12
+ """Return per-minute API call counts bucketed by tool, for the last `hours` hours."""
13
+ store = get_service().metrics
14
+ rows = store.get_metrics(hours=hours)
15
+ buckets: list[str] = []
16
+ tools: set[str] = set()
17
+ data: dict[str, dict[str, int]] = {}
18
+ for row in rows:
19
+ bucket = row["minute_bucket"]
20
+ tool = row["tool_name"]
21
+ count = row["count"]
22
+ tools.add(tool)
23
+ if bucket not in data:
24
+ data[bucket] = {}
25
+ buckets.append(bucket)
26
+ data[bucket][tool] = count
27
+ return {
28
+ "hours": hours,
29
+ "buckets": buckets,
30
+ "tools": sorted(tools),
31
+ "data": data,
32
+ }
@@ -0,0 +1,52 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from lorekeeper.serializers import serialize_reflection, serialize_session
6
+ from lorekeeper.server import get_service
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("/api/reflections")
12
+ def list_reflections() -> list[dict[str, Any]]:
13
+ store = get_service().reflections
14
+ return [serialize_reflection(dict(r)) for r in store.all_reflections()]
15
+
16
+
17
+ @router.get("/api/reflections/{reflection_id}")
18
+ def get_reflection_detail(reflection_id: str) -> dict[str, Any]:
19
+ store = get_service().reflections
20
+ row = store.get_reflection(reflection_id)
21
+ if row is None:
22
+ raise HTTPException(status_code=404, detail="Reflection not found")
23
+ sessions = store.sessions_for_reflection(reflection_id)
24
+ return {
25
+ "reflection": serialize_reflection(dict(row)),
26
+ "sessions": [serialize_session(dict(s)) for s in sessions],
27
+ }
28
+
29
+
30
+ @router.get("/api/sessions")
31
+ def list_sessions(with_content: bool = True) -> list[dict[str, Any]]:
32
+ store = get_service().reflections
33
+ rows = store.sessions_with_content() if with_content else store.all_sessions()
34
+ return [serialize_session(dict(s)) for s in rows]
35
+
36
+
37
+ @router.get("/api/sessions/{session_id}")
38
+ def get_session_detail(session_id: str) -> dict[str, Any]:
39
+ store = get_service().reflections
40
+ row = store.get_session(session_id)
41
+ if row is None:
42
+ raise HTTPException(status_code=404, detail="Session not found")
43
+ reflection = None
44
+ if row["reflection_id"]:
45
+ ref_row = store.get_reflection(row["reflection_id"])
46
+ if ref_row:
47
+ reflection = {
48
+ "id": ref_row["id"],
49
+ "created_at": ref_row["created_at"],
50
+ "summary": ref_row["summary"],
51
+ }
52
+ return {"session": serialize_session(dict(row)), "reflection": reflection}
@@ -0,0 +1,33 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter
4
+ from pydantic import BaseModel
5
+
6
+ from lorekeeper.serializers import serialize_search_result
7
+ from lorekeeper.server import get_service
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ class SearchRequest(BaseModel):
13
+ query: str
14
+ limit: int = 5
15
+ min_score: float = 0.1
16
+
17
+
18
+ @router.post("/api/search")
19
+ def search(body: SearchRequest) -> list[dict[str, Any]]:
20
+ results = get_service().search(
21
+ body.query, limit=body.limit, min_score=body.min_score, include_links=False
22
+ )
23
+ return [
24
+ serialize_search_result(
25
+ r,
26
+ truncate_content=300,
27
+ exclude_memory_fields={"created_at", "updated_at", "confidence", "confidence_count"},
28
+ exclude_relevance_fields={"decay_factor"},
29
+ round_relevance=4,
30
+ include_links=False,
31
+ )
32
+ for r in results
33
+ ]