agentdatabase 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ """
2
+ AgentDB MCP server — stdio transport (Claude Desktop).
3
+
4
+ HTTP+SSE transport: same tool definitions, transport="sse",
5
+ X-AgentDB-Key auth middleware, agentdb-mcp-http entry point.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Optional
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from agentdb import AgentDB
15
+
16
+ mcp = FastMCP("AgentDB")
17
+ _db: Optional[AgentDB] = None
18
+
19
+
20
+ def _get_db() -> AgentDB:
21
+ global _db
22
+ if _db is None:
23
+ _db = AgentDB(memory_dir=os.environ.get("AGENTDB_MEMORY_DIR"))
24
+ return _db
25
+
26
+
27
+ @mcp.tool()
28
+ def memory_write(
29
+ key: str,
30
+ value: str,
31
+ origin: str,
32
+ agent_id: str,
33
+ session_id: Optional[str] = None,
34
+ confidence: Optional[float] = None,
35
+ entities: Optional[list] = None,
36
+ ) -> dict:
37
+ """Write a governed memory record."""
38
+ record = _get_db().write(
39
+ key=key,
40
+ value=value,
41
+ origin=origin,
42
+ agent_id=agent_id,
43
+ session_id=session_id,
44
+ confidence=confidence,
45
+ entities=entities or [],
46
+ )
47
+ return {"id": record.id, "key": record.key, "origin": str(record.origin)}
48
+
49
+
50
+ @mcp.tool()
51
+ def memory_retrieve(
52
+ query: str,
53
+ agent_id: str,
54
+ limit: Optional[int] = None,
55
+ session_id: Optional[str] = None,
56
+ budget_tokens: Optional[int] = None,
57
+ ) -> dict:
58
+ """Retrieve governed memory records ranked by multi-signal ranker."""
59
+ result = _get_db().retrieve(
60
+ query=query,
61
+ agent_id=agent_id,
62
+ limit=limit,
63
+ session_id=session_id,
64
+ budget_tokens=budget_tokens,
65
+ )
66
+ return {
67
+ "records": [
68
+ {"id": r.id, "key": r.key, "value": r.value, "origin": str(r.origin)}
69
+ for r in result.records
70
+ ],
71
+ "conflicts": [str(c) for c in result.conflicts],
72
+ "override_instructions": result.override_instructions,
73
+ "token_estimate": result.token_estimate,
74
+ "truncated": result.truncated,
75
+ }
76
+
77
+
78
+ @mcp.tool()
79
+ def conflict_queue(resolved: bool = False) -> dict:
80
+ """Return pending conflicts from the governance gateway."""
81
+ conflicts = _get_db().gateway.conflict_queue(resolved=resolved)
82
+ return {"conflicts": conflicts, "count": len(conflicts)}
83
+
84
+
85
+ @mcp.tool()
86
+ def memory_stats() -> dict:
87
+ """Return database-wide statistics."""
88
+ return _get_db().system.agentdb_stats()
89
+
90
+
91
+ def main() -> None:
92
+ mcp.run(transport="stdio")
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1,3 @@
1
+ from .algorithm import MultiSignalRanker, RankedRecord
2
+
3
+ __all__ = ["MultiSignalRanker", "RankedRecord"]
@@ -0,0 +1,207 @@
1
+ """
2
+ Multi-signal memory ranking algorithm.
3
+
4
+ This is the algorithmic core of AgentDB. It is not the marketing lead —
5
+ it is what makes the database worth using.
6
+
7
+ Signals (weights are starting defaults, all configurable):
8
+ provenance (0.35) — human_approved > provider_ingested > agent_inferred
9
+ Novel: trust level as a first-class ranking factor.
10
+ recency (0.25) — exponential decay from last_retrieved (falling back
11
+ to created_at for a record never retrieved),
12
+ modulated by retrieval frequency. Frequently-
13
+ retrieved memories decay slower.
14
+ confidence (0.25) — direct passthrough of record.confidence (0-1).
15
+ causal (0.15) — how often was this memory retrieved before
16
+ successful task completions? Phase 2
17
+ approximation: retrieval_count weighted by
18
+ recency. Full WAL-correlation in Phase 4.
19
+
20
+ Semantic signal (cosine similarity against query embedding) is added in
21
+ Phase 3 when embeddings are active. Until then, semantic weight is 0 and
22
+ the remaining four signals carry full weight.
23
+ """
24
+
25
+ import math
26
+ from dataclasses import dataclass
27
+ from datetime import datetime, timezone
28
+ from typing import Any, Optional
29
+
30
+ from ..core.models import MemoryRecord, Origin
31
+
32
+
33
+ @dataclass
34
+ class RankedRecord:
35
+ record: MemoryRecord
36
+ composite_score: float
37
+ signal_breakdown: dict
38
+ conflict_flag: bool
39
+ staleness_warning: bool
40
+
41
+
42
+ _PROVENANCE_SCORES = {
43
+ Origin.HUMAN_APPROVED: 1.0,
44
+ Origin.PROVIDER_INGESTED: 0.6,
45
+ Origin.AGENT_INFERRED: 0.3,
46
+ }
47
+
48
+
49
+ def _utcnow() -> datetime:
50
+ return datetime.now(timezone.utc)
51
+
52
+
53
+ def _aware(dt: datetime) -> datetime:
54
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
55
+
56
+
57
+ class MultiSignalRanker:
58
+ """
59
+ All weights must sum to 1.0.
60
+ semantic_weight is 0.0 until Phase 3 activates embeddings.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ weight_provenance: float = 0.35,
66
+ weight_recency: float = 0.25,
67
+ weight_confidence: float = 0.25,
68
+ weight_causal: float = 0.15,
69
+ weight_semantic: float = 0.0, # activated in Phase 3
70
+ weight_workspace: float = 0.0, # Jaccard overlap with session working dirs
71
+ recency_lambda: float = 0.01, # decay rate; 0.01 ~= half-life 69 days
72
+ staleness_days: int = 90,
73
+ provenance_scores: Optional[dict] = None,
74
+ ):
75
+ total = (
76
+ weight_provenance + weight_recency + weight_confidence
77
+ + weight_causal + weight_semantic + weight_workspace
78
+ )
79
+ if abs(total - 1.0) > 1e-6:
80
+ raise ValueError(f"Signal weights must sum to 1.0, got {total}")
81
+ self.w_provenance = weight_provenance
82
+ self.w_recency = weight_recency
83
+ self.w_confidence = weight_confidence
84
+ self.w_causal = weight_causal
85
+ self.w_semantic = weight_semantic
86
+ self.w_workspace = weight_workspace
87
+ self._lambda = recency_lambda
88
+ self._staleness_days = staleness_days
89
+ self._provenance = provenance_scores or _PROVENANCE_SCORES
90
+
91
+ def rank(
92
+ self,
93
+ query: str,
94
+ candidates: list,
95
+ agent_id: str,
96
+ query_embedding: Optional[list] = None,
97
+ session_id: Optional[str] = None,
98
+ session_working_dirs: Optional[list] = None,
99
+ ) -> list:
100
+ if not candidates:
101
+ return []
102
+
103
+ now = _utcnow()
104
+ results = []
105
+ for record in candidates:
106
+ prov = self._provenance_score(record)
107
+ rec = self._recency_score(record, now)
108
+ conf = record.confidence
109
+ causal = self._causal_score(record, now)
110
+ embedding = getattr(record, "embedding", None)
111
+ sem = (
112
+ self._semantic_score(query_embedding, embedding)
113
+ if query_embedding and embedding else 0.0
114
+ )
115
+ workspace = self._workspace_score(record, session_working_dirs)
116
+
117
+ composite = (
118
+ self.w_provenance * prov
119
+ + self.w_recency * rec
120
+ + self.w_confidence * conf
121
+ + self.w_causal * causal
122
+ + self.w_semantic * sem
123
+ + self.w_workspace * workspace
124
+ )
125
+
126
+ ref_time = self._reference_time(record)
127
+ days_since = (now - ref_time).total_seconds() / 86400
128
+ results.append(RankedRecord(
129
+ record=record,
130
+ composite_score=round(min(max(composite, 0.0), 1.0), 6),
131
+ signal_breakdown={
132
+ "provenance": round(prov, 4),
133
+ "recency": round(rec, 4),
134
+ "confidence": round(conf, 4),
135
+ "causal": round(causal, 4),
136
+ "semantic": round(sem, 4),
137
+ "workspace": round(workspace, 4),
138
+ },
139
+ conflict_flag=bool(getattr(record, "conflict_id", None)),
140
+ staleness_warning=days_since > self._staleness_days,
141
+ ))
142
+
143
+ results.sort(key=lambda x: x.composite_score, reverse=True)
144
+ return results
145
+
146
+ def _reference_time(self, record: MemoryRecord) -> datetime:
147
+ ref = getattr(record, "last_retrieved", None) or record.created_at
148
+ return _aware(ref)
149
+
150
+ def _provenance_score(self, record: MemoryRecord) -> float:
151
+ return self._provenance.get(record.origin, 0.3)
152
+
153
+ def _recency_score(self, record: MemoryRecord, now: datetime) -> float:
154
+ """
155
+ Exponential decay from last_retrieved (or created_at if never
156
+ retrieved). Frequently-retrieved memories decay slower:
157
+ score = exp(-lambda * days) * (1 + log(retrieval_count + 1) * 0.1)
158
+ Capped at 1.0.
159
+ """
160
+ ref_time = self._reference_time(record)
161
+ days = max((now - ref_time).total_seconds() / 86400, 0)
162
+ base = math.exp(-self._lambda * days)
163
+ freq_boost = 1.0 + math.log(record.retrieval_count + 1) * 0.1
164
+ return min(base * freq_boost, 1.0)
165
+
166
+ def _causal_score(self, record: MemoryRecord, now: datetime) -> float:
167
+ """
168
+ Phase 2 approximation: retrieval frequency weighted by recency.
169
+ High retrieval_count + recent last_retrieved = high causal
170
+ relevance. Full WAL-derived correlation deferred to Phase 4.
171
+ """
172
+ if record.retrieval_count == 0:
173
+ return 0.0
174
+ freq_score = min(math.log(record.retrieval_count + 1) / math.log(101), 1.0)
175
+ recency_weight = self._recency_score(record, now)
176
+ return freq_score * recency_weight
177
+
178
+ def _workspace_score(
179
+ self, record: "MemoryRecord", session_working_dirs: Optional[list]
180
+ ) -> float:
181
+ """Jaccard similarity between record's working_dirs and session's working_dirs.
182
+
183
+ Returns 0.5 (neutral) when either side has no data, so legacy records
184
+ are not penalized and sessions with no working_dirs don't distort scores.
185
+ """
186
+ rec_dirs = set(getattr(record, "working_dirs", []) or [])
187
+ sess_dirs = set(session_working_dirs or [])
188
+ if not rec_dirs or not sess_dirs:
189
+ return 0.5
190
+ intersection = rec_dirs & sess_dirs
191
+ union = rec_dirs | sess_dirs
192
+ return len(intersection) / len(union)
193
+
194
+ def _semantic_score(self, query_embedding: list, record_embedding: list) -> float:
195
+ """Cosine similarity. Uses numpy if available, falls back to pure
196
+ Python otherwise."""
197
+ try:
198
+ import numpy as np
199
+ a = np.array(query_embedding)
200
+ b = np.array(record_embedding)
201
+ denom = np.linalg.norm(a) * np.linalg.norm(b)
202
+ return float(np.dot(a, b) / denom) if denom > 0 else 0.0
203
+ except ImportError:
204
+ dot = sum(x * y for x, y in zip(query_embedding, record_embedding))
205
+ mag_a = math.sqrt(sum(x * x for x in query_embedding))
206
+ mag_b = math.sqrt(sum(x * x for x in record_embedding))
207
+ return max(dot / (mag_a * mag_b), 0.0) if mag_a and mag_b else 0.0
@@ -0,0 +1,3 @@
1
+ from .skill_store import SkillStore, SkillStoreError
2
+
3
+ __all__ = ["SkillStore", "SkillStoreError"]
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from contextlib import contextmanager
6
+ from datetime import datetime, timezone, timedelta
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Optional
9
+
10
+ from ..core.models import Skill
11
+
12
+
13
+ class SkillStoreError(Exception):
14
+ pass
15
+
16
+
17
+ class SkillConflict(SkillStoreError):
18
+ """Raised when update() is called while multiple active versions exist.
19
+
20
+ Resolve or archive the existing active versions before adding a new one.
21
+ """
22
+ pass
23
+
24
+
25
+ _CREATE_SKILLS_TABLE = """
26
+ CREATE TABLE IF NOT EXISTS skills (
27
+ id TEXT NOT NULL,
28
+ name TEXT NOT NULL,
29
+ content TEXT NOT NULL,
30
+ authored_by TEXT NOT NULL,
31
+ scope TEXT NOT NULL,
32
+ version TEXT NOT NULL,
33
+ archived INTEGER NOT NULL DEFAULT 0,
34
+ health_score REAL NOT NULL DEFAULT 1.0,
35
+ health_score_reasons TEXT NOT NULL DEFAULT '[]',
36
+ created_at TEXT NOT NULL,
37
+ updated_at TEXT NOT NULL,
38
+ last_retrieved TEXT,
39
+ parent_skill_id TEXT,
40
+ PRIMARY KEY (id, version)
41
+ )
42
+ """
43
+
44
+
45
+ def _utcnow() -> datetime:
46
+ return datetime.now(timezone.utc)
47
+
48
+
49
+ def _row_to_skill(row: sqlite3.Row) -> Skill:
50
+ return Skill(
51
+ id=row["id"],
52
+ name=row["name"],
53
+ content=row["content"],
54
+ authored_by=row["authored_by"],
55
+ scope=json.loads(row["scope"]),
56
+ version=row["version"],
57
+ archived=bool(row["archived"]),
58
+ health_score=row["health_score"],
59
+ health_score_reasons=json.loads(row["health_score_reasons"]),
60
+ created_at=datetime.fromisoformat(row["created_at"]),
61
+ updated_at=datetime.fromisoformat(row["updated_at"]),
62
+ last_retrieved=datetime.fromisoformat(row["last_retrieved"]) if row["last_retrieved"] else None,
63
+ parent_skill_id=row["parent_skill_id"],
64
+ )
65
+
66
+
67
+ def _compute_health(skill: Skill) -> tuple[float, list[str]]:
68
+ """Compute health score based on staleness since last retrieval (or
69
+ creation, for a skill that has never been retrieved)."""
70
+ now = _utcnow()
71
+ reference = skill.last_retrieved or skill.created_at
72
+ if reference.tzinfo is None:
73
+ reference = reference.replace(tzinfo=timezone.utc)
74
+ age_days = (now - reference).days
75
+ health_score = 1.0
76
+ reasons: list[str] = []
77
+ if age_days > 30:
78
+ # Decay formula: steeper than linear to ensure > 45 days gives health < 0.6
79
+ # max(0.1, 1.0 - (age_days - 30) * 0.03)
80
+ health_score = max(0.1, 1.0 - (age_days - 30) * 0.03)
81
+ reasons.append("staleness")
82
+ return health_score, reasons
83
+
84
+
85
+ class SkillStore:
86
+ def __init__(
87
+ self,
88
+ db_path: Any,
89
+ on_stale_crossing: Optional[Callable[[str], None]] = None,
90
+ ) -> None:
91
+ self._db_path = Path(db_path)
92
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
93
+ self.on_stale_crossing = on_stale_crossing
94
+ with self._connect() as conn:
95
+ conn.execute(_CREATE_SKILLS_TABLE)
96
+
97
+ @contextmanager
98
+ def _connect(self):
99
+ conn = sqlite3.connect(str(self._db_path))
100
+ conn.row_factory = sqlite3.Row
101
+ conn.execute("PRAGMA journal_mode=WAL")
102
+ try:
103
+ yield conn
104
+ conn.commit()
105
+ except Exception:
106
+ conn.rollback()
107
+ raise
108
+ finally:
109
+ conn.close()
110
+
111
+ def create(self, skill: Skill) -> Skill:
112
+ if skill.authored_by != "human":
113
+ raise SkillStoreError(
114
+ f"Skills may only be authored by humans, got '{skill.authored_by}'"
115
+ )
116
+ with self._connect() as conn:
117
+ conn.execute(
118
+ """
119
+ INSERT INTO skills
120
+ (id, name, content, authored_by, scope, version, archived,
121
+ health_score, health_score_reasons, created_at, updated_at,
122
+ last_retrieved, parent_skill_id)
123
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
124
+ """,
125
+ (
126
+ skill.id,
127
+ skill.name,
128
+ skill.content,
129
+ skill.authored_by,
130
+ json.dumps(skill.scope),
131
+ skill.version,
132
+ int(skill.archived),
133
+ skill.health_score,
134
+ json.dumps(skill.health_score_reasons),
135
+ skill.created_at.isoformat(),
136
+ skill.updated_at.isoformat(),
137
+ skill.last_retrieved.isoformat() if skill.last_retrieved else None,
138
+ skill.parent_skill_id,
139
+ ),
140
+ )
141
+ return skill
142
+
143
+ def get(self, skill_id: str) -> Optional[Skill]:
144
+ """
145
+ Returns the latest non-archived version of the skill.
146
+ Falls back to oldest non-archived if all newer versions are archived.
147
+ """
148
+ with self._connect() as conn:
149
+ # Get all non-archived versions, ordered newest first
150
+ rows = conn.execute(
151
+ """
152
+ SELECT * FROM skills
153
+ WHERE id = ? AND archived = 0
154
+ ORDER BY created_at DESC
155
+ """,
156
+ (skill_id,),
157
+ ).fetchall()
158
+
159
+ if not rows:
160
+ return None
161
+
162
+ # Return the first (newest) non-archived row
163
+ skill = _row_to_skill(rows[0])
164
+ return skill
165
+
166
+ def update(self, skill_id: str, content: str, new_version: str) -> Skill:
167
+ """
168
+ Create a new version of the skill with parent_skill_id=skill_id.
169
+ Raises SkillConflict if more than one active version already exists.
170
+ The count check and INSERT run in the same transaction to avoid a
171
+ TOCTOU window between the guard and the write.
172
+ """
173
+ original = self.get(skill_id)
174
+ if original is None:
175
+ raise SkillStoreError(f"Skill '{skill_id}' not found")
176
+
177
+ now = _utcnow()
178
+ new_skill = Skill(
179
+ id=skill_id,
180
+ name=original.name,
181
+ content=content,
182
+ authored_by=original.authored_by,
183
+ scope=original.scope,
184
+ version=new_version,
185
+ archived=False,
186
+ health_score=1.0,
187
+ health_score_reasons=[],
188
+ created_at=now,
189
+ updated_at=now,
190
+ last_retrieved=None,
191
+ parent_skill_id=skill_id,
192
+ )
193
+ with self._connect() as conn:
194
+ count = conn.execute(
195
+ "SELECT COUNT(*) FROM skills WHERE id = ? AND archived = 0",
196
+ (skill_id,),
197
+ ).fetchone()[0]
198
+ if count > 1:
199
+ raise SkillConflict(
200
+ f"Skill '{skill_id}' has unresolved active versions — "
201
+ f"archive or reconcile existing versions before updating"
202
+ )
203
+ conn.execute(
204
+ """
205
+ INSERT INTO skills
206
+ (id, name, content, authored_by, scope, version, archived,
207
+ health_score, health_score_reasons, created_at, updated_at,
208
+ last_retrieved, parent_skill_id)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
210
+ """,
211
+ (
212
+ new_skill.id,
213
+ new_skill.name,
214
+ new_skill.content,
215
+ new_skill.authored_by,
216
+ json.dumps(new_skill.scope),
217
+ new_skill.version,
218
+ int(new_skill.archived),
219
+ new_skill.health_score,
220
+ json.dumps(new_skill.health_score_reasons),
221
+ new_skill.created_at.isoformat(),
222
+ new_skill.updated_at.isoformat(),
223
+ None,
224
+ new_skill.parent_skill_id,
225
+ ),
226
+ )
227
+ return new_skill
228
+
229
+ def get_for_agent(self, agent_id: str) -> list[Skill]:
230
+ """
231
+ Returns non-archived skills where agent_id is in scope.
232
+ Computes health_score based on staleness. Fires on_stale_crossing
233
+ callback exactly once when health first crosses below 0.6.
234
+ """
235
+ with self._connect() as conn:
236
+ rows = conn.execute(
237
+ """
238
+ SELECT * FROM skills WHERE archived = 0
239
+ ORDER BY created_at DESC
240
+ """
241
+ ).fetchall()
242
+
243
+ results: list[Skill] = []
244
+ seen_ids: set[str] = set()
245
+ for row in rows:
246
+ skill = _row_to_skill(row)
247
+ if skill.id in seen_ids:
248
+ continue
249
+ if agent_id not in skill.scope:
250
+ continue
251
+ seen_ids.add(skill.id)
252
+
253
+ stored_health = skill.health_score # from DB — reflects last persisted score
254
+ new_health, reasons = _compute_health(skill)
255
+ skill.health_score = new_health
256
+ skill.health_score_reasons = reasons
257
+
258
+ # Detect first-time crossing below 0.6: persist so it won't re-fire
259
+ if stored_health >= 0.6 and new_health < 0.6:
260
+ self._persist_health_score(skill.id, skill.version, new_health)
261
+ if self.on_stale_crossing is not None:
262
+ self.on_stale_crossing(skill.id)
263
+
264
+ results.append(skill)
265
+
266
+ return results
267
+
268
+ def mark_retrieved(self, skill_id: str) -> None:
269
+ now = _utcnow().isoformat()
270
+ with self._connect() as conn:
271
+ conn.execute(
272
+ """
273
+ UPDATE skills SET last_retrieved = ?
274
+ WHERE id = ? AND archived = 0
275
+ """,
276
+ (now, skill_id),
277
+ )
278
+
279
+ def _persist_health_score(self, skill_id: str, version: str, health_score: float) -> None:
280
+ with self._connect() as conn:
281
+ conn.execute(
282
+ "UPDATE skills SET health_score = ? WHERE id = ? AND version = ?",
283
+ (health_score, skill_id, version),
284
+ )
285
+
286
+ def archive(self, skill_id: str, version: str) -> None:
287
+ with self._connect() as conn:
288
+ conn.execute(
289
+ """
290
+ UPDATE skills SET archived = 1
291
+ WHERE id = ? AND version = ?
292
+ """,
293
+ (skill_id, version),
294
+ )
295
+
296
+ def list_all_active_ids(self) -> list[str]:
297
+ """Return composite IDs 'id:version' for all non-archived skills."""
298
+ with self._connect() as conn:
299
+ rows = conn.execute(
300
+ "SELECT id, version FROM skills WHERE archived = 0"
301
+ ).fetchall()
302
+ return [f"{row['id']}:{row['version']}" for row in rows]
303
+
304
+ def list_stale(self, agent_id: str, days: int = 30) -> list[Skill]:
305
+ """Returns non-archived skills for agent_id that are older than `days`."""
306
+ skills = self.get_for_agent(agent_id)
307
+ now = _utcnow()
308
+ stale = []
309
+ for skill in skills:
310
+ created_at = skill.created_at
311
+ if created_at.tzinfo is None:
312
+ created_at = created_at.replace(tzinfo=timezone.utc)
313
+ age_days = (now - created_at).days
314
+ if age_days > days:
315
+ stale.append(skill)
316
+ return stale
317
+
318
+ def search(self, query: str, agent_id: Optional[str] = None) -> list[Skill]:
319
+ """Case-insensitive keyword search across skill name and content.
320
+
321
+ If agent_id is given, only returns skills where agent_id is in scope.
322
+ Returns [] for an empty query.
323
+ """
324
+ words = [w.lower() for w in query.split() if w]
325
+ if not words:
326
+ return []
327
+
328
+ with self._connect() as conn:
329
+ rows = conn.execute(
330
+ "SELECT * FROM skills WHERE archived = 0 ORDER BY created_at DESC"
331
+ ).fetchall()
332
+
333
+ seen_ids: set[str] = set()
334
+ results: list[Skill] = []
335
+ for row in rows:
336
+ skill = _row_to_skill(row)
337
+ if skill.id in seen_ids:
338
+ continue
339
+ if agent_id is not None and agent_id not in skill.scope:
340
+ continue
341
+ name_lower = skill.name.lower()
342
+ content_lower = skill.content.lower()
343
+ for word in words:
344
+ if word in name_lower or word in content_lower:
345
+ seen_ids.add(skill.id)
346
+ new_health, reasons = _compute_health(skill)
347
+ skill.health_score = new_health
348
+ skill.health_score_reasons = reasons
349
+ results.append(skill)
350
+ break
351
+ return results