lorekeeper-mcp 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.
- lorekeeper/__init__.py +0 -0
- lorekeeper/__main__.py +14 -0
- lorekeeper/config.py +78 -0
- lorekeeper/dashboard/__init__.py +40 -0
- lorekeeper/dashboard/__main__.py +3 -0
- lorekeeper/dashboard/app.py +79 -0
- lorekeeper/dashboard/routes/__init__.py +0 -0
- lorekeeper/dashboard/routes/backup.py +57 -0
- lorekeeper/dashboard/routes/config.py +97 -0
- lorekeeper/dashboard/routes/links.py +68 -0
- lorekeeper/dashboard/routes/memories.py +70 -0
- lorekeeper/dashboard/routes/metrics.py +32 -0
- lorekeeper/dashboard/routes/reflections.py +52 -0
- lorekeeper/dashboard/routes/search.py +33 -0
- lorekeeper/dashboard/static/css/styles.css +789 -0
- lorekeeper/dashboard/static/index.html +336 -0
- lorekeeper/dashboard/static/js/api.js +23 -0
- lorekeeper/dashboard/static/js/app.js +123 -0
- lorekeeper/dashboard/static/js/backup.js +150 -0
- lorekeeper/dashboard/static/js/config.js +269 -0
- lorekeeper/dashboard/static/js/detail.js +358 -0
- lorekeeper/dashboard/static/js/links.js +107 -0
- lorekeeper/dashboard/static/js/memories.js +254 -0
- lorekeeper/dashboard/static/js/metrics.js +300 -0
- lorekeeper/dashboard/static/js/query.js +57 -0
- lorekeeper/dashboard/static/js/reflections.js +133 -0
- lorekeeper/dashboard/static/js/runs.js +105 -0
- lorekeeper/dashboard/static/js/sessions.js +227 -0
- lorekeeper/dashboard/static/js/state.js +50 -0
- lorekeeper/dashboard/static/js/tab-registry.js +17 -0
- lorekeeper/dashboard/static/js/tab.js +140 -0
- lorekeeper/dashboard/static/js/utils.js +86 -0
- lorekeeper/logging_setup.py +73 -0
- lorekeeper/models.py +69 -0
- lorekeeper/serializers.py +186 -0
- lorekeeper/server.py +415 -0
- lorekeeper/services/__init__.py +0 -0
- lorekeeper/services/chromadb_engine.py +205 -0
- lorekeeper/services/config_store.py +49 -0
- lorekeeper/services/database.py +414 -0
- lorekeeper/services/dedup.py +9 -0
- lorekeeper/services/engine_factory.py +18 -0
- lorekeeper/services/feedback.py +29 -0
- lorekeeper/services/keyword_index.py +41 -0
- lorekeeper/services/lancedb_engine.py +152 -0
- lorekeeper/services/link_candidate.py +290 -0
- lorekeeper/services/link_store.py +152 -0
- lorekeeper/services/memory_engine.py +55 -0
- lorekeeper/services/memory_store.py +177 -0
- lorekeeper/services/metrics_store.py +84 -0
- lorekeeper/services/orchestrator.py +987 -0
- lorekeeper/services/reflection_store.py +140 -0
- lorekeeper/services/search.py +107 -0
- lorekeeper_mcp-0.2.0.dist-info/METADATA +516 -0
- lorekeeper_mcp-0.2.0.dist-info/RECORD +57 -0
- lorekeeper_mcp-0.2.0.dist-info/WHEEL +4 -0
- lorekeeper_mcp-0.2.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,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
|
+
]
|