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,993 @@
1
+ """
2
+ MongoDB Operational Store — Motor-based adapter implementing IOperationalStore.
3
+
4
+ This is the MongoDB-backed replacement for the SQLite RelationalStore.
5
+ It implements all domain repository interfaces through a single composite class,
6
+ matching the current RelationalStore API surface for backwards compatibility.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import uuid
13
+ from collections import Counter
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import Any, cast
16
+
17
+ from motor.motor_asyncio import AsyncIOMotorDatabase
18
+
19
+ from minder.store.mongodb.client import MongoClient
20
+ from minder.store.mongodb.indexes import ensure_indexes
21
+
22
+
23
+ def _uuid_to_str(value: uuid.UUID) -> str:
24
+ """Serialize UUID to string for MongoDB storage."""
25
+ return str(value)
26
+
27
+
28
+ def _str_to_uuid(value: str) -> uuid.UUID:
29
+ """Deserialize string to UUID from MongoDB storage."""
30
+ return uuid.UUID(value)
31
+
32
+
33
+ def _now() -> datetime:
34
+ return datetime.now(UTC)
35
+
36
+
37
+ def _normalize_datetime(value: datetime | None) -> datetime | None:
38
+ if value is None:
39
+ return None
40
+ if value.tzinfo is None:
41
+ return value.replace(tzinfo=UTC)
42
+ return value.astimezone(UTC)
43
+
44
+
45
+ class _MongoDoc:
46
+ """
47
+ Lightweight wrapper around a MongoDB document dict to provide
48
+ attribute-style access, matching SQLAlchemy model access patterns.
49
+
50
+ This lets existing application code like `user.email` work without
51
+ changes, whether the object came from SQLAlchemy or MongoDB.
52
+ """
53
+
54
+ def __init__(self, data: dict[str, Any]) -> None:
55
+ self._data = data
56
+ # Convert _id to id if present
57
+ if "_id" in self._data and "id" not in self._data:
58
+ self._data["id"] = self._data.pop("_id")
59
+ # Convert string UUIDs back to uuid.UUID for id fields
60
+ for field in ("id", "user_id", "repo_id", "session_id", "workflow_id"):
61
+ val = self._data.get(field)
62
+ if isinstance(val, str):
63
+ try:
64
+ self._data[field] = uuid.UUID(val)
65
+ except ValueError:
66
+ pass
67
+
68
+ def __getattr__(self, name: str) -> Any:
69
+ try:
70
+ return self._data[name]
71
+ except KeyError:
72
+ raise AttributeError(f"MongoDoc has no attribute '{name}'")
73
+
74
+ def __repr__(self) -> str:
75
+ return f"MongoDoc({self._data!r})"
76
+
77
+
78
+ def _to_doc(data: dict[str, Any]) -> _MongoDoc:
79
+ """Convert a raw MongoDB document to an attribute-accessible object."""
80
+ return _MongoDoc(data)
81
+
82
+
83
+ class MongoOperationalStore:
84
+ """
85
+ MongoDB-backed operational store implementing the full
86
+ IOperationalStore interface (user, skill, session, workflow,
87
+ repository, workflow_state, document, history, error repos).
88
+ """
89
+
90
+ def __init__(self, client: MongoClient) -> None:
91
+ self._client = client
92
+ self._db: AsyncIOMotorDatabase = client.db # type: ignore[type-arg]
93
+
94
+ # ------------------------------------------------------------------
95
+ # Lifecycle
96
+ # ------------------------------------------------------------------
97
+
98
+ async def init_db(self) -> None:
99
+ """Create all indexes (idempotent)."""
100
+ await ensure_indexes(self._db)
101
+
102
+ async def dispose(self) -> None:
103
+ """Close the MongoDB client."""
104
+ await self._client.close()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Prompts
108
+ # ------------------------------------------------------------------
109
+
110
+ async def create_prompt(self, **kwargs: Any) -> _MongoDoc:
111
+ if "id" not in kwargs:
112
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
113
+ else:
114
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
115
+ kwargs.setdefault("company_id", "default")
116
+ kwargs.setdefault("arguments", [])
117
+ kwargs.setdefault("created_at", _now())
118
+ kwargs.setdefault("updated_at", _now())
119
+ kwargs["_id"] = kwargs.pop("id")
120
+ await self._db.prompts.insert_one(kwargs)
121
+ return _to_doc(kwargs)
122
+
123
+ async def get_prompt_by_id(self, prompt_id: uuid.UUID) -> _MongoDoc | None:
124
+ doc = await self._db.prompts.find_one({"_id": _uuid_to_str(prompt_id)})
125
+ return _to_doc(doc) if doc else None
126
+
127
+ async def get_prompt_by_name(self, name: str) -> _MongoDoc | None:
128
+ doc = await self._db.prompts.find_one({"name": name})
129
+ return _to_doc(doc) if doc else None
130
+
131
+ async def list_prompts(self) -> list[_MongoDoc]:
132
+ cursor = self._db.prompts.find().sort("name", 1)
133
+ return [_to_doc(doc) async for doc in cursor]
134
+
135
+ async def update_prompt(
136
+ self, prompt_id: uuid.UUID, **kwargs: Any
137
+ ) -> _MongoDoc | None:
138
+ if not kwargs:
139
+ return await self.get_prompt_by_id(prompt_id)
140
+ kwargs["updated_at"] = _now()
141
+ await self._db.prompts.update_one(
142
+ {"_id": _uuid_to_str(prompt_id)},
143
+ {"$set": kwargs},
144
+ )
145
+ return await self.get_prompt_by_id(prompt_id)
146
+
147
+ async def delete_prompt(self, prompt_id: uuid.UUID) -> None:
148
+ await self._db.prompts.delete_one({"_id": _uuid_to_str(prompt_id)})
149
+
150
+ # ------------------------------------------------------------------
151
+ # User
152
+ # ------------------------------------------------------------------
153
+
154
+ async def create_user(self, **kwargs: Any) -> _MongoDoc:
155
+ if "id" not in kwargs:
156
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
157
+ else:
158
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
159
+ for field in ("company_id",):
160
+ kwargs.setdefault(field, "default")
161
+ kwargs.setdefault("is_active", True)
162
+ kwargs.setdefault("settings", {})
163
+ kwargs.setdefault("created_at", _now())
164
+ kwargs.setdefault("last_login", None)
165
+ kwargs["_id"] = kwargs.pop("id")
166
+ await self._db.users.insert_one(kwargs)
167
+ return _to_doc(kwargs)
168
+
169
+ async def get_user_by_id(self, user_id: uuid.UUID) -> _MongoDoc | None:
170
+ doc = await self._db.users.find_one({"_id": _uuid_to_str(user_id)})
171
+ return _to_doc(doc) if doc else None
172
+
173
+ async def get_user_by_email(self, email: str) -> _MongoDoc | None:
174
+ doc = await self._db.users.find_one({"email": email})
175
+ return _to_doc(doc) if doc else None
176
+
177
+ async def get_user_by_username(self, username: str) -> _MongoDoc | None:
178
+ doc = await self._db.users.find_one({"username": username})
179
+ return _to_doc(doc) if doc else None
180
+
181
+ async def list_users(self, active_only: bool = True) -> list[_MongoDoc]:
182
+ query: dict[str, Any] = {}
183
+ if active_only:
184
+ query["is_active"] = True
185
+ cursor = self._db.users.find(query)
186
+ return [_to_doc(doc) async for doc in cursor]
187
+
188
+ async def update_user(self, user_id: uuid.UUID, **kwargs: Any) -> _MongoDoc | None:
189
+ if not kwargs:
190
+ return await self.get_user_by_id(user_id)
191
+ await self._db.users.update_one(
192
+ {"_id": _uuid_to_str(user_id)}, {"$set": kwargs}
193
+ )
194
+ return await self.get_user_by_id(user_id)
195
+
196
+ async def delete_user(self, user_id: uuid.UUID) -> None:
197
+ await self._db.users.delete_one({"_id": _uuid_to_str(user_id)})
198
+
199
+ async def has_admin_users(self) -> bool:
200
+ doc = await self._db.users.find_one({"role": "admin"})
201
+ return doc is not None
202
+
203
+ # ------------------------------------------------------------------
204
+ # Client Gateway
205
+ # ------------------------------------------------------------------
206
+
207
+ async def create_client(self, **kwargs: Any) -> _MongoDoc:
208
+ if "id" not in kwargs:
209
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
210
+ else:
211
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
212
+ if "created_by_user_id" in kwargs and isinstance(
213
+ kwargs["created_by_user_id"], uuid.UUID
214
+ ):
215
+ kwargs["created_by_user_id"] = _uuid_to_str(kwargs["created_by_user_id"])
216
+ kwargs.setdefault("description", "")
217
+ kwargs.setdefault("status", "active")
218
+ kwargs.setdefault("transport_modes", ["sse", "stdio"])
219
+ kwargs.setdefault("tool_scopes", [])
220
+ kwargs.setdefault("repo_scopes", [])
221
+ kwargs.setdefault("workflow_scopes", [])
222
+ kwargs.setdefault("rate_limit_policy", {})
223
+ kwargs.setdefault("created_at", _now())
224
+ kwargs.setdefault("updated_at", _now())
225
+ kwargs["_id"] = kwargs.pop("id")
226
+ await self._db.clients.insert_one(kwargs)
227
+ return _to_doc(kwargs)
228
+
229
+ async def get_client_by_id(self, client_id: uuid.UUID) -> _MongoDoc | None:
230
+ doc = await self._db.clients.find_one({"_id": _uuid_to_str(client_id)})
231
+ return _to_doc(doc) if doc else None
232
+
233
+ async def get_client_by_slug(self, slug: str) -> _MongoDoc | None:
234
+ doc = await self._db.clients.find_one({"slug": slug})
235
+ return _to_doc(doc) if doc else None
236
+
237
+ async def list_clients(self) -> list[_MongoDoc]:
238
+ cursor = self._db.clients.find()
239
+ return [_to_doc(doc) async for doc in cursor]
240
+
241
+ async def update_client(
242
+ self, client_id: uuid.UUID, **kwargs: Any
243
+ ) -> _MongoDoc | None:
244
+ if not kwargs:
245
+ return await self.get_client_by_id(client_id)
246
+ kwargs["updated_at"] = _now()
247
+ await self._db.clients.update_one(
248
+ {"_id": _uuid_to_str(client_id)},
249
+ {"$set": kwargs},
250
+ )
251
+ return await self.get_client_by_id(client_id)
252
+
253
+ async def create_client_api_key(self, **kwargs: Any) -> _MongoDoc:
254
+ if "id" not in kwargs:
255
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
256
+ else:
257
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
258
+ for uuid_field in ("client_id", "created_by_user_id"):
259
+ if uuid_field in kwargs and isinstance(kwargs[uuid_field], uuid.UUID):
260
+ kwargs[uuid_field] = _uuid_to_str(kwargs[uuid_field])
261
+ kwargs.setdefault("status", "active")
262
+ kwargs.setdefault("last_used_at", None)
263
+ kwargs.setdefault("created_at", _now())
264
+ kwargs.setdefault("expires_at", None)
265
+ kwargs.setdefault("revoked_at", None)
266
+ kwargs["_id"] = kwargs.pop("id")
267
+ await self._db.client_api_keys.insert_one(kwargs)
268
+ return _to_doc(kwargs)
269
+
270
+ async def list_client_api_keys(self, client_id: uuid.UUID) -> list[_MongoDoc]:
271
+ cursor = self._db.client_api_keys.find({"client_id": _uuid_to_str(client_id)})
272
+ return [_to_doc(doc) async for doc in cursor]
273
+
274
+ async def update_client_api_key(
275
+ self, key_id: uuid.UUID, **kwargs: Any
276
+ ) -> _MongoDoc | None:
277
+ if not kwargs:
278
+ doc = await self._db.client_api_keys.find_one({"_id": _uuid_to_str(key_id)})
279
+ return _to_doc(doc) if doc else None
280
+ await self._db.client_api_keys.update_one(
281
+ {"_id": _uuid_to_str(key_id)},
282
+ {"$set": kwargs},
283
+ )
284
+ doc = await self._db.client_api_keys.find_one({"_id": _uuid_to_str(key_id)})
285
+ return _to_doc(doc) if doc else None
286
+
287
+ async def create_client_session(self, **kwargs: Any) -> _MongoDoc:
288
+ if "id" not in kwargs:
289
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
290
+ else:
291
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
292
+ if "client_id" in kwargs and isinstance(kwargs["client_id"], uuid.UUID):
293
+ kwargs["client_id"] = _uuid_to_str(kwargs["client_id"])
294
+ kwargs.setdefault("status", "active")
295
+ kwargs.setdefault("scopes", [])
296
+ kwargs.setdefault("issued_at", _now())
297
+ kwargs.setdefault("last_seen_at", None)
298
+ kwargs.setdefault("session_metadata", {})
299
+ kwargs["_id"] = kwargs.pop("id")
300
+ await self._db.client_sessions.insert_one(kwargs)
301
+ return _to_doc(kwargs)
302
+
303
+ async def get_client_session_by_token_id(self, token_id: str) -> _MongoDoc | None:
304
+ doc = await self._db.client_sessions.find_one({"access_token_id": token_id})
305
+ return _to_doc(doc) if doc else None
306
+
307
+ async def update_client_session(
308
+ self, session_id: uuid.UUID, **kwargs: Any
309
+ ) -> _MongoDoc | None:
310
+ if not kwargs:
311
+ doc = await self._db.client_sessions.find_one(
312
+ {"_id": _uuid_to_str(session_id)}
313
+ )
314
+ return _to_doc(doc) if doc else None
315
+ await self._db.client_sessions.update_one(
316
+ {"_id": _uuid_to_str(session_id)},
317
+ {"$set": kwargs},
318
+ )
319
+ doc = await self._db.client_sessions.find_one({"_id": _uuid_to_str(session_id)})
320
+ return _to_doc(doc) if doc else None
321
+
322
+ async def count_active_client_sessions(self) -> int:
323
+ now = _now()
324
+ query = {"status": "active", "expires_at": {"$gt": now}}
325
+ return int(await self._db.client_sessions.count_documents(query))
326
+
327
+ async def create_audit_log(self, **kwargs: Any) -> _MongoDoc:
328
+ if "id" not in kwargs:
329
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
330
+ else:
331
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
332
+ kwargs.setdefault("audit_metadata", {})
333
+ kwargs.setdefault("created_at", _now())
334
+ kwargs["_id"] = kwargs.pop("id")
335
+ await self._db.audit_logs.insert_one(kwargs)
336
+ return _to_doc(kwargs)
337
+
338
+ async def list_audit_logs(
339
+ self,
340
+ *,
341
+ actor_id: str | None = None,
342
+ event_type: str | None = None,
343
+ outcome: str | None = None,
344
+ limit: int | None = None,
345
+ offset: int = 0,
346
+ ) -> list[_MongoDoc]:
347
+ query: dict[str, Any] = {}
348
+ if actor_id is not None:
349
+ query["actor_id"] = actor_id
350
+ if event_type is not None:
351
+ query["event_type"] = event_type
352
+ if outcome is not None:
353
+ query["outcome"] = outcome
354
+ cursor = self._db.audit_logs.find(query).sort("created_at", -1).skip(offset)
355
+ if limit is not None:
356
+ cursor = cursor.limit(limit)
357
+ return [_to_doc(doc) async for doc in cursor]
358
+
359
+ async def count_audit_logs(
360
+ self,
361
+ *,
362
+ actor_id: str | None = None,
363
+ event_type: str | None = None,
364
+ outcome: str | None = None,
365
+ ) -> int:
366
+ query: dict[str, Any] = {}
367
+ if actor_id is not None:
368
+ query["actor_id"] = actor_id
369
+ if event_type is not None:
370
+ query["event_type"] = event_type
371
+ if outcome is not None:
372
+ query["outcome"] = outcome
373
+ return int(await self._db.audit_logs.count_documents(query))
374
+
375
+ async def get_audit_summary(
376
+ self,
377
+ *,
378
+ actor_id: str | None = None,
379
+ event_type: str | None = None,
380
+ outcome: str | None = None,
381
+ group_by: str = "event_type",
382
+ ) -> dict[str, int]:
383
+ match_query: dict[str, Any] = {}
384
+ if actor_id is not None:
385
+ match_query["actor_id"] = actor_id
386
+ if event_type is not None:
387
+ match_query["event_type"] = event_type
388
+ if outcome is not None:
389
+ match_query["outcome"] = outcome
390
+
391
+ pipeline = []
392
+ if match_query:
393
+ pipeline.append({"$match": match_query})
394
+
395
+ pipeline.append({"$group": {"_id": f"${group_by}", "count": {"$sum": 1}}})
396
+
397
+ cursor = self._db.audit_logs.aggregate(pipeline)
398
+ result: dict[str, int] = {}
399
+ async for doc in cursor:
400
+ label = doc["_id"] or "unknown"
401
+ result[label] = doc["count"]
402
+ return result
403
+
404
+ # ------------------------------------------------------------------
405
+ # Skill
406
+ # ------------------------------------------------------------------
407
+
408
+ async def create_skill(self, **kwargs: Any) -> _MongoDoc:
409
+ if "id" not in kwargs:
410
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
411
+ else:
412
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
413
+ kwargs.setdefault("company_id", "default")
414
+ kwargs.setdefault("usage_count", 0)
415
+ kwargs.setdefault("quality_score", 0.0)
416
+ kwargs.setdefault("tags", [])
417
+ kwargs.setdefault("embedding", None)
418
+ kwargs.setdefault("source_metadata", None)
419
+ kwargs.setdefault("excerpt_kind", "none")
420
+ kwargs.setdefault("created_at", _now())
421
+ kwargs.setdefault("updated_at", _now())
422
+ kwargs["_id"] = kwargs.pop("id")
423
+ await self._db.skills.insert_one(kwargs)
424
+ return _to_doc(kwargs)
425
+
426
+ async def get_skill_by_id(self, skill_id: uuid.UUID) -> _MongoDoc | None:
427
+ doc = await self._db.skills.find_one({"_id": _uuid_to_str(skill_id)})
428
+ return _to_doc(doc) if doc else None
429
+
430
+ async def list_skills(self) -> list[_MongoDoc]:
431
+ cursor = self._db.skills.find()
432
+ return [_to_doc(doc) async for doc in cursor]
433
+
434
+ async def update_skill(
435
+ self, skill_id: uuid.UUID, **kwargs: Any
436
+ ) -> _MongoDoc | None:
437
+ if not kwargs:
438
+ return await self.get_skill_by_id(skill_id)
439
+ kwargs["updated_at"] = _now()
440
+ await self._db.skills.update_one(
441
+ {"_id": _uuid_to_str(skill_id)},
442
+ {"$set": kwargs},
443
+ )
444
+ return await self.get_skill_by_id(skill_id)
445
+
446
+ async def delete_skill(self, skill_id: uuid.UUID) -> None:
447
+ await self._db.skills.delete_one({"_id": _uuid_to_str(skill_id)})
448
+
449
+ # ------------------------------------------------------------------
450
+ # Admin Jobs
451
+ # ------------------------------------------------------------------
452
+
453
+ async def create_admin_job(self, **kwargs: Any) -> _MongoDoc:
454
+ if "id" not in kwargs:
455
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
456
+ else:
457
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
458
+ if "requested_by_user_id" in kwargs and isinstance(
459
+ kwargs["requested_by_user_id"], uuid.UUID
460
+ ):
461
+ kwargs["requested_by_user_id"] = _uuid_to_str(
462
+ kwargs["requested_by_user_id"]
463
+ )
464
+ kwargs.setdefault("company_id", "default")
465
+ kwargs.setdefault("status", "queued")
466
+ kwargs.setdefault("payload", {})
467
+ kwargs.setdefault("result_payload", None)
468
+ kwargs.setdefault("error_message", None)
469
+ kwargs.setdefault("progress_current", 0)
470
+ kwargs.setdefault("progress_total", 0)
471
+ kwargs.setdefault("message", None)
472
+ kwargs.setdefault("events", [])
473
+ kwargs.setdefault("created_at", _now())
474
+ kwargs.setdefault("updated_at", _now())
475
+ kwargs.setdefault("started_at", None)
476
+ kwargs.setdefault("finished_at", None)
477
+ kwargs["_id"] = kwargs.pop("id")
478
+ await self._db.admin_jobs.insert_one(kwargs)
479
+ return _to_doc(kwargs)
480
+
481
+ async def get_admin_job_by_id(self, job_id: uuid.UUID) -> _MongoDoc | None:
482
+ doc = await self._db.admin_jobs.find_one({"_id": _uuid_to_str(job_id)})
483
+ return _to_doc(doc) if doc else None
484
+
485
+ async def list_admin_jobs(
486
+ self,
487
+ *,
488
+ job_type: str | None = None,
489
+ status: str | None = None,
490
+ requested_by_user_id: uuid.UUID | None = None,
491
+ limit: int | None = None,
492
+ offset: int = 0,
493
+ ) -> list[_MongoDoc]:
494
+ query: dict[str, Any] = {}
495
+ if job_type:
496
+ query["job_type"] = job_type
497
+ if status:
498
+ query["status"] = status
499
+ if requested_by_user_id is not None:
500
+ query["requested_by_user_id"] = _uuid_to_str(requested_by_user_id)
501
+ cursor = self._db.admin_jobs.find(query).sort("created_at", -1).skip(offset)
502
+ if limit is not None:
503
+ cursor = cursor.limit(limit)
504
+ return [_to_doc(doc) async for doc in cursor]
505
+
506
+ async def update_admin_job(
507
+ self, job_id: uuid.UUID, **kwargs: Any
508
+ ) -> _MongoDoc | None:
509
+ if not kwargs:
510
+ return await self.get_admin_job_by_id(job_id)
511
+ if "requested_by_user_id" in kwargs and isinstance(
512
+ kwargs["requested_by_user_id"], uuid.UUID
513
+ ):
514
+ kwargs["requested_by_user_id"] = _uuid_to_str(
515
+ kwargs["requested_by_user_id"]
516
+ )
517
+ kwargs["updated_at"] = _now()
518
+ await self._db.admin_jobs.update_one(
519
+ {"_id": _uuid_to_str(job_id)},
520
+ {"$set": kwargs},
521
+ )
522
+ return await self.get_admin_job_by_id(job_id)
523
+
524
+ # ------------------------------------------------------------------
525
+ # Session
526
+ # ------------------------------------------------------------------
527
+
528
+ async def create_session(self, **kwargs: Any) -> _MongoDoc:
529
+ if "id" not in kwargs:
530
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
531
+ else:
532
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
533
+ kwargs.setdefault("company_id", "default")
534
+ for uuid_field in ("user_id", "client_id", "repo_id"):
535
+ if uuid_field in kwargs and isinstance(kwargs[uuid_field], uuid.UUID):
536
+ kwargs[uuid_field] = _uuid_to_str(kwargs[uuid_field])
537
+ kwargs.setdefault("name", None)
538
+ kwargs.setdefault("project_context", {})
539
+ kwargs.setdefault("active_skills", {})
540
+ kwargs.setdefault("state", {})
541
+ kwargs.setdefault("ttl", 86400)
542
+ kwargs.setdefault("created_at", _now())
543
+ kwargs.setdefault("last_active", _now())
544
+ kwargs["_id"] = kwargs.pop("id")
545
+ await self._db.sessions.insert_one(kwargs)
546
+ return _to_doc(kwargs)
547
+
548
+ async def get_session_by_id(self, session_id: uuid.UUID) -> _MongoDoc | None:
549
+ doc = await self._db.sessions.find_one({"_id": _uuid_to_str(session_id)})
550
+ return _to_doc(doc) if doc else None
551
+
552
+ async def get_sessions_by_user(self, user_id: uuid.UUID) -> list[_MongoDoc]:
553
+ cursor = self._db.sessions.find({"user_id": _uuid_to_str(user_id)}).sort(
554
+ "last_active", -1
555
+ )
556
+ return [_to_doc(doc) async for doc in cursor]
557
+
558
+ async def get_sessions_by_client(self, client_id: uuid.UUID) -> list[_MongoDoc]:
559
+ cursor = self._db.sessions.find({"client_id": _uuid_to_str(client_id)}).sort(
560
+ "last_active", -1
561
+ )
562
+ return [_to_doc(doc) async for doc in cursor]
563
+
564
+ async def find_session_by_name(
565
+ self,
566
+ name: str,
567
+ *,
568
+ user_id: uuid.UUID | None = None,
569
+ client_id: uuid.UUID | None = None,
570
+ ) -> _MongoDoc | None:
571
+ query: dict[str, Any] = {"name": name}
572
+ if client_id is not None:
573
+ query["client_id"] = _uuid_to_str(client_id)
574
+ elif user_id is not None:
575
+ query["user_id"] = _uuid_to_str(user_id)
576
+ doc = await self._db.sessions.find_one(query, sort=[("last_active", -1)])
577
+ return _to_doc(doc) if doc else None
578
+
579
+ async def update_session(
580
+ self, session_id: uuid.UUID, **kwargs: Any
581
+ ) -> _MongoDoc | None:
582
+ if not kwargs:
583
+ return await self.get_session_by_id(session_id)
584
+ kwargs["last_active"] = _now()
585
+ await self._db.sessions.update_one(
586
+ {"_id": _uuid_to_str(session_id)}, {"$set": kwargs}
587
+ )
588
+ return await self.get_session_by_id(session_id)
589
+
590
+ async def delete_session(self, session_id: uuid.UUID) -> None:
591
+ await self._db.sessions.delete_one({"_id": _uuid_to_str(session_id)})
592
+
593
+ async def cleanup_expired_sessions(
594
+ self,
595
+ *,
596
+ now: datetime | None = None,
597
+ user_id: uuid.UUID | None = None,
598
+ client_id: uuid.UUID | None = None,
599
+ ) -> dict[str, int]:
600
+ reference_time = _normalize_datetime(now) or _now()
601
+ query: dict[str, Any] = {}
602
+ if user_id is not None:
603
+ query["user_id"] = _uuid_to_str(user_id)
604
+ if client_id is not None:
605
+ query["client_id"] = _uuid_to_str(client_id)
606
+
607
+ cursor = self._db.sessions.find(query)
608
+ expired_session_ids: list[str] = []
609
+ async for session in cursor:
610
+ ttl = int(session.get("ttl", 0) or 0)
611
+ if ttl <= 0:
612
+ continue
613
+ base = _normalize_datetime(
614
+ session.get("last_active") or session.get("created_at")
615
+ )
616
+ if base is not None and base + timedelta(seconds=ttl) <= reference_time:
617
+ expired_session_ids.append(str(session["_id"]))
618
+
619
+ if not expired_session_ids:
620
+ return {"deleted_sessions": 0, "deleted_history": 0}
621
+
622
+ history_result = await self._db.history.delete_many(
623
+ {"session_id": {"$in": expired_session_ids}}
624
+ )
625
+ session_result = await self._db.sessions.delete_many(
626
+ {"_id": {"$in": expired_session_ids}}
627
+ )
628
+ return {
629
+ "deleted_sessions": int(session_result.deleted_count),
630
+ "deleted_history": int(history_result.deleted_count),
631
+ }
632
+
633
+ # ------------------------------------------------------------------
634
+ # Workflow
635
+ # ------------------------------------------------------------------
636
+
637
+ async def create_workflow(self, **kwargs: Any) -> _MongoDoc:
638
+ if "id" not in kwargs:
639
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
640
+ else:
641
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
642
+ kwargs.setdefault("company_id", "default")
643
+ kwargs.setdefault("version", 1)
644
+ kwargs.setdefault("steps", [])
645
+ kwargs.setdefault("policies", {})
646
+ kwargs.setdefault("default_for_repo", False)
647
+ kwargs.setdefault("created_at", _now())
648
+ kwargs.setdefault("updated_at", _now())
649
+ kwargs["_id"] = kwargs.pop("id")
650
+ await self._db.workflows.insert_one(kwargs)
651
+ return _to_doc(kwargs)
652
+
653
+ async def get_workflow_by_id(self, workflow_id: uuid.UUID) -> _MongoDoc | None:
654
+ doc = await self._db.workflows.find_one({"_id": _uuid_to_str(workflow_id)})
655
+ return _to_doc(doc) if doc else None
656
+
657
+ async def get_workflow_by_name(self, name: str) -> _MongoDoc | None:
658
+ doc = await self._db.workflows.find_one({"name": name})
659
+ return _to_doc(doc) if doc else None
660
+
661
+ async def list_workflows(self) -> list[_MongoDoc]:
662
+ cursor = self._db.workflows.find()
663
+ return [_to_doc(doc) async for doc in cursor]
664
+
665
+ async def update_workflow(
666
+ self, workflow_id: uuid.UUID, **kwargs: Any
667
+ ) -> _MongoDoc | None:
668
+ if not kwargs:
669
+ return await self.get_workflow_by_id(workflow_id)
670
+ kwargs["updated_at"] = _now()
671
+ await self._db.workflows.update_one(
672
+ {"_id": _uuid_to_str(workflow_id)}, {"$set": kwargs}
673
+ )
674
+ return await self.get_workflow_by_id(workflow_id)
675
+
676
+ async def delete_workflow(self, workflow_id: uuid.UUID) -> None:
677
+ await self._db.workflows.delete_one({"_id": _uuid_to_str(workflow_id)})
678
+
679
+ # ------------------------------------------------------------------
680
+ # Repository
681
+ # ------------------------------------------------------------------
682
+
683
+ async def create_repository(self, **kwargs: Any) -> _MongoDoc:
684
+ if "id" not in kwargs:
685
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
686
+ else:
687
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
688
+ kwargs.setdefault("company_id", "default")
689
+ for uuid_field in ("workflow_id",):
690
+ if uuid_field in kwargs and isinstance(kwargs[uuid_field], uuid.UUID):
691
+ kwargs[uuid_field] = _uuid_to_str(kwargs[uuid_field])
692
+ kwargs.setdefault("state_path", ".minder")
693
+ kwargs.setdefault("context_snapshot", {})
694
+ kwargs.setdefault("relationships", {})
695
+ kwargs.setdefault("created_at", _now())
696
+ kwargs.setdefault("updated_at", _now())
697
+ kwargs["_id"] = kwargs.pop("id")
698
+ await self._db.repositories.insert_one(kwargs)
699
+ return _to_doc(kwargs)
700
+
701
+ async def get_repository_by_id(self, repo_id: uuid.UUID) -> _MongoDoc | None:
702
+ doc = await self._db.repositories.find_one({"_id": _uuid_to_str(repo_id)})
703
+ return _to_doc(doc) if doc else None
704
+
705
+ async def get_repository_by_name(self, repo_name: str) -> _MongoDoc | None:
706
+ doc = await self._db.repositories.find_one({"repo_name": repo_name})
707
+ return _to_doc(doc) if doc else None
708
+
709
+ async def list_repositories(self) -> list[_MongoDoc]:
710
+ cursor = self._db.repositories.find()
711
+ return [_to_doc(doc) async for doc in cursor]
712
+
713
+ async def update_repository(
714
+ self, repo_id: uuid.UUID, **kwargs: Any
715
+ ) -> _MongoDoc | None:
716
+ if not kwargs:
717
+ return await self.get_repository_by_id(repo_id)
718
+ kwargs["updated_at"] = _now()
719
+ await self._db.repositories.update_one(
720
+ {"_id": _uuid_to_str(repo_id)}, {"$set": kwargs}
721
+ )
722
+ return await self.get_repository_by_id(repo_id)
723
+
724
+ async def delete_repository(self, repo_id: uuid.UUID) -> None:
725
+ await self._db.repositories.delete_one({"_id": _uuid_to_str(repo_id)})
726
+
727
+ # ------------------------------------------------------------------
728
+ # RepositoryWorkflowState
729
+ # ------------------------------------------------------------------
730
+
731
+ async def create_workflow_state(self, **kwargs: Any) -> _MongoDoc:
732
+ if "id" not in kwargs:
733
+ kwargs["id"] = _uuid_to_str(uuid.uuid4())
734
+ else:
735
+ kwargs["id"] = _uuid_to_str(kwargs["id"])
736
+ for uuid_field in ("repo_id", "session_id"):
737
+ if uuid_field in kwargs and isinstance(kwargs[uuid_field], uuid.UUID):
738
+ kwargs[uuid_field] = _uuid_to_str(kwargs[uuid_field])
739
+ kwargs.setdefault("completed_steps", [])
740
+ kwargs.setdefault("blocked_by", [])
741
+ kwargs.setdefault("artifacts", {})
742
+ kwargs.setdefault("next_step", None)
743
+ kwargs.setdefault("updated_at", _now())
744
+ kwargs["_id"] = kwargs.pop("id")
745
+ await self._db.repository_workflow_states.insert_one(kwargs)
746
+ return _to_doc(kwargs)
747
+
748
+ async def get_workflow_state_by_id(self, state_id: uuid.UUID) -> _MongoDoc | None:
749
+ doc = await self._db.repository_workflow_states.find_one(
750
+ {"_id": _uuid_to_str(state_id)}
751
+ )
752
+ return _to_doc(doc) if doc else None
753
+
754
+ async def get_workflow_state_by_repo(self, repo_id: uuid.UUID) -> _MongoDoc | None:
755
+ doc = await self._db.repository_workflow_states.find_one(
756
+ {"repo_id": _uuid_to_str(repo_id)}
757
+ )
758
+ return _to_doc(doc) if doc else None
759
+
760
+ async def update_workflow_state(
761
+ self, state_id: uuid.UUID, **kwargs: Any
762
+ ) -> _MongoDoc | None:
763
+ if not kwargs:
764
+ return await self.get_workflow_state_by_id(state_id)
765
+ kwargs["updated_at"] = _now()
766
+ await self._db.repository_workflow_states.update_one(
767
+ {"_id": _uuid_to_str(state_id)}, {"$set": kwargs}
768
+ )
769
+ return await self.get_workflow_state_by_id(state_id)
770
+
771
+ async def delete_workflow_state(self, state_id: uuid.UUID) -> None:
772
+ await self._db.repository_workflow_states.delete_one(
773
+ {"_id": _uuid_to_str(state_id)}
774
+ )
775
+
776
+ # ------------------------------------------------------------------
777
+ # Document (metadata — not vector store)
778
+ # ------------------------------------------------------------------
779
+
780
+ async def create_document(
781
+ self,
782
+ title: str,
783
+ content: str,
784
+ doc_type: str,
785
+ source_path: str,
786
+ project: str,
787
+ *,
788
+ chunks: dict[str, Any] | None = None,
789
+ embedding: list[float] | None = None,
790
+ ) -> _MongoDoc:
791
+ doc_id = _uuid_to_str(uuid.uuid4())
792
+ document: dict[str, Any] = {
793
+ "_id": doc_id,
794
+ "title": title,
795
+ "content": content,
796
+ "doc_type": doc_type,
797
+ "source_path": source_path,
798
+ "project": project,
799
+ "chunks": chunks or {},
800
+ "embedding": embedding,
801
+ "created_at": _now(),
802
+ "updated_at": _now(),
803
+ }
804
+ await self._db.documents.insert_one(document)
805
+ return _to_doc(document)
806
+
807
+ async def get_document_by_path(
808
+ self, source_path: str, *, project: str | None = None
809
+ ) -> _MongoDoc | None:
810
+ query: dict[str, Any] = {"source_path": source_path}
811
+ if project is not None:
812
+ query["project"] = project
813
+ doc = await self._db.documents.find_one(query)
814
+ return _to_doc(doc) if doc else None
815
+
816
+ async def get_documents_by_ids(self, doc_ids: list[uuid.UUID]) -> list[_MongoDoc]:
817
+ if not doc_ids:
818
+ return []
819
+ cursor = self._db.documents.find(
820
+ {"_id": {"$in": [_uuid_to_str(doc_id) for doc_id in doc_ids]}}
821
+ )
822
+ return [_to_doc(doc) async for doc in cursor]
823
+
824
+ async def list_documents(self, project: str | None = None) -> list[_MongoDoc]:
825
+ query: dict[str, Any] = {}
826
+ if project is not None:
827
+ query["project"] = project
828
+ cursor = self._db.documents.find(query)
829
+ return [_to_doc(doc) async for doc in cursor]
830
+
831
+ async def upsert_document(
832
+ self,
833
+ *,
834
+ title: str,
835
+ content: str,
836
+ doc_type: str,
837
+ source_path: str,
838
+ project: str,
839
+ chunks: dict[str, Any] | None = None,
840
+ embedding: list[float] | None = None,
841
+ ) -> _MongoDoc:
842
+ existing = await self.get_document_by_path(source_path, project=project)
843
+ if existing is None:
844
+ return await self.create_document(
845
+ title=title,
846
+ content=content,
847
+ doc_type=doc_type,
848
+ source_path=source_path,
849
+ project=project,
850
+ chunks=chunks,
851
+ embedding=embedding,
852
+ )
853
+ await self._db.documents.update_one(
854
+ {"_id": _uuid_to_str(existing.id)},
855
+ {
856
+ "$set": {
857
+ "title": title,
858
+ "content": content,
859
+ "doc_type": doc_type,
860
+ "chunks": chunks or {},
861
+ "embedding": embedding,
862
+ "project": project,
863
+ "updated_at": _now(),
864
+ }
865
+ },
866
+ )
867
+ updated = await self._db.documents.find_one({"_id": _uuid_to_str(existing.id)})
868
+ return _to_doc(updated) # type: ignore[arg-type]
869
+
870
+ async def delete_documents_not_in_paths(
871
+ self, *, project: str, keep_paths: set[str]
872
+ ) -> None:
873
+ query: dict[str, Any] = {"project": project}
874
+ if keep_paths:
875
+ query["source_path"] = {"$nin": list(keep_paths)}
876
+ await self._db.documents.delete_many(query)
877
+
878
+ # ------------------------------------------------------------------
879
+ # History
880
+ # ------------------------------------------------------------------
881
+
882
+ async def create_history(
883
+ self,
884
+ session_id: uuid.UUID,
885
+ role: str,
886
+ content: str,
887
+ reasoning_trace: str | None = None,
888
+ tool_calls: dict[str, Any] | None = None,
889
+ tokens_used: int = 0,
890
+ latency_ms: int = 0,
891
+ ) -> _MongoDoc:
892
+ doc_id = _uuid_to_str(uuid.uuid4())
893
+ document: dict[str, Any] = {
894
+ "_id": doc_id,
895
+ "session_id": _uuid_to_str(session_id),
896
+ "role": role,
897
+ "content": content,
898
+ "reasoning_trace": reasoning_trace,
899
+ "tool_calls": tool_calls or {},
900
+ "tokens_used": tokens_used,
901
+ "latency_ms": latency_ms,
902
+ "created_at": _now(),
903
+ }
904
+ await self._db.history.insert_one(document)
905
+ return _to_doc(document)
906
+
907
+ async def list_history_for_session(self, session_id: uuid.UUID) -> list[_MongoDoc]:
908
+ cursor = self._db.history.find({"session_id": _uuid_to_str(session_id)})
909
+ return [_to_doc(doc) async for doc in cursor]
910
+
911
+ async def list_history_for_user(self, user_id: uuid.UUID) -> list[_MongoDoc]:
912
+ # Get all session IDs for this user, then get history for those sessions
913
+ user_id_str = _uuid_to_str(user_id)
914
+ session_cursor = self._db.sessions.find({"user_id": user_id_str}, {"_id": 1})
915
+ session_ids = [doc["_id"] async for doc in session_cursor]
916
+ if not session_ids:
917
+ return []
918
+ cursor = self._db.history.find({"session_id": {"$in": session_ids}})
919
+ return [_to_doc(doc) async for doc in cursor]
920
+
921
+ async def delete_history_for_session(self, session_id: uuid.UUID) -> int:
922
+ result = await self._db.history.delete_many(
923
+ {"session_id": _uuid_to_str(session_id)}
924
+ )
925
+ return int(result.deleted_count)
926
+
927
+ # ------------------------------------------------------------------
928
+ # Error
929
+ # ------------------------------------------------------------------
930
+
931
+ async def create_error(
932
+ self,
933
+ error_code: str,
934
+ error_message: str,
935
+ stack_trace: str | None = None,
936
+ context: dict[str, Any] | None = None,
937
+ resolution: str | None = None,
938
+ embedding: list[float] | None = None,
939
+ resolved: bool = False,
940
+ ) -> _MongoDoc:
941
+ doc_id = _uuid_to_str(uuid.uuid4())
942
+ document: dict[str, Any] = {
943
+ "_id": doc_id,
944
+ "error_code": error_code,
945
+ "error_message": error_message,
946
+ "stack_trace": stack_trace,
947
+ "context": context or {},
948
+ "resolution": resolution,
949
+ "embedding": embedding,
950
+ "resolved": resolved,
951
+ "created_at": _now(),
952
+ }
953
+ await self._db.errors.insert_one(document)
954
+ return _to_doc(document)
955
+
956
+ async def list_errors(self) -> list[_MongoDoc]:
957
+ cursor = self._db.errors.find()
958
+ return [_to_doc(doc) async for doc in cursor]
959
+
960
+ async def search_errors(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
961
+ """Simple TF-based error search (same as SQLite adapter for now)."""
962
+ rows = await self.list_errors()
963
+ query_vector = self._text_vector(query)
964
+ ranked: list[dict[str, Any]] = []
965
+ for row in rows:
966
+ text = f"{row.error_code} {row.error_message} {row.context}"
967
+ score = self._cosine_similarity(query_vector, self._text_vector(text))
968
+ ranked.append(
969
+ {
970
+ "id": row.id,
971
+ "error_code": row.error_code,
972
+ "error_message": row.error_message,
973
+ "resolution": row.resolution,
974
+ "score": round(score, 4),
975
+ }
976
+ )
977
+ ranked.sort(key=lambda item: cast(float, item["score"]), reverse=True)
978
+ return ranked[:limit]
979
+
980
+ @staticmethod
981
+ def _text_vector(text: str) -> Counter[str]:
982
+ return Counter(token for token in text.lower().split() if len(token) > 2)
983
+
984
+ @staticmethod
985
+ def _cosine_similarity(left: Counter[str], right: Counter[str]) -> float:
986
+ if not left or not right:
987
+ return 0.0
988
+ numerator = sum(left[key] * right[key] for key in left.keys() & right.keys())
989
+ left_norm = math.sqrt(sum(value * value for value in left.values()))
990
+ right_norm = math.sqrt(sum(value * value for value in right.values()))
991
+ if left_norm == 0 or right_norm == 0:
992
+ return 0.0
993
+ return numerator / (left_norm * right_norm)