exemplar-core 0.1.0__tar.gz
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.
- exemplar_core-0.1.0/PKG-INFO +33 -0
- exemplar_core-0.1.0/README.md +17 -0
- exemplar_core-0.1.0/exemplar_core/__init__.py +63 -0
- exemplar_core-0.1.0/exemplar_core/config.py +64 -0
- exemplar_core-0.1.0/exemplar_core/exceptions.py +20 -0
- exemplar_core-0.1.0/exemplar_core/memory/__init__.py +21 -0
- exemplar_core-0.1.0/exemplar_core/memory/client.py +80 -0
- exemplar_core-0.1.0/exemplar_core/memory/memory.py +207 -0
- exemplar_core-0.1.0/exemplar_core/memory/models.py +108 -0
- exemplar_core-0.1.0/exemplar_core/skills/__init__.py +13 -0
- exemplar_core-0.1.0/exemplar_core/skills/client.py +90 -0
- exemplar_core-0.1.0/exemplar_core/skills/models.py +70 -0
- exemplar_core-0.1.0/exemplar_core/skills/skill_md.py +54 -0
- exemplar_core-0.1.0/exemplar_core/skills/skills.py +134 -0
- exemplar_core-0.1.0/pyproject.toml +23 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: exemplar-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared Exemplar REST clients for skills and memory
|
|
5
|
+
License: LicenseRef-Proprietary
|
|
6
|
+
Author: Exemplar Dev LLC
|
|
7
|
+
Requires-Python: >=3.10,<3.14
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: httpx (>=0.27.0)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# exemplar-core
|
|
17
|
+
|
|
18
|
+
Shared HTTP clients for Exemplar **skills** (`/api/agent-skills`) and **memory** (`/api/harness-memory`).
|
|
19
|
+
|
|
20
|
+
Used by `exemplar-harness-sdk` and `exemplar-cli`. Install directly only if you need the REST layer without the full SDK.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install exemplar-core
|
|
24
|
+
export EXEMPLAR_API_KEY=eis_...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from exemplar_core import skills_from_env, memory_from_env
|
|
29
|
+
|
|
30
|
+
skills = skills_from_env()
|
|
31
|
+
memory = memory_from_env(user_id="user-123")
|
|
32
|
+
```
|
|
33
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# exemplar-core
|
|
2
|
+
|
|
3
|
+
Shared HTTP clients for Exemplar **skills** (`/api/agent-skills`) and **memory** (`/api/harness-memory`).
|
|
4
|
+
|
|
5
|
+
Used by `exemplar-harness-sdk` and `exemplar-cli`. Install directly only if you need the REST layer without the full SDK.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install exemplar-core
|
|
9
|
+
export EXEMPLAR_API_KEY=eis_...
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from exemplar_core import skills_from_env, memory_from_env
|
|
14
|
+
|
|
15
|
+
skills = skills_from_env()
|
|
16
|
+
memory = memory_from_env(user_id="user-123")
|
|
17
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Shared Exemplar REST clients for skills and memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from exemplar_core.config import (
|
|
8
|
+
Environment,
|
|
9
|
+
resolve_api_key,
|
|
10
|
+
resolve_base_url,
|
|
11
|
+
resolve_organization_id,
|
|
12
|
+
)
|
|
13
|
+
from exemplar_core.exceptions import ExemplarAPIError, ExemplarConfigError, ExemplarError
|
|
14
|
+
from exemplar_core.memory.client import MemoryHTTPClient
|
|
15
|
+
from exemplar_core.memory.memory import HarnessMemory
|
|
16
|
+
from exemplar_core.memory.models import MemoryRecord, MemoryScopes
|
|
17
|
+
from exemplar_core.skills.client import SkillsHTTPClient
|
|
18
|
+
from exemplar_core.skills.models import SkillRecord, SkillSummary
|
|
19
|
+
from exemplar_core.skills.skills import HarnessSkills
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Environment",
|
|
23
|
+
"ExemplarAPIError",
|
|
24
|
+
"ExemplarConfigError",
|
|
25
|
+
"ExemplarError",
|
|
26
|
+
"HarnessMemory",
|
|
27
|
+
"HarnessSkills",
|
|
28
|
+
"MemoryRecord",
|
|
29
|
+
"MemoryScopes",
|
|
30
|
+
"SkillRecord",
|
|
31
|
+
"SkillSummary",
|
|
32
|
+
"memory_from_env",
|
|
33
|
+
"skills_from_env",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def skills_from_env(*, organization_id: Optional[str] = None) -> HarnessSkills:
|
|
38
|
+
api_key = resolve_api_key()
|
|
39
|
+
base_url = resolve_base_url()
|
|
40
|
+
org_id = resolve_organization_id(organization_id)
|
|
41
|
+
client = SkillsHTTPClient(api_key=api_key, base_url=base_url, organization_id=org_id)
|
|
42
|
+
return HarnessSkills(client)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def memory_from_env(
|
|
46
|
+
*,
|
|
47
|
+
organization_id: Optional[str] = None,
|
|
48
|
+
user_id: Optional[str] = None,
|
|
49
|
+
agent_id: Optional[str] = None,
|
|
50
|
+
session_id: Optional[str] = None,
|
|
51
|
+
app_id: Optional[str] = None,
|
|
52
|
+
) -> HarnessMemory:
|
|
53
|
+
api_key = resolve_api_key()
|
|
54
|
+
base_url = resolve_base_url()
|
|
55
|
+
org_id = resolve_organization_id(organization_id)
|
|
56
|
+
client = MemoryHTTPClient(api_key=api_key, base_url=base_url, organization_id=org_id)
|
|
57
|
+
scopes = MemoryScopes(
|
|
58
|
+
user_id=user_id,
|
|
59
|
+
agent_id=agent_id,
|
|
60
|
+
session_id=session_id,
|
|
61
|
+
app_id=app_id,
|
|
62
|
+
)
|
|
63
|
+
return HarnessMemory(client, scopes=scopes)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Resolve API key, base URL, and environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from exemplar_core.exceptions import ExemplarConfigError
|
|
10
|
+
|
|
11
|
+
BASE_URLS = {
|
|
12
|
+
"production": "https://production-api.exemplar.dev",
|
|
13
|
+
"development": "https://development-api.exemplar.dev",
|
|
14
|
+
"local": "http://localhost:8000",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Environment(str, Enum):
|
|
19
|
+
PRODUCTION = "production"
|
|
20
|
+
DEVELOPMENT = "development"
|
|
21
|
+
LOCAL = "local"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_api_key(explicit: Optional[str] = None) -> str:
|
|
25
|
+
key = explicit or os.environ.get("EXEMPLAR_API_KEY") or ""
|
|
26
|
+
if not key:
|
|
27
|
+
raise ExemplarConfigError("EXEMPLAR_API_KEY is not set")
|
|
28
|
+
return key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_environment(explicit: Optional[Environment | str] = None) -> Environment:
|
|
32
|
+
if explicit is not None:
|
|
33
|
+
if isinstance(explicit, Environment):
|
|
34
|
+
return explicit
|
|
35
|
+
return Environment(str(explicit).lower())
|
|
36
|
+
env = (os.environ.get("EXEMPLAR_ENV") or "production").lower()
|
|
37
|
+
try:
|
|
38
|
+
return Environment(env)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return Environment.PRODUCTION
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_base_url(
|
|
44
|
+
*,
|
|
45
|
+
base_url: Optional[str] = None,
|
|
46
|
+
environment: Optional[Environment | str] = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
if base_url:
|
|
49
|
+
return base_url.rstrip("/")
|
|
50
|
+
env_url = os.environ.get("EXEMPLAR_BASE_URL")
|
|
51
|
+
if env_url:
|
|
52
|
+
return env_url.rstrip("/")
|
|
53
|
+
env = resolve_environment(environment)
|
|
54
|
+
return BASE_URLS[env.value]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_organization_id(explicit: Optional[str] = None) -> Optional[str]:
|
|
58
|
+
org_id = (
|
|
59
|
+
explicit
|
|
60
|
+
or os.environ.get("EXEMPLAR_ORGANIZATION_ID")
|
|
61
|
+
or os.environ.get("EXEMPLAR_ORG_ID")
|
|
62
|
+
or ""
|
|
63
|
+
).strip()
|
|
64
|
+
return org_id or None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Core library exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExemplarError(Exception):
|
|
7
|
+
"""Base error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExemplarConfigError(ExemplarError):
|
|
11
|
+
"""Missing or invalid configuration."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExemplarAPIError(ExemplarError):
|
|
15
|
+
"""Integration-service API returned an error."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.message = message
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Harness memory client."""
|
|
2
|
+
|
|
3
|
+
from exemplar_core.memory.memory import HarnessMemory
|
|
4
|
+
from exemplar_core.memory.models import (
|
|
5
|
+
MemoryRecord,
|
|
6
|
+
MemoryScopes,
|
|
7
|
+
normalize_search_filters,
|
|
8
|
+
require_bulk_delete_filters,
|
|
9
|
+
require_scope,
|
|
10
|
+
scopes_from_kwargs,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"HarnessMemory",
|
|
15
|
+
"MemoryRecord",
|
|
16
|
+
"MemoryScopes",
|
|
17
|
+
"normalize_search_filters",
|
|
18
|
+
"require_bulk_delete_filters",
|
|
19
|
+
"require_scope",
|
|
20
|
+
"scopes_from_kwargs",
|
|
21
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""HTTP client for /api/harness-memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from exemplar_core.exceptions import ExemplarAPIError
|
|
10
|
+
|
|
11
|
+
MEMORY_PREFIX = "/api/harness-memory"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MemoryHTTPClient:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str,
|
|
20
|
+
organization_id: Optional[str] = None,
|
|
21
|
+
timeout: float = 60.0,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._api_key = api_key
|
|
24
|
+
self._base_url = base_url.rstrip("/")
|
|
25
|
+
self._organization_id = (organization_id or "").strip() or None
|
|
26
|
+
self._timeout = timeout
|
|
27
|
+
|
|
28
|
+
def _headers(self) -> dict[str, str]:
|
|
29
|
+
headers = {
|
|
30
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
31
|
+
"X-API-Key": self._api_key,
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
}
|
|
34
|
+
if self._organization_id:
|
|
35
|
+
headers["x-organization-id"] = self._organization_id
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
def _url(self, path: str) -> str:
|
|
39
|
+
return f"{self._base_url}{MEMORY_PREFIX}{path}"
|
|
40
|
+
|
|
41
|
+
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
43
|
+
resp = client.post(self._url(path), json=body, headers=self._headers())
|
|
44
|
+
if resp.status_code >= 400:
|
|
45
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
46
|
+
if resp.status_code == 204 or not resp.content:
|
|
47
|
+
return {}
|
|
48
|
+
return resp.json()
|
|
49
|
+
|
|
50
|
+
def put(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
52
|
+
resp = client.put(self._url(path), json=body, headers=self._headers())
|
|
53
|
+
if resp.status_code >= 400:
|
|
54
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
55
|
+
if resp.status_code == 204 or not resp.content:
|
|
56
|
+
return {}
|
|
57
|
+
return resp.json()
|
|
58
|
+
|
|
59
|
+
def get(self, path: str, *, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
60
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
61
|
+
resp = client.get(self._url(path), headers=self._headers(), params=params)
|
|
62
|
+
if resp.status_code >= 400:
|
|
63
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
64
|
+
if resp.status_code == 204 or not resp.content:
|
|
65
|
+
return {}
|
|
66
|
+
return resp.json()
|
|
67
|
+
|
|
68
|
+
def delete(self, path: str, *, body: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
69
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
70
|
+
resp = client.request(
|
|
71
|
+
"DELETE",
|
|
72
|
+
self._url(path),
|
|
73
|
+
headers=self._headers(),
|
|
74
|
+
json=body,
|
|
75
|
+
)
|
|
76
|
+
if resp.status_code >= 400:
|
|
77
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
78
|
+
if resp.status_code == 204 or not resp.content:
|
|
79
|
+
return {}
|
|
80
|
+
return resp.json()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""High-level harness memory API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from exemplar_core.memory.client import MemoryHTTPClient
|
|
9
|
+
from exemplar_core.memory.models import (
|
|
10
|
+
MemoryRecord,
|
|
11
|
+
MemoryScopes,
|
|
12
|
+
normalize_search_filters,
|
|
13
|
+
require_bulk_delete_filters,
|
|
14
|
+
require_scope,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_recall(results: list[MemoryRecord]) -> str:
|
|
19
|
+
if not results:
|
|
20
|
+
return ""
|
|
21
|
+
lines = ["Relevant memories:"]
|
|
22
|
+
for item in results:
|
|
23
|
+
prefix = f"- [{item.memory_type}]"
|
|
24
|
+
if item.score is not None:
|
|
25
|
+
prefix = f"- ({item.score:.2f}) [{item.memory_type}]"
|
|
26
|
+
lines.append(f"{prefix} {item.content}")
|
|
27
|
+
return "\n".join(lines)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HarnessMemory:
|
|
31
|
+
"""Add, search, and inject long-term memory via integration-service REST."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
client: MemoryHTTPClient,
|
|
36
|
+
*,
|
|
37
|
+
scopes: Optional[MemoryScopes] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._client = client
|
|
40
|
+
self._scopes = scopes or MemoryScopes()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def scopes(self) -> MemoryScopes:
|
|
44
|
+
return self._scopes
|
|
45
|
+
|
|
46
|
+
def with_scopes(self, **kwargs: Any) -> "HarnessMemory":
|
|
47
|
+
merged = MemoryScopes(
|
|
48
|
+
user_id=kwargs.get("user_id", self._scopes.user_id),
|
|
49
|
+
agent_id=kwargs.get("agent_id", self._scopes.agent_id),
|
|
50
|
+
session_id=kwargs.get("session_id", self._scopes.session_id),
|
|
51
|
+
app_id=kwargs.get("app_id", self._scopes.app_id),
|
|
52
|
+
)
|
|
53
|
+
return HarnessMemory(self._client, scopes=merged)
|
|
54
|
+
|
|
55
|
+
def add(
|
|
56
|
+
self,
|
|
57
|
+
content: str,
|
|
58
|
+
*,
|
|
59
|
+
memory_type: str = "fact",
|
|
60
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
61
|
+
scopes: Optional[MemoryScopes] = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
text = (content or "").strip()
|
|
64
|
+
if not text:
|
|
65
|
+
raise ValueError("content must be non-empty")
|
|
66
|
+
active = scopes or self._scopes
|
|
67
|
+
require_scope(active)
|
|
68
|
+
body: dict[str, Any] = {
|
|
69
|
+
"content": text,
|
|
70
|
+
"memoryType": memory_type,
|
|
71
|
+
"scopes": active.to_api_dict(),
|
|
72
|
+
"metadata": metadata or {},
|
|
73
|
+
}
|
|
74
|
+
return self._client.post("/v1/memories", body)
|
|
75
|
+
|
|
76
|
+
def update(
|
|
77
|
+
self,
|
|
78
|
+
memory_id: str,
|
|
79
|
+
*,
|
|
80
|
+
content: Optional[str] = None,
|
|
81
|
+
memory_type: Optional[str] = None,
|
|
82
|
+
scopes: Optional[MemoryScopes] = None,
|
|
83
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
84
|
+
expires_at: Optional[datetime] = None,
|
|
85
|
+
) -> MemoryRecord:
|
|
86
|
+
mid = (memory_id or "").strip()
|
|
87
|
+
if not mid:
|
|
88
|
+
raise ValueError("memory_id must be non-empty")
|
|
89
|
+
body: dict[str, Any] = {}
|
|
90
|
+
if content is not None:
|
|
91
|
+
text = content.strip()
|
|
92
|
+
if not text:
|
|
93
|
+
raise ValueError("content must be non-empty when provided")
|
|
94
|
+
body["content"] = text
|
|
95
|
+
if memory_type is not None:
|
|
96
|
+
body["memoryType"] = memory_type
|
|
97
|
+
if scopes is not None:
|
|
98
|
+
require_scope(scopes)
|
|
99
|
+
body["scopes"] = scopes.to_api_dict()
|
|
100
|
+
if metadata is not None:
|
|
101
|
+
body["metadata"] = metadata
|
|
102
|
+
if expires_at is not None:
|
|
103
|
+
body["expiresAt"] = expires_at.isoformat()
|
|
104
|
+
if not body:
|
|
105
|
+
raise ValueError("at least one field must be provided to update")
|
|
106
|
+
raw = self._client.put(f"/v1/memories/{mid}", body)
|
|
107
|
+
return MemoryRecord.from_api(raw)
|
|
108
|
+
|
|
109
|
+
def search(
|
|
110
|
+
self,
|
|
111
|
+
query: str,
|
|
112
|
+
*,
|
|
113
|
+
top_k: int = 10,
|
|
114
|
+
threshold: float = 0.1,
|
|
115
|
+
include_episodic: bool = False,
|
|
116
|
+
filters: Optional[dict[str, Any]] = None,
|
|
117
|
+
scopes: Optional[MemoryScopes] = None,
|
|
118
|
+
) -> list[MemoryRecord]:
|
|
119
|
+
q = (query or "").strip()
|
|
120
|
+
if not q:
|
|
121
|
+
raise ValueError("query must be non-empty")
|
|
122
|
+
active = scopes or self._scopes
|
|
123
|
+
merged_filters = dict(filters or {})
|
|
124
|
+
for key, val in active.to_filter_dict().items():
|
|
125
|
+
merged_filters.setdefault(key, val)
|
|
126
|
+
body = {
|
|
127
|
+
"query": q,
|
|
128
|
+
"filters": normalize_search_filters(merged_filters),
|
|
129
|
+
"topK": top_k,
|
|
130
|
+
"threshold": threshold,
|
|
131
|
+
"includeEpisodic": include_episodic,
|
|
132
|
+
}
|
|
133
|
+
raw = self._client.post("/v1/memories/search", body)
|
|
134
|
+
results = raw.get("results") if isinstance(raw, dict) else []
|
|
135
|
+
if not isinstance(results, list):
|
|
136
|
+
return []
|
|
137
|
+
return [MemoryRecord.from_api(item) for item in results if isinstance(item, dict)]
|
|
138
|
+
|
|
139
|
+
def recall(
|
|
140
|
+
self,
|
|
141
|
+
query: str,
|
|
142
|
+
*,
|
|
143
|
+
top_k: int = 5,
|
|
144
|
+
threshold: float = 0.1,
|
|
145
|
+
**search_kwargs: Any,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Return formatted memory context suitable for a system prompt."""
|
|
148
|
+
records = self.search(query, top_k=top_k, threshold=threshold, **search_kwargs)
|
|
149
|
+
return _format_recall(records)
|
|
150
|
+
|
|
151
|
+
def list(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
skip: int = 0,
|
|
155
|
+
limit: int = 100,
|
|
156
|
+
memory_type: Optional[str] = None,
|
|
157
|
+
include_episodic: bool = True,
|
|
158
|
+
scopes: Optional[MemoryScopes] = None,
|
|
159
|
+
) -> list[MemoryRecord]:
|
|
160
|
+
active = scopes or self._scopes
|
|
161
|
+
params: dict[str, Any] = {
|
|
162
|
+
"skip": skip,
|
|
163
|
+
"limit": limit,
|
|
164
|
+
"includeEpisodic": include_episodic,
|
|
165
|
+
}
|
|
166
|
+
params.update(active.to_api_dict())
|
|
167
|
+
if memory_type:
|
|
168
|
+
params["memoryType"] = memory_type
|
|
169
|
+
raw = self._client.get("/v1/memories", params=params)
|
|
170
|
+
memories = raw.get("memories") if isinstance(raw, dict) else []
|
|
171
|
+
if not isinstance(memories, list):
|
|
172
|
+
return []
|
|
173
|
+
return [MemoryRecord.from_api(item) for item in memories if isinstance(item, dict)]
|
|
174
|
+
|
|
175
|
+
def get(self, memory_id: str) -> MemoryRecord:
|
|
176
|
+
mid = (memory_id or "").strip()
|
|
177
|
+
if not mid:
|
|
178
|
+
raise ValueError("memory_id must be non-empty")
|
|
179
|
+
raw = self._client.get(f"/v1/memories/{mid}")
|
|
180
|
+
return MemoryRecord.from_api(raw)
|
|
181
|
+
|
|
182
|
+
def delete(self, memory_id: str) -> None:
|
|
183
|
+
mid = (memory_id or "").strip()
|
|
184
|
+
if not mid:
|
|
185
|
+
raise ValueError("memory_id must be non-empty")
|
|
186
|
+
self._client.delete(f"/v1/memories/{mid}")
|
|
187
|
+
|
|
188
|
+
def delete_bulk(
|
|
189
|
+
self,
|
|
190
|
+
*,
|
|
191
|
+
filters: Optional[dict[str, Any]] = None,
|
|
192
|
+
scopes: Optional[MemoryScopes] = None,
|
|
193
|
+
memory_type: Optional[str] = None,
|
|
194
|
+
) -> int:
|
|
195
|
+
"""Delete all memories matching filters (requires at least one scope filter)."""
|
|
196
|
+
active = scopes or self._scopes
|
|
197
|
+
merged: dict[str, Any] = dict(filters or {})
|
|
198
|
+
for key, val in active.to_filter_dict().items():
|
|
199
|
+
merged.setdefault(key, val)
|
|
200
|
+
if memory_type:
|
|
201
|
+
merged["memoryType"] = memory_type
|
|
202
|
+
require_bulk_delete_filters(merged)
|
|
203
|
+
raw = self._client.delete(
|
|
204
|
+
"/v1/memories",
|
|
205
|
+
body={"filters": normalize_search_filters(merged)},
|
|
206
|
+
)
|
|
207
|
+
return int(raw.get("deleted", 0))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Memory API models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class MemoryScopes:
|
|
11
|
+
user_id: Optional[str] = None
|
|
12
|
+
agent_id: Optional[str] = None
|
|
13
|
+
session_id: Optional[str] = None
|
|
14
|
+
app_id: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
def to_api_dict(self) -> dict[str, str]:
|
|
17
|
+
out: dict[str, str] = {}
|
|
18
|
+
if self.user_id:
|
|
19
|
+
out["userId"] = self.user_id
|
|
20
|
+
if self.agent_id:
|
|
21
|
+
out["agentId"] = self.agent_id
|
|
22
|
+
if self.session_id:
|
|
23
|
+
out["sessionId"] = self.session_id
|
|
24
|
+
if self.app_id:
|
|
25
|
+
out["appId"] = self.app_id
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
def to_filter_dict(self) -> dict[str, str]:
|
|
29
|
+
return dict(self.to_api_dict())
|
|
30
|
+
|
|
31
|
+
def has_scope(self) -> bool:
|
|
32
|
+
return bool(self.to_api_dict())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SCOPE_REQUIRED_MSG = (
|
|
36
|
+
"at least one scope is required (userId, agentId, sessionId, or appId)"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_scope(scopes: MemoryScopes) -> None:
|
|
41
|
+
if not scopes.has_scope():
|
|
42
|
+
raise ValueError(SCOPE_REQUIRED_MSG)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
FILTERS_REQUIRED_MSG = "filters required for bulk delete"
|
|
46
|
+
|
|
47
|
+
_SCOPE_FILTER_KEYS = ("userId", "agentId", "sessionId", "appId")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def require_bulk_delete_filters(filters: dict[str, Any]) -> None:
|
|
51
|
+
if not filters:
|
|
52
|
+
raise ValueError(FILTERS_REQUIRED_MSG)
|
|
53
|
+
if not any(filters.get(key) for key in _SCOPE_FILTER_KEYS):
|
|
54
|
+
raise ValueError(SCOPE_REQUIRED_MSG)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scopes_from_kwargs(**kwargs: Any) -> MemoryScopes:
|
|
58
|
+
return MemoryScopes(
|
|
59
|
+
user_id=kwargs.get("user_id"),
|
|
60
|
+
agent_id=kwargs.get("agent_id"),
|
|
61
|
+
session_id=kwargs.get("session_id"),
|
|
62
|
+
app_id=kwargs.get("app_id"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_search_filters(filters: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
"""Chroma requires a single top-level operator when combining conditions."""
|
|
68
|
+
if not filters or len(filters) == 1:
|
|
69
|
+
return filters
|
|
70
|
+
if any(str(key).startswith("$") for key in filters):
|
|
71
|
+
return filters
|
|
72
|
+
return {"$and": [{key: value} for key, value in filters.items()]}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MemoryRecord:
|
|
77
|
+
memory_id: str
|
|
78
|
+
content: str
|
|
79
|
+
memory_type: str = "fact"
|
|
80
|
+
score: Optional[float] = None
|
|
81
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
82
|
+
scopes: Optional[MemoryScopes] = None
|
|
83
|
+
created_at: Optional[str] = None
|
|
84
|
+
updated_at: Optional[str] = None
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_api(cls, data: dict[str, Any]) -> "MemoryRecord":
|
|
88
|
+
scopes_raw = data.get("scopes")
|
|
89
|
+
scopes: Optional[MemoryScopes] = None
|
|
90
|
+
if isinstance(scopes_raw, dict):
|
|
91
|
+
scopes = MemoryScopes(
|
|
92
|
+
user_id=scopes_raw.get("userId") or scopes_raw.get("user_id"),
|
|
93
|
+
agent_id=scopes_raw.get("agentId") or scopes_raw.get("agent_id"),
|
|
94
|
+
session_id=scopes_raw.get("sessionId") or scopes_raw.get("session_id"),
|
|
95
|
+
app_id=scopes_raw.get("appId") or scopes_raw.get("app_id"),
|
|
96
|
+
)
|
|
97
|
+
created = data.get("createdAt") or data.get("created_at")
|
|
98
|
+
updated = data.get("updatedAt") or data.get("updated_at")
|
|
99
|
+
return cls(
|
|
100
|
+
memory_id=str(data.get("memoryId") or data.get("memory_id") or ""),
|
|
101
|
+
content=str(data.get("content") or ""),
|
|
102
|
+
memory_type=str(data.get("memoryType") or data.get("memory_type") or "fact"),
|
|
103
|
+
score=data.get("score"),
|
|
104
|
+
metadata=dict(data.get("metadata") or {}),
|
|
105
|
+
scopes=scopes,
|
|
106
|
+
created_at=str(created) if created is not None else None,
|
|
107
|
+
updated_at=str(updated) if updated is not None else None,
|
|
108
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Org-scoped agent skill registry client."""
|
|
2
|
+
|
|
3
|
+
from exemplar_core.skills.models import SkillRecord, SkillSummary
|
|
4
|
+
from exemplar_core.skills.skill_md import parse_skill_md, skill_md_template
|
|
5
|
+
from exemplar_core.skills.skills import HarnessSkills
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"HarnessSkills",
|
|
9
|
+
"SkillRecord",
|
|
10
|
+
"SkillSummary",
|
|
11
|
+
"parse_skill_md",
|
|
12
|
+
"skill_md_template",
|
|
13
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""HTTP client for /api/agent-skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from exemplar_core.exceptions import ExemplarAPIError
|
|
10
|
+
|
|
11
|
+
SKILLS_PREFIX = "/api/agent-skills"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SkillsHTTPClient:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str,
|
|
20
|
+
organization_id: Optional[str] = None,
|
|
21
|
+
timeout: float = 60.0,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._api_key = api_key
|
|
24
|
+
self._base_url = base_url.rstrip("/")
|
|
25
|
+
self._organization_id = (organization_id or "").strip() or None
|
|
26
|
+
self._timeout = timeout
|
|
27
|
+
|
|
28
|
+
def _headers(self) -> dict[str, str]:
|
|
29
|
+
headers = {
|
|
30
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
31
|
+
"X-API-Key": self._api_key,
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
}
|
|
34
|
+
if self._organization_id:
|
|
35
|
+
headers["x-organization-id"] = self._organization_id
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
def _url(self, path: str) -> str:
|
|
39
|
+
return f"{self._base_url}{SKILLS_PREFIX}{path}"
|
|
40
|
+
|
|
41
|
+
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
43
|
+
resp = client.post(self._url(path), json=body, headers=self._headers())
|
|
44
|
+
if resp.status_code >= 400:
|
|
45
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
46
|
+
if resp.status_code == 204 or not resp.content:
|
|
47
|
+
return {}
|
|
48
|
+
return resp.json()
|
|
49
|
+
|
|
50
|
+
def put(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
52
|
+
resp = client.put(self._url(path), json=body, headers=self._headers())
|
|
53
|
+
if resp.status_code >= 400:
|
|
54
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
55
|
+
if resp.status_code == 204 or not resp.content:
|
|
56
|
+
return {}
|
|
57
|
+
return resp.json()
|
|
58
|
+
|
|
59
|
+
def get(
|
|
60
|
+
self,
|
|
61
|
+
path: str,
|
|
62
|
+
*,
|
|
63
|
+
params: Optional[dict[str, Any]] = None,
|
|
64
|
+
as_text: bool = False,
|
|
65
|
+
) -> Any:
|
|
66
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
67
|
+
resp = client.get(self._url(path), headers=self._headers(), params=params)
|
|
68
|
+
if resp.status_code >= 400:
|
|
69
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
70
|
+
if resp.status_code == 204 or not resp.content:
|
|
71
|
+
return {} if not as_text else ""
|
|
72
|
+
if as_text:
|
|
73
|
+
return resp.text
|
|
74
|
+
return resp.json()
|
|
75
|
+
|
|
76
|
+
def delete(self, path: str) -> dict[str, Any]:
|
|
77
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
78
|
+
resp = client.delete(self._url(path), headers=self._headers())
|
|
79
|
+
if resp.status_code >= 400:
|
|
80
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
81
|
+
if resp.status_code == 204 or not resp.content:
|
|
82
|
+
return {}
|
|
83
|
+
return resp.json()
|
|
84
|
+
|
|
85
|
+
def get_bytes(self, path: str, *, params: Optional[dict[str, Any]] = None) -> bytes:
|
|
86
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
87
|
+
resp = client.get(self._url(path), headers=self._headers(), params=params)
|
|
88
|
+
if resp.status_code >= 400:
|
|
89
|
+
raise ExemplarAPIError(resp.status_code, resp.text[:2000])
|
|
90
|
+
return resp.content
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Agent skill registry API models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SkillRecord:
|
|
11
|
+
skill_id: str
|
|
12
|
+
name: str
|
|
13
|
+
display_name: str = ""
|
|
14
|
+
description: str = ""
|
|
15
|
+
instructions: str = ""
|
|
16
|
+
tags: list[str] = field(default_factory=list)
|
|
17
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
status: str = "active"
|
|
19
|
+
file_count: int = 0
|
|
20
|
+
created_at: Optional[str] = None
|
|
21
|
+
updated_at: Optional[str] = None
|
|
22
|
+
created_by: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_api(cls, data: dict[str, Any]) -> "SkillRecord":
|
|
26
|
+
return cls(
|
|
27
|
+
skill_id=str(data.get("skillId") or data.get("skill_id") or ""),
|
|
28
|
+
name=str(data.get("name") or ""),
|
|
29
|
+
display_name=str(data.get("displayName") or data.get("display_name") or data.get("name") or ""),
|
|
30
|
+
description=str(data.get("description") or ""),
|
|
31
|
+
instructions=str(data.get("instructions") or ""),
|
|
32
|
+
tags=list(data.get("tags") or []),
|
|
33
|
+
metadata=dict(data.get("metadata") or {}),
|
|
34
|
+
status=str(data.get("status") or "active"),
|
|
35
|
+
file_count=int(data.get("fileCount") or data.get("file_count") or 0),
|
|
36
|
+
created_at=_str_or_none(data.get("createdAt") or data.get("created_at")),
|
|
37
|
+
updated_at=_str_or_none(data.get("updatedAt") or data.get("updated_at")),
|
|
38
|
+
created_by=data.get("createdBy") or data.get("created_by"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SkillSummary:
|
|
44
|
+
skill_id: str
|
|
45
|
+
name: str
|
|
46
|
+
display_name: str = ""
|
|
47
|
+
description: str = ""
|
|
48
|
+
tags: list[str] = field(default_factory=list)
|
|
49
|
+
status: str = "active"
|
|
50
|
+
file_count: int = 0
|
|
51
|
+
updated_at: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_api(cls, data: dict[str, Any]) -> "SkillSummary":
|
|
55
|
+
return cls(
|
|
56
|
+
skill_id=str(data.get("skillId") or data.get("skill_id") or ""),
|
|
57
|
+
name=str(data.get("name") or ""),
|
|
58
|
+
display_name=str(data.get("displayName") or data.get("display_name") or data.get("name") or ""),
|
|
59
|
+
description=str(data.get("description") or ""),
|
|
60
|
+
tags=list(data.get("tags") or []),
|
|
61
|
+
status=str(data.get("status") or "active"),
|
|
62
|
+
file_count=int(data.get("fileCount") or data.get("file_count") or 0),
|
|
63
|
+
updated_at=_str_or_none(data.get("updatedAt") or data.get("updated_at")),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _str_or_none(value: Any) -> Optional[str]:
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
return str(value)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Parse and synthesize local SKILL.md files for CLI push/pull."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_skill_md(content: str) -> dict[str, Any]:
|
|
9
|
+
"""Extract name, description, and instructions from SKILL.md frontmatter."""
|
|
10
|
+
text = content or ""
|
|
11
|
+
if not text.startswith("---\n"):
|
|
12
|
+
return {"name": "", "description": "", "instructions": text.strip()}
|
|
13
|
+
|
|
14
|
+
end = text.find("\n---\n", 4)
|
|
15
|
+
if end == -1:
|
|
16
|
+
return {"name": "", "description": "", "instructions": text.strip()}
|
|
17
|
+
|
|
18
|
+
frontmatter = text[4:end]
|
|
19
|
+
body = text[end + 5 :].strip()
|
|
20
|
+
name = ""
|
|
21
|
+
description_lines: list[str] = []
|
|
22
|
+
description_mode = False
|
|
23
|
+
|
|
24
|
+
for line in frontmatter.splitlines():
|
|
25
|
+
if line.startswith("name:"):
|
|
26
|
+
name = line.split(":", 1)[1].strip()
|
|
27
|
+
elif line.startswith("description:"):
|
|
28
|
+
rest = line.split(":", 1)[1].strip()
|
|
29
|
+
if rest == ">-":
|
|
30
|
+
description_mode = True
|
|
31
|
+
elif rest:
|
|
32
|
+
description = rest
|
|
33
|
+
description_mode = False
|
|
34
|
+
else:
|
|
35
|
+
description_mode = True
|
|
36
|
+
elif description_mode:
|
|
37
|
+
description_lines.append(line.strip())
|
|
38
|
+
|
|
39
|
+
description = " ".join(description_lines).strip() if description_lines else ""
|
|
40
|
+
if not description and "description:" in frontmatter:
|
|
41
|
+
for line in frontmatter.splitlines():
|
|
42
|
+
if line.startswith("description:") and line.split(":", 1)[1].strip() not in (">-", ""):
|
|
43
|
+
description = line.split(":", 1)[1].strip()
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
return {"name": name, "description": description, "instructions": body}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def skill_md_template(*, name: str, description: str = "", instructions: str = "") -> str:
|
|
50
|
+
lines = ["---", f"name: {name}"]
|
|
51
|
+
if description:
|
|
52
|
+
lines.append(f"description: {description}")
|
|
53
|
+
lines.extend(["---", "", instructions.strip(), ""])
|
|
54
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""High-level org-scoped agent skill registry API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from exemplar_core.skills.client import SkillsHTTPClient
|
|
8
|
+
from exemplar_core.skills.models import SkillRecord, SkillSummary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HarnessSkills:
|
|
12
|
+
"""Create, list, search, and export org skills via integration-service REST."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: SkillsHTTPClient) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def create(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
name: str,
|
|
21
|
+
instructions: str,
|
|
22
|
+
description: Optional[str] = None,
|
|
23
|
+
display_name: Optional[str] = None,
|
|
24
|
+
tags: Optional[list[str]] = None,
|
|
25
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
26
|
+
files: Optional[dict[str, str]] = None,
|
|
27
|
+
) -> SkillRecord:
|
|
28
|
+
body: dict[str, Any] = {
|
|
29
|
+
"name": name.strip(),
|
|
30
|
+
"instructions": instructions.strip(),
|
|
31
|
+
"tags": tags or [],
|
|
32
|
+
"metadata": metadata or {},
|
|
33
|
+
"files": files or {},
|
|
34
|
+
}
|
|
35
|
+
if description is not None:
|
|
36
|
+
body["description"] = description
|
|
37
|
+
if display_name is not None:
|
|
38
|
+
body["displayName"] = display_name
|
|
39
|
+
data = self._client.post("/v1/skills", body)
|
|
40
|
+
return SkillRecord.from_api(data)
|
|
41
|
+
|
|
42
|
+
def list(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
skip: int = 0,
|
|
46
|
+
limit: int = 100,
|
|
47
|
+
status: str = "active",
|
|
48
|
+
tag: Optional[str] = None,
|
|
49
|
+
query: Optional[str] = None,
|
|
50
|
+
) -> list[SkillSummary]:
|
|
51
|
+
params: dict[str, Any] = {"skip": skip, "limit": limit, "status": status}
|
|
52
|
+
if tag:
|
|
53
|
+
params["tag"] = tag
|
|
54
|
+
if query:
|
|
55
|
+
params["query"] = query
|
|
56
|
+
data = self._client.get("/v1/skills", params=params)
|
|
57
|
+
return [SkillSummary.from_api(item) for item in data.get("skills") or []]
|
|
58
|
+
|
|
59
|
+
def get(self, skill_id_or_name: str) -> SkillRecord:
|
|
60
|
+
key = (skill_id_or_name or "").strip()
|
|
61
|
+
if not key:
|
|
62
|
+
raise ValueError("skill_id_or_name must be non-empty")
|
|
63
|
+
if key.startswith("skill_"):
|
|
64
|
+
data = self._client.get(f"/v1/skills/{key}")
|
|
65
|
+
return SkillRecord.from_api(data)
|
|
66
|
+
for item in self.list(limit=500):
|
|
67
|
+
if item.name == key or item.skill_id == key:
|
|
68
|
+
return self.get(item.skill_id)
|
|
69
|
+
raise ValueError(f"skill not found: {key}")
|
|
70
|
+
|
|
71
|
+
def update(
|
|
72
|
+
self,
|
|
73
|
+
skill_id: str,
|
|
74
|
+
*,
|
|
75
|
+
instructions: Optional[str] = None,
|
|
76
|
+
description: Optional[str] = None,
|
|
77
|
+
display_name: Optional[str] = None,
|
|
78
|
+
tags: Optional[list[str]] = None,
|
|
79
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
80
|
+
status: Optional[str] = None,
|
|
81
|
+
) -> SkillRecord:
|
|
82
|
+
body: dict[str, Any] = {}
|
|
83
|
+
if instructions is not None:
|
|
84
|
+
body["instructions"] = instructions
|
|
85
|
+
if description is not None:
|
|
86
|
+
body["description"] = description
|
|
87
|
+
if display_name is not None:
|
|
88
|
+
body["displayName"] = display_name
|
|
89
|
+
if tags is not None:
|
|
90
|
+
body["tags"] = tags
|
|
91
|
+
if metadata is not None:
|
|
92
|
+
body["metadata"] = metadata
|
|
93
|
+
if status is not None:
|
|
94
|
+
body["status"] = status
|
|
95
|
+
data = self._client.put(f"/v1/skills/{skill_id}", body)
|
|
96
|
+
return SkillRecord.from_api(data)
|
|
97
|
+
|
|
98
|
+
def delete(self, skill_id: str) -> None:
|
|
99
|
+
self._client.delete(f"/v1/skills/{skill_id}")
|
|
100
|
+
|
|
101
|
+
def search(
|
|
102
|
+
self,
|
|
103
|
+
query: str,
|
|
104
|
+
*,
|
|
105
|
+
tags: Optional[list[str]] = None,
|
|
106
|
+
status: str = "active",
|
|
107
|
+
top_k: int = 20,
|
|
108
|
+
) -> list[SkillSummary]:
|
|
109
|
+
body: dict[str, Any] = {
|
|
110
|
+
"query": query,
|
|
111
|
+
"tags": tags or [],
|
|
112
|
+
"status": status,
|
|
113
|
+
"topK": top_k,
|
|
114
|
+
"mode": "keyword",
|
|
115
|
+
}
|
|
116
|
+
data = self._client.post("/v1/skills/search", body)
|
|
117
|
+
return [SkillSummary.from_api(item) for item in data.get("results") or []]
|
|
118
|
+
|
|
119
|
+
def export_skill_md(self, skill_id_or_name: str) -> str:
|
|
120
|
+
record = self.get(skill_id_or_name)
|
|
121
|
+
return str(
|
|
122
|
+
self._client.get(
|
|
123
|
+
f"/v1/skills/{record.skill_id}/export",
|
|
124
|
+
params={"format": "skill-md"},
|
|
125
|
+
as_text=True,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def export_zip(self, skill_id_or_name: str) -> bytes:
|
|
130
|
+
record = self.get(skill_id_or_name)
|
|
131
|
+
return self._client.get_bytes(
|
|
132
|
+
f"/v1/skills/{record.skill_id}/export",
|
|
133
|
+
params={"format": "zip"},
|
|
134
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "exemplar-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared Exemplar REST clients for skills and memory"
|
|
5
|
+
authors = ["Exemplar Dev LLC"]
|
|
6
|
+
license = "LicenseRef-Proprietary"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "exemplar_core" }]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.10,<3.14"
|
|
12
|
+
httpx = ">=0.27.0"
|
|
13
|
+
|
|
14
|
+
[tool.poetry.group.dev.dependencies]
|
|
15
|
+
pytest = ">=8.0"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core>=1.0.0"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
pythonpath = ["."]
|