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.
@@ -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 = ["."]