minder-cli 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.
Files changed (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Awaitable, Callable
5
+
6
+ from minder.application.admin.use_cases import AdminConsoleUseCases
7
+ from minder.auth.principal import ClientPrincipal, Principal
8
+ from minder.auth.middleware import AuthMiddleware
9
+ from minder.auth.service import AuthService
10
+ from minder.config import MinderConfig
11
+ from minder.store.interfaces import ICacheProvider, IGraphRepository, IOperationalStore
12
+ from starlette.requests import Request
13
+
14
+ ADMIN_COOKIE_NAME = "minder_admin_token"
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class AdminRouteContext:
19
+ config: MinderConfig
20
+ store: IOperationalStore
21
+ graph_store: IGraphRepository | None
22
+ cache: ICacheProvider | None
23
+ auth_service: AuthService
24
+ middleware: AuthMiddleware
25
+ use_cases: AdminConsoleUseCases
26
+ prompt_sync_hook: Callable[[], Awaitable[None]] | None = None
27
+
28
+ @classmethod
29
+ def build(
30
+ cls,
31
+ *,
32
+ config: MinderConfig,
33
+ store: IOperationalStore,
34
+ graph_store: IGraphRepository | None = None,
35
+ cache: ICacheProvider | None = None,
36
+ prompt_sync_hook: Callable[[], Awaitable[None]] | None = None,
37
+ ) -> "AdminRouteContext":
38
+ auth_service = AuthService(store, config, cache=cache)
39
+ middleware = AuthMiddleware(auth_service)
40
+ use_cases = AdminConsoleUseCases(
41
+ store=store,
42
+ auth_service=auth_service,
43
+ config=config,
44
+ graph_store=graph_store,
45
+ )
46
+ return cls(
47
+ config=config,
48
+ store=store,
49
+ graph_store=graph_store,
50
+ cache=cache,
51
+ auth_service=auth_service,
52
+ middleware=middleware,
53
+ use_cases=use_cases,
54
+ prompt_sync_hook=prompt_sync_hook,
55
+ )
56
+
57
+ def request_token(self, request: Request) -> str | None:
58
+ authorization = request.headers.get("Authorization")
59
+ if authorization:
60
+ return authorization
61
+ cookie_token = request.cookies.get(ADMIN_COOKIE_NAME)
62
+ if cookie_token:
63
+ return f"Bearer {cookie_token}"
64
+ return None
65
+
66
+ @staticmethod
67
+ def request_client_key(request: Request) -> str | None:
68
+ client_key = request.headers.get("X-Minder-Client-Key")
69
+ if client_key and client_key.strip():
70
+ return client_key.strip()
71
+ return None
72
+
73
+ async def principal_from_request(
74
+ self,
75
+ request: Request,
76
+ *,
77
+ requested_scopes: list[str] | None = None,
78
+ ) -> Principal:
79
+ return await self.middleware.authenticate_principal(
80
+ self.request_token(request),
81
+ client_key=self.request_client_key(request),
82
+ )
83
+
84
+ async def client_principal_from_request(self, request: Request) -> ClientPrincipal:
85
+ principal = await self.principal_from_request(request)
86
+ if not isinstance(principal, ClientPrincipal):
87
+ raise PermissionError("Client principal required")
88
+ return principal
89
+
90
+ async def admin_user_from_request(self, request: Request) -> Any:
91
+ user = await self.middleware.authenticate(self.request_token(request))
92
+ if user.role != "admin":
93
+ raise PermissionError("Admin role required")
94
+ return user
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from starlette.responses import FileResponse, PlainTextResponse, RedirectResponse
6
+ from starlette.routing import BaseRoute, Route
7
+
8
+ from .context import AdminRouteContext
9
+
10
+
11
+ def _resolve_dashboard_static_root(static_dir: Path) -> Path:
12
+ if (static_dir / "index.html").is_file():
13
+ return static_dir
14
+
15
+ client_dir = static_dir / "client"
16
+ if client_dir.is_dir() and (client_dir / "index.html").is_file():
17
+ return client_dir
18
+
19
+ return static_dir
20
+
21
+
22
+ def build_dashboard_routes(context: AdminRouteContext) -> list[BaseRoute]:
23
+ def _dev_dashboard_url(asset_path: str = "") -> str | None:
24
+ dev_server_url = (context.config.dashboard.dev_server_url or "").strip().rstrip("/")
25
+ if not dev_server_url:
26
+ return None
27
+ if asset_path:
28
+ return f"{dev_server_url}/{asset_path.strip('/')}"
29
+ return dev_server_url
30
+
31
+ async def dashboard_static(request):
32
+ asset_path = str(request.path_params.get("asset_path", "")).strip("/")
33
+ dev_target = _dev_dashboard_url(asset_path)
34
+ if dev_target is not None:
35
+ return RedirectResponse(url=dev_target, status_code=307)
36
+
37
+ static_dir = Path(context.config.dashboard.static_dir).expanduser()
38
+
39
+ async def _has_admin_session() -> bool:
40
+ try:
41
+ await context.admin_user_from_request(request)
42
+ except Exception:
43
+ return False
44
+ return True
45
+
46
+ has_admin_users = await context.use_cases.has_admin_users()
47
+ has_admin_session = await _has_admin_session()
48
+ is_asset_request = "." in Path(asset_path).name or asset_path.startswith("_astro/")
49
+
50
+ if not is_asset_request:
51
+ if not has_admin_users and asset_path not in {"setup"}:
52
+ return RedirectResponse(url=f"{context.config.dashboard.base_path}/setup", status_code=303)
53
+ if has_admin_users and asset_path == "setup":
54
+ target = (
55
+ f"{context.config.dashboard.base_path}/clients"
56
+ if has_admin_session
57
+ else f"{context.config.dashboard.base_path}/login"
58
+ )
59
+ return RedirectResponse(url=target, status_code=303)
60
+ if has_admin_users and not has_admin_session and asset_path not in {"login"}:
61
+ return RedirectResponse(url=f"{context.config.dashboard.base_path}/login", status_code=303)
62
+ if has_admin_users and has_admin_session and asset_path in {"", "login"}:
63
+ return RedirectResponse(url=f"{context.config.dashboard.base_path}/clients", status_code=303)
64
+
65
+ if not static_dir.exists():
66
+ return PlainTextResponse("Dashboard build not found", status_code=404)
67
+
68
+ static_root = _resolve_dashboard_static_root(static_dir)
69
+
70
+ if not asset_path:
71
+ candidates = [static_root / "index.html"]
72
+ else:
73
+ requested = static_root / asset_path
74
+ candidates = [requested, requested / "index.html"]
75
+
76
+ for candidate in candidates:
77
+ if candidate.exists() and candidate.is_file():
78
+ return FileResponse(candidate)
79
+
80
+ if asset_path.startswith("clients/"):
81
+ detail_shell = static_root / "clients" / "_client-detail" / "index.html"
82
+ if detail_shell.exists() and detail_shell.is_file():
83
+ return FileResponse(detail_shell)
84
+
85
+ clients_index = static_root / "clients" / "index.html"
86
+ if clients_index.exists() and clients_index.is_file():
87
+ return FileResponse(clients_index)
88
+
89
+ fallback = static_root / "index.html"
90
+ if fallback.exists() and fallback.is_file():
91
+ return FileResponse(fallback)
92
+ return PlainTextResponse("Dashboard build not found", status_code=404)
93
+
94
+ async def setup_redirect(_):
95
+ dev_target = _dev_dashboard_url("setup")
96
+ if dev_target is not None:
97
+ return RedirectResponse(url=dev_target, status_code=308)
98
+ return RedirectResponse(url=f"{context.config.dashboard.base_path}/setup", status_code=308)
99
+
100
+ async def dashboard_favicon_ico(_):
101
+ dev_target = _dev_dashboard_url("favicon.png")
102
+ if dev_target is not None:
103
+ return RedirectResponse(url=dev_target, status_code=308)
104
+ return RedirectResponse(url=f"{context.config.dashboard.base_path}/favicon.png", status_code=308)
105
+
106
+ return [
107
+ Route("/setup", setup_redirect, methods=["GET"]),
108
+ Route(f"{context.config.dashboard.base_path}/favicon.ico", dashboard_favicon_ico, methods=["GET"]),
109
+ Route(f"{context.config.dashboard.base_path}", dashboard_static, methods=["GET"]),
110
+ Route(f"{context.config.dashboard.base_path}" + "/{asset_path:path}", dashboard_static, methods=["GET"]),
111
+ ]
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+ from starlette.requests import Request
10
+ from starlette.responses import JSONResponse, StreamingResponse
11
+ from starlette.routing import BaseRoute, Route
12
+
13
+ from minder.application.admin.jobs import AdminJobService, iter_job_stream
14
+
15
+ from .context import AdminRouteContext
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AdminJobCreateRequest(BaseModel):
21
+ job_type: str
22
+ payload: dict[str, Any] = Field(default_factory=dict)
23
+
24
+
25
+ def _job_service_from_request(
26
+ request: Request,
27
+ context: AdminRouteContext,
28
+ ) -> AdminJobService:
29
+ service = getattr(request.app.state, "job_service", None)
30
+ if isinstance(service, AdminJobService):
31
+ return service
32
+ service = AdminJobService(context.store, context.config)
33
+ request.app.state.job_service = service
34
+ return service
35
+
36
+
37
+ def _serialize_job(job: Any) -> dict[str, Any]:
38
+ created_at = getattr(job, "created_at", None)
39
+ updated_at = getattr(job, "updated_at", None)
40
+ started_at = getattr(job, "started_at", None)
41
+ finished_at = getattr(job, "finished_at", None)
42
+ progress_current = int(getattr(job, "progress_current", 0) or 0)
43
+ progress_total = int(getattr(job, "progress_total", 0) or 0)
44
+ progress_percent = 0.0
45
+ if progress_total > 0:
46
+ progress_percent = round((progress_current / progress_total) * 100, 1)
47
+ return {
48
+ "id": str(job.id),
49
+ "job_type": str(getattr(job, "job_type", "")),
50
+ "title": str(getattr(job, "title", "")),
51
+ "status": str(getattr(job, "status", "queued")),
52
+ "requested_by_user_id": (
53
+ str(getattr(job, "requested_by_user_id", ""))
54
+ if getattr(job, "requested_by_user_id", None)
55
+ else None
56
+ ),
57
+ "payload": dict(getattr(job, "payload", {}) or {}),
58
+ "result_payload": getattr(job, "result_payload", None),
59
+ "error_message": getattr(job, "error_message", None),
60
+ "progress_current": progress_current,
61
+ "progress_total": progress_total,
62
+ "progress_percent": progress_percent,
63
+ "message": getattr(job, "message", None),
64
+ "events": list(getattr(job, "events", []) or []),
65
+ "created_at": created_at.isoformat() if created_at else None,
66
+ "updated_at": updated_at.isoformat() if updated_at else None,
67
+ "started_at": started_at.isoformat() if started_at else None,
68
+ "finished_at": finished_at.isoformat() if finished_at else None,
69
+ }
70
+
71
+
72
+ def _job_title(job_type: str, payload: dict[str, Any]) -> str:
73
+ if job_type == "skill_import_git":
74
+ repo_url = str(payload.get("repo_url") or "").strip()
75
+ return f"Import skills from {repo_url or 'Git repository'}"
76
+ return job_type.replace("_", " ").strip() or "Admin job"
77
+
78
+
79
+ def build_jobs_routes(context: AdminRouteContext) -> list[BaseRoute]:
80
+ async def create_job(request: Request) -> JSONResponse:
81
+ try:
82
+ admin_user = await context.admin_user_from_request(request)
83
+ except PermissionError:
84
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
85
+ except Exception as exc:
86
+ return JSONResponse({"error": str(exc)}, status_code=401)
87
+
88
+ try:
89
+ payload = AdminJobCreateRequest(**(await request.json()))
90
+ if payload.job_type != "skill_import_git":
91
+ return JSONResponse(
92
+ {"error": f"Unsupported job type: {payload.job_type}"},
93
+ status_code=400,
94
+ )
95
+ service = _job_service_from_request(request, context)
96
+ job = await service.enqueue(
97
+ job_type=payload.job_type,
98
+ title=_job_title(payload.job_type, payload.payload),
99
+ requested_by_user_id=admin_user.id,
100
+ payload=payload.payload,
101
+ )
102
+ return JSONResponse(_serialize_job(job), status_code=202)
103
+ except Exception as exc:
104
+ logger.exception("Failed to create admin job", exc_info=exc)
105
+ return JSONResponse({"error": str(exc)}, status_code=400)
106
+
107
+ async def list_jobs(request: Request) -> JSONResponse:
108
+ try:
109
+ await context.admin_user_from_request(request)
110
+ except PermissionError:
111
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
112
+ except Exception as exc:
113
+ return JSONResponse({"error": str(exc)}, status_code=401)
114
+
115
+ job_type = (request.query_params.get("job_type") or "").strip() or None
116
+ status = (request.query_params.get("status") or "").strip() or None
117
+ limit = int(request.query_params.get("limit") or 50)
118
+ jobs = await context.store.list_admin_jobs(
119
+ job_type=job_type,
120
+ status=status,
121
+ limit=limit,
122
+ )
123
+ return JSONResponse(
124
+ {"jobs": [_serialize_job(job) for job in jobs], "limit": limit}
125
+ )
126
+
127
+ async def get_job(request: Request) -> JSONResponse:
128
+ try:
129
+ await context.admin_user_from_request(request)
130
+ except PermissionError:
131
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
132
+ except Exception as exc:
133
+ return JSONResponse({"error": str(exc)}, status_code=401)
134
+
135
+ job_id = uuid.UUID(str(request.path_params["job_id"]))
136
+ job = await context.store.get_admin_job_by_id(job_id)
137
+ if job is None:
138
+ return JSONResponse({"error": "Job not found"}, status_code=404)
139
+ return JSONResponse(_serialize_job(job))
140
+
141
+ async def stream_jobs(request: Request) -> StreamingResponse | JSONResponse:
142
+ try:
143
+ await context.admin_user_from_request(request)
144
+ except PermissionError:
145
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
146
+ except Exception as exc:
147
+ return JSONResponse({"error": str(exc)}, status_code=401)
148
+
149
+ job_type = (request.query_params.get("job_type") or "").strip() or None
150
+ status_values = {
151
+ value.strip()
152
+ for value in (request.query_params.get("status") or "").split(",")
153
+ if value.strip()
154
+ }
155
+ service = _job_service_from_request(request, context)
156
+ queue, unsubscribe = service.subscribe(
157
+ job_type=job_type,
158
+ statuses=status_values or None,
159
+ )
160
+
161
+ async def event_stream():
162
+ try:
163
+ async for event in iter_job_stream(
164
+ queue=queue,
165
+ request_is_disconnected=request.is_disconnected,
166
+ ):
167
+ yield f"data: {json.dumps(event)}\n\n"
168
+ finally:
169
+ unsubscribe()
170
+
171
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
172
+
173
+ async def stream_job(request: Request) -> StreamingResponse | JSONResponse:
174
+ try:
175
+ await context.admin_user_from_request(request)
176
+ except PermissionError:
177
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
178
+ except Exception as exc:
179
+ return JSONResponse({"error": str(exc)}, status_code=401)
180
+
181
+ job_id = uuid.UUID(str(request.path_params["job_id"]))
182
+ existing = await context.store.get_admin_job_by_id(job_id)
183
+ if existing is None:
184
+ return JSONResponse({"error": "Job not found"}, status_code=404)
185
+
186
+ service = _job_service_from_request(request, context)
187
+ queue, unsubscribe = service.subscribe(job_id=str(job_id))
188
+
189
+ async def event_stream():
190
+ try:
191
+ yield f"data: {json.dumps({'type': 'job', 'payload': _serialize_job(existing)})}\n\n"
192
+ async for event in iter_job_stream(
193
+ queue=queue,
194
+ request_is_disconnected=request.is_disconnected,
195
+ ):
196
+ yield f"data: {json.dumps(event)}\n\n"
197
+ finally:
198
+ unsubscribe()
199
+
200
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
201
+
202
+ return [
203
+ Route("/api/v1/jobs", list_jobs, methods=["GET"]),
204
+ Route("/api/v1/jobs", create_job, methods=["POST"]),
205
+ Route("/api/v1/jobs/stream", stream_jobs, methods=["GET"]),
206
+ Route("/api/v1/jobs/{job_id}", get_job, methods=["GET"]),
207
+ Route("/api/v1/jobs/{job_id}/stream", stream_job, methods=["GET"]),
208
+ ]
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+ from starlette.requests import Request
9
+ from starlette.responses import JSONResponse
10
+ from starlette.routing import BaseRoute, Route
11
+
12
+ from minder.config import MinderConfig
13
+ from minder.embedding.local import LocalEmbeddingProvider
14
+ from minder.observability.metrics import record_admin_operation
15
+ from minder.tools.memory import MemoryTools
16
+
17
+ from .context import AdminRouteContext
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class MemoryCreateRequest(BaseModel):
23
+ title: str
24
+ content: str
25
+ language: str = "markdown"
26
+ tags: list[str] = Field(default_factory=list)
27
+
28
+
29
+ class MemoryUpdateRequest(BaseModel):
30
+ title: str | None = None
31
+ content: str | None = None
32
+ language: str | None = None
33
+ tags: list[str] | None = None
34
+
35
+
36
+ def _config_from_request(request: Request) -> MinderConfig:
37
+ config = getattr(request.app.state, "config", None)
38
+ if isinstance(config, MinderConfig):
39
+ return config
40
+ return MinderConfig()
41
+
42
+
43
+ def _serialize_memory(skill: Any) -> dict[str, Any]:
44
+ return {
45
+ "id": str(skill.id),
46
+ "title": str(skill.title),
47
+ "content": str(skill.content),
48
+ "language": str(getattr(skill, "language", "markdown") or "markdown"),
49
+ "tags": list(getattr(skill, "tags", []) or []),
50
+ "created_at": skill.created_at.isoformat() if skill.created_at else None,
51
+ "updated_at": skill.updated_at.isoformat() if skill.updated_at else None,
52
+ }
53
+
54
+
55
+ def build_memories_routes(context: AdminRouteContext) -> list[BaseRoute]:
56
+ async def list_memories(request: Request) -> JSONResponse:
57
+ del request
58
+ await record_admin_operation(
59
+ operation="list_memories",
60
+ outcome="success",
61
+ actor_id="system",
62
+ store=context.store,
63
+ )
64
+ try:
65
+ skills = sorted(
66
+ await context.store.list_skills(),
67
+ key=lambda skill: (
68
+ str(getattr(skill, "title", "")).lower(),
69
+ str(getattr(skill, "language", "")).lower(),
70
+ ),
71
+ )
72
+ return JSONResponse([_serialize_memory(skill) for skill in skills])
73
+ except Exception as exc:
74
+ logger.exception("Failed to list memories", exc_info=exc)
75
+ return JSONResponse({"error": str(exc)}, status_code=500)
76
+
77
+ async def get_memory(request: Request) -> JSONResponse:
78
+ memory_id = request.path_params["memory_id"]
79
+ await record_admin_operation(
80
+ operation="get_memory",
81
+ outcome="success",
82
+ actor_id="system",
83
+ store=context.store,
84
+ )
85
+ try:
86
+ skill = await context.store.get_skill_by_id(uuid.UUID(memory_id))
87
+ if skill is None:
88
+ return JSONResponse({"error": "Memory not found"}, status_code=404)
89
+ return JSONResponse(_serialize_memory(skill))
90
+ except Exception as exc:
91
+ logger.exception("Failed to get memory", exc_info=exc)
92
+ return JSONResponse({"error": str(exc)}, status_code=500)
93
+
94
+ async def create_memory(request: Request) -> JSONResponse:
95
+ await record_admin_operation(
96
+ operation="create_memory",
97
+ outcome="success",
98
+ actor_id="system",
99
+ store=context.store,
100
+ )
101
+ try:
102
+ payload = MemoryCreateRequest(**(await request.json()))
103
+ tools = MemoryTools(context.store, _config_from_request(request))
104
+ created = await tools.minder_memory_store(
105
+ title=payload.title,
106
+ content=payload.content,
107
+ tags=payload.tags,
108
+ language=payload.language,
109
+ )
110
+ skill = await context.store.get_skill_by_id(uuid.UUID(created["id"]))
111
+ if skill is None:
112
+ return JSONResponse(created, status_code=201)
113
+ return JSONResponse(_serialize_memory(skill), status_code=201)
114
+ except Exception as exc:
115
+ logger.exception("Failed to create memory", exc_info=exc)
116
+ return JSONResponse({"error": str(exc)}, status_code=400)
117
+
118
+ async def update_memory(request: Request) -> JSONResponse:
119
+ memory_id = request.path_params["memory_id"]
120
+ await record_admin_operation(
121
+ operation="update_memory",
122
+ outcome="success",
123
+ actor_id="system",
124
+ store=context.store,
125
+ )
126
+ try:
127
+ payload = MemoryUpdateRequest(**(await request.json()))
128
+ existing = await context.store.get_skill_by_id(uuid.UUID(memory_id))
129
+ if existing is None:
130
+ return JSONResponse({"error": "Memory not found"}, status_code=404)
131
+
132
+ update_data = payload.model_dump(exclude_unset=True)
133
+ next_title = str(update_data.get("title") or existing.title)
134
+ next_content = str(update_data.get("content") or existing.content)
135
+ if "tags" in update_data and update_data["tags"] is not None:
136
+ update_data["tags"] = [
137
+ str(tag).strip() for tag in update_data["tags"] if str(tag).strip()
138
+ ]
139
+
140
+ if "title" in update_data or "content" in update_data:
141
+ config = _config_from_request(request)
142
+ embedder = LocalEmbeddingProvider(
143
+ config.embedding.model_path,
144
+ dimensions=min(config.embedding.dimensions, 16),
145
+ runtime="auto",
146
+ )
147
+ update_data["embedding"] = embedder.embed(
148
+ f"{next_title}\n{next_content}"
149
+ )
150
+
151
+ updated = await context.store.update_skill(
152
+ uuid.UUID(memory_id), **update_data
153
+ )
154
+ if updated is None:
155
+ return JSONResponse({"error": "Memory not found"}, status_code=404)
156
+ return JSONResponse(_serialize_memory(updated))
157
+ except Exception as exc:
158
+ logger.exception("Failed to update memory", exc_info=exc)
159
+ return JSONResponse({"error": str(exc)}, status_code=400)
160
+
161
+ async def delete_memory(request: Request) -> JSONResponse:
162
+ memory_id = request.path_params["memory_id"]
163
+ await record_admin_operation(
164
+ operation="delete_memory",
165
+ outcome="success",
166
+ actor_id="system",
167
+ store=context.store,
168
+ )
169
+ try:
170
+ skill = await context.store.get_skill_by_id(uuid.UUID(memory_id))
171
+ if skill is None:
172
+ return JSONResponse({"error": "Memory not found"}, status_code=404)
173
+ await context.store.delete_skill(uuid.UUID(memory_id))
174
+ return JSONResponse({"deleted": True}, status_code=200)
175
+ except Exception as exc:
176
+ logger.exception("Failed to delete memory", exc_info=exc)
177
+ return JSONResponse({"error": str(exc)}, status_code=500)
178
+
179
+ return [
180
+ Route("/api/v1/memories", list_memories, methods=["GET"]),
181
+ Route("/api/v1/memories", create_memory, methods=["POST"]),
182
+ Route("/api/v1/memories/{memory_id}", get_memory, methods=["GET"]),
183
+ Route("/api/v1/memories/{memory_id}", update_memory, methods=["PATCH", "PUT"]),
184
+ Route("/api/v1/memories/{memory_id}", delete_memory, methods=["DELETE"]),
185
+ ]