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.
- agentdatabase-0.1.0.dist-info/METADATA +847 -0
- agentdatabase-0.1.0.dist-info/RECORD +35 -0
- agentdatabase-0.1.0.dist-info/WHEEL +5 -0
- agentdatabase-0.1.0.dist-info/entry_points.txt +2 -0
- agentdatabase-0.1.0.dist-info/licenses/LICENSE +651 -0
- agentdatabase-0.1.0.dist-info/top_level.txt +1 -0
- agentdb/__init__.py +5 -0
- agentdb/adapters/claude_agent_sdk.py +831 -0
- agentdb/adapters/hermes.py +247 -0
- agentdb/backend.py +75 -0
- agentdb/core/__init__.py +19 -0
- agentdb/core/directory_tracking.py +59 -0
- agentdb/core/file_integrity.py +79 -0
- agentdb/core/models.py +90 -0
- agentdb/core/profiles.py +116 -0
- agentdb/core/store.py +373 -0
- agentdb/core/system.py +86 -0
- agentdb/embeddings/__init__.py +7 -0
- agentdb/embeddings/provider.py +99 -0
- agentdb/embeddings/store.py +199 -0
- agentdb/embeddings/text.py +20 -0
- agentdb/gateway/__init__.py +189 -0
- agentdb/gateway/adapter.py +58 -0
- agentdb/governance/__init__.py +3 -0
- agentdb/governance/conflict_detector.py +103 -0
- agentdb/governance/lifecycle_manager.py +226 -0
- agentdb/governance/permission_router.py +131 -0
- agentdb/interface/__init__.py +25 -0
- agentdb/interface/client.py +1108 -0
- agentdb/interface/mcp_server.py +96 -0
- agentdb/retrieval/__init__.py +3 -0
- agentdb/retrieval/algorithm.py +207 -0
- agentdb/skills/__init__.py +3 -0
- agentdb/skills/skill_store.py +351 -0
- agentdb/testing.py +68 -0
|
@@ -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,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,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
|