hivemind-sdk 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.
- hivemind_sdk-0.1.0/PKG-INFO +99 -0
- hivemind_sdk-0.1.0/README.md +79 -0
- hivemind_sdk-0.1.0/hivemind/__init__.py +4 -0
- hivemind_sdk-0.1.0/hivemind/client.py +256 -0
- hivemind_sdk-0.1.0/hivemind/session.py +340 -0
- hivemind_sdk-0.1.0/hivemind_sdk.egg-info/PKG-INFO +99 -0
- hivemind_sdk-0.1.0/hivemind_sdk.egg-info/SOURCES.txt +10 -0
- hivemind_sdk-0.1.0/hivemind_sdk.egg-info/dependency_links.txt +1 -0
- hivemind_sdk-0.1.0/hivemind_sdk.egg-info/top_level.txt +1 -0
- hivemind_sdk-0.1.0/pyproject.toml +36 -0
- hivemind_sdk-0.1.0/setup.cfg +4 -0
- hivemind_sdk-0.1.0/tests/test_session.py +181 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivemind-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client SDK for HiveMind: persistent shared memory and a working-context compiler for agent systems.
|
|
5
|
+
Author-email: "Militant.AI" <support@militant.ai>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://hivemind.militant.ai
|
|
8
|
+
Project-URL: Documentation, https://hivemind.militant.ai/docs
|
|
9
|
+
Keywords: ai,agents,memory,llm,context,rag
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# HiveMind Python SDK
|
|
22
|
+
|
|
23
|
+
Persistent memory and a working-context compiler for your agent, in three
|
|
24
|
+
lines inside your own loop. Your model, your key — HiveMind never calls an
|
|
25
|
+
LLM.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install hivemind-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python ≥ 3.10, zero dependencies. The import name is `hivemind`.
|
|
32
|
+
Docs: https://hivemind.militant.ai/docs
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from hivemind import HiveMind
|
|
36
|
+
|
|
37
|
+
mind = HiveMind(base_url="...", api_key="...", tenant_id="...")
|
|
38
|
+
session = mind.session(budget_total=8192)
|
|
39
|
+
|
|
40
|
+
result = session.turn(user_input) # store -> recall -> compile
|
|
41
|
+
reply = call_your_llm(result.messages) # your model, your key
|
|
42
|
+
session.record(reply) # completes the exchange
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What one `turn()` does
|
|
46
|
+
|
|
47
|
+
1. Stores the user message (receipted).
|
|
48
|
+
2. Semantically recalls relevant memories and past conversation.
|
|
49
|
+
3. Compiles local history + recalled records + active holds + your
|
|
50
|
+
operator briefing into a token-budgeted bundle.
|
|
51
|
+
|
|
52
|
+
`result.messages` is the **entire** prompt payload — send it as-is, splice
|
|
53
|
+
nothing in. `result.bundle["decisions"]` explains every admission under
|
|
54
|
+
the budget; `result.receipt` is the audit record. Empty recall on a young
|
|
55
|
+
tenant is normal, not an error.
|
|
56
|
+
|
|
57
|
+
`session.record(reply)` stores your model's reply as the other half of the
|
|
58
|
+
exchange, so the next turn — and every future session — remembers it.
|
|
59
|
+
|
|
60
|
+
## Beyond the loop
|
|
61
|
+
|
|
62
|
+
- `mind.remember(content, metadata)` — deliberately store a durable
|
|
63
|
+
lesson, decision, fact, or outcome.
|
|
64
|
+
- `mind.recall(query)` / `mind.recall_filtered(query, metadata)` —
|
|
65
|
+
explicit recall, `[]` when nothing matches.
|
|
66
|
+
- `session.hold_set(key, content)` / `hold_clear(key)` — pin operational
|
|
67
|
+
state ("stop-order", "API is down") into every compile until cleared.
|
|
68
|
+
- `mind.receipts(session_id=...)` — the audit trail: what ran, what it
|
|
69
|
+
consumed, what it produced, with lineage.
|
|
70
|
+
- `mind.delete_by_metadata(metadata)` — destructive, audited deletion.
|
|
71
|
+
- `mind.client` — the raw HTTP client for anything not wrapped.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Constructor arguments override environment:
|
|
76
|
+
|
|
77
|
+
| Env var | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `HIVEMIND_BASE_URL` | Service root (hosted or local — same API) |
|
|
80
|
+
| `HIVEMIND_API_KEY` | Sent as `Authorization: Bearer <key>` |
|
|
81
|
+
| `HIVEMIND_TENANT_ID` | Your tenant (`X-Tenant-ID`) |
|
|
82
|
+
| `HIVEMIND_TIMEOUT` | Request timeout, seconds (default 30) |
|
|
83
|
+
| `HIVEMIND_BUDGET_TOTAL` | Default compile token budget (default 4096) |
|
|
84
|
+
|
|
85
|
+
## Examples
|
|
86
|
+
|
|
87
|
+
- [`examples/quickstart.py`](examples/quickstart.py) — the full loop with
|
|
88
|
+
no LLM key needed.
|
|
89
|
+
- [`examples/chat_loop.py`](examples/chat_loop.py) — a real chat with any
|
|
90
|
+
OpenAI-compatible model.
|
|
91
|
+
|
|
92
|
+
## Development note (this repo)
|
|
93
|
+
|
|
94
|
+
The import name `hivemind` collides with the service package at the repo
|
|
95
|
+
root, so run SDK tests as their own invocation:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python -m pytest sdk/python/tests
|
|
99
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# HiveMind Python SDK
|
|
2
|
+
|
|
3
|
+
Persistent memory and a working-context compiler for your agent, in three
|
|
4
|
+
lines inside your own loop. Your model, your key — HiveMind never calls an
|
|
5
|
+
LLM.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install hivemind-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python ≥ 3.10, zero dependencies. The import name is `hivemind`.
|
|
12
|
+
Docs: https://hivemind.militant.ai/docs
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from hivemind import HiveMind
|
|
16
|
+
|
|
17
|
+
mind = HiveMind(base_url="...", api_key="...", tenant_id="...")
|
|
18
|
+
session = mind.session(budget_total=8192)
|
|
19
|
+
|
|
20
|
+
result = session.turn(user_input) # store -> recall -> compile
|
|
21
|
+
reply = call_your_llm(result.messages) # your model, your key
|
|
22
|
+
session.record(reply) # completes the exchange
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What one `turn()` does
|
|
26
|
+
|
|
27
|
+
1. Stores the user message (receipted).
|
|
28
|
+
2. Semantically recalls relevant memories and past conversation.
|
|
29
|
+
3. Compiles local history + recalled records + active holds + your
|
|
30
|
+
operator briefing into a token-budgeted bundle.
|
|
31
|
+
|
|
32
|
+
`result.messages` is the **entire** prompt payload — send it as-is, splice
|
|
33
|
+
nothing in. `result.bundle["decisions"]` explains every admission under
|
|
34
|
+
the budget; `result.receipt` is the audit record. Empty recall on a young
|
|
35
|
+
tenant is normal, not an error.
|
|
36
|
+
|
|
37
|
+
`session.record(reply)` stores your model's reply as the other half of the
|
|
38
|
+
exchange, so the next turn — and every future session — remembers it.
|
|
39
|
+
|
|
40
|
+
## Beyond the loop
|
|
41
|
+
|
|
42
|
+
- `mind.remember(content, metadata)` — deliberately store a durable
|
|
43
|
+
lesson, decision, fact, or outcome.
|
|
44
|
+
- `mind.recall(query)` / `mind.recall_filtered(query, metadata)` —
|
|
45
|
+
explicit recall, `[]` when nothing matches.
|
|
46
|
+
- `session.hold_set(key, content)` / `hold_clear(key)` — pin operational
|
|
47
|
+
state ("stop-order", "API is down") into every compile until cleared.
|
|
48
|
+
- `mind.receipts(session_id=...)` — the audit trail: what ran, what it
|
|
49
|
+
consumed, what it produced, with lineage.
|
|
50
|
+
- `mind.delete_by_metadata(metadata)` — destructive, audited deletion.
|
|
51
|
+
- `mind.client` — the raw HTTP client for anything not wrapped.
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Constructor arguments override environment:
|
|
56
|
+
|
|
57
|
+
| Env var | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `HIVEMIND_BASE_URL` | Service root (hosted or local — same API) |
|
|
60
|
+
| `HIVEMIND_API_KEY` | Sent as `Authorization: Bearer <key>` |
|
|
61
|
+
| `HIVEMIND_TENANT_ID` | Your tenant (`X-Tenant-ID`) |
|
|
62
|
+
| `HIVEMIND_TIMEOUT` | Request timeout, seconds (default 30) |
|
|
63
|
+
| `HIVEMIND_BUDGET_TOTAL` | Default compile token budget (default 4096) |
|
|
64
|
+
|
|
65
|
+
## Examples
|
|
66
|
+
|
|
67
|
+
- [`examples/quickstart.py`](examples/quickstart.py) — the full loop with
|
|
68
|
+
no LLM key needed.
|
|
69
|
+
- [`examples/chat_loop.py`](examples/chat_loop.py) — a real chat with any
|
|
70
|
+
OpenAI-compatible model.
|
|
71
|
+
|
|
72
|
+
## Development note (this repo)
|
|
73
|
+
|
|
74
|
+
The import name `hivemind` collides with the service package at the repo
|
|
75
|
+
root, so run SDK tests as their own invocation:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m pytest sdk/python/tests
|
|
79
|
+
```
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Zero-dependency HTTP client for the HiveMind service API.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library. This is the transport layer the Python
|
|
4
|
+
SDK builds on.
|
|
5
|
+
|
|
6
|
+
Configuration (constructor arguments override environment):
|
|
7
|
+
|
|
8
|
+
- ``HIVEMIND_BASE_URL`` — service root, default ``http://localhost:8000``
|
|
9
|
+
- ``HIVEMIND_API_KEY`` — optional; sent as ``Authorization: Bearer <key>``
|
|
10
|
+
- ``HIVEMIND_TENANT_ID`` — optional; sent as ``X-Tenant-ID`` (required unless
|
|
11
|
+
the service resolves tenancy from auth or runs with a local tenant)
|
|
12
|
+
- ``HIVEMIND_TIMEOUT`` — request timeout in seconds, default ``30``
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
API_PREFIX = "/hivemind/v1"
|
|
25
|
+
|
|
26
|
+
# Transport signature: (request, timeout_seconds) -> (status_code, body_bytes)
|
|
27
|
+
Transport = Callable[[urllib.request.Request, float], Tuple[int, bytes]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HivemindClientError(RuntimeError):
|
|
31
|
+
"""Raised when the HiveMind service returns an error response."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, *, status: Optional[int] = None, detail: Any = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
self.detail = detail
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_transport(request: urllib.request.Request, timeout: float) -> Tuple[int, bytes]:
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 (caller controls URL)
|
|
42
|
+
return int(response.status), response.read()
|
|
43
|
+
except urllib.error.HTTPError as exc:
|
|
44
|
+
return int(exc.code), exc.read()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HivemindClient:
|
|
48
|
+
"""Thin, synchronous client over the HiveMind HTTP contract."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: Optional[str] = None,
|
|
53
|
+
*,
|
|
54
|
+
api_key: Optional[str] = None,
|
|
55
|
+
tenant_id: Optional[str] = None,
|
|
56
|
+
project: Optional[str] = None,
|
|
57
|
+
timeout: Optional[float] = None,
|
|
58
|
+
transport: Optional[Transport] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.base_url = (base_url or os.getenv("HIVEMIND_BASE_URL") or "http://localhost:8000").rstrip("/")
|
|
61
|
+
self.api_key = api_key if api_key is not None else os.getenv("HIVEMIND_API_KEY")
|
|
62
|
+
self.tenant_id = tenant_id if tenant_id is not None else os.getenv("HIVEMIND_TENANT_ID")
|
|
63
|
+
# Which project this client operates in; absent, the hive's default
|
|
64
|
+
# project serves. One hive can bind different projects to different
|
|
65
|
+
# storage (managed and BYO side by side).
|
|
66
|
+
self.project = project if project is not None else os.getenv("HIVEMIND_PROJECT")
|
|
67
|
+
self.timeout = float(timeout if timeout is not None else os.getenv("HIVEMIND_TIMEOUT") or 30)
|
|
68
|
+
self._transport = transport or _default_transport
|
|
69
|
+
|
|
70
|
+
# ── memory ────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def create_memory(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
73
|
+
return self._post("/create_memory", {"content": content, "metadata": metadata or {}})
|
|
74
|
+
|
|
75
|
+
def recall_memory(self, query: str, top_k: int = 5) -> Dict[str, Any]:
|
|
76
|
+
return self._post("/recall_memory", {"query": query, "top_k": int(top_k)})
|
|
77
|
+
|
|
78
|
+
def recall_with_metadata(
|
|
79
|
+
self, query: str, metadata: Dict[str, Any], top_k: int = 10
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
return self._post(
|
|
82
|
+
"/recall_with_metadata",
|
|
83
|
+
{"query": query, "metadata": dict(metadata or {}), "top_k": int(top_k)},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def delete_by_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
return self._post("/delete_by_metadata", {"metadata": dict(metadata or {})})
|
|
88
|
+
|
|
89
|
+
# ── conversation ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def store_conversation(
|
|
92
|
+
self,
|
|
93
|
+
content: str,
|
|
94
|
+
session_id: str,
|
|
95
|
+
turn_number: int,
|
|
96
|
+
role: str,
|
|
97
|
+
exchange_pair_id: str,
|
|
98
|
+
additional_metadata: Optional[Dict[str, Any]] = None,
|
|
99
|
+
) -> Dict[str, Any]:
|
|
100
|
+
return self._post(
|
|
101
|
+
"/conversation/store",
|
|
102
|
+
{
|
|
103
|
+
"content": content,
|
|
104
|
+
"session_id": session_id,
|
|
105
|
+
"turn_number": int(turn_number),
|
|
106
|
+
"role": role,
|
|
107
|
+
"exchange_pair_id": exchange_pair_id,
|
|
108
|
+
"additional_metadata": additional_metadata,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def recall_conversation(
|
|
113
|
+
self, query: str, session_id: Optional[str] = None, top_k: int = 10
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
return self._post(
|
|
116
|
+
"/conversation/recall",
|
|
117
|
+
{"query": query, "session_id": session_id, "top_k": int(top_k)},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ── context ───────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def compile_context(
|
|
123
|
+
self,
|
|
124
|
+
session_id: str,
|
|
125
|
+
messages: Sequence[Dict[str, Any]],
|
|
126
|
+
turn_number: int,
|
|
127
|
+
budget_total: int,
|
|
128
|
+
*,
|
|
129
|
+
user_input: Optional[str] = None,
|
|
130
|
+
operator_briefing: Optional[str] = None,
|
|
131
|
+
holds: Optional[List[Dict[str, Any]]] = None,
|
|
132
|
+
artifacts_context: Optional[str] = None,
|
|
133
|
+
recalled_conversation: Optional[List[Dict[str, Any]]] = None,
|
|
134
|
+
recalled_memories: Optional[List[Dict[str, Any]]] = None,
|
|
135
|
+
policy: Optional[Dict[str, Any]] = None,
|
|
136
|
+
config: Optional[Dict[str, Any]] = None,
|
|
137
|
+
event_id: Optional[str] = None,
|
|
138
|
+
trace_id: Optional[str] = None,
|
|
139
|
+
timestamp: Optional[float] = None,
|
|
140
|
+
) -> Dict[str, Any]:
|
|
141
|
+
payload: Dict[str, Any] = {
|
|
142
|
+
"session_id": session_id,
|
|
143
|
+
"messages": list(messages),
|
|
144
|
+
"turn_number": int(turn_number),
|
|
145
|
+
"budget_total": int(budget_total),
|
|
146
|
+
"user_input": user_input,
|
|
147
|
+
"operator_briefing": operator_briefing,
|
|
148
|
+
"holds": holds,
|
|
149
|
+
"artifacts_context": artifacts_context,
|
|
150
|
+
"recalled_conversation": recalled_conversation,
|
|
151
|
+
"recalled_memories": recalled_memories,
|
|
152
|
+
"policy": policy,
|
|
153
|
+
"config": config,
|
|
154
|
+
"event_id": event_id,
|
|
155
|
+
"trace_id": trace_id,
|
|
156
|
+
"timestamp": timestamp,
|
|
157
|
+
}
|
|
158
|
+
return self._post("/context/compile", payload)
|
|
159
|
+
|
|
160
|
+
# ── receipts ──────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def list_receipts(
|
|
163
|
+
self,
|
|
164
|
+
limit: int = 50,
|
|
165
|
+
session_id: Optional[str] = None,
|
|
166
|
+
operation: Optional[str] = None,
|
|
167
|
+
) -> Dict[str, Any]:
|
|
168
|
+
params: Dict[str, Any] = {"limit": int(limit)}
|
|
169
|
+
if session_id:
|
|
170
|
+
params["session_id"] = session_id
|
|
171
|
+
if operation:
|
|
172
|
+
params["operation"] = operation
|
|
173
|
+
return self._get("/receipts", params)
|
|
174
|
+
|
|
175
|
+
# ── export ────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def export(self, page_size: int = 200):
|
|
178
|
+
"""Iterate every record the tenant owns: memories and conversations,
|
|
179
|
+
then receipts (including legacy). Yields dicts of
|
|
180
|
+
{id, content, metadata, phase}. Follows pagination to completion."""
|
|
181
|
+
cursor: Optional[str] = None
|
|
182
|
+
while True:
|
|
183
|
+
params: Dict[str, Any] = {"limit": int(page_size)}
|
|
184
|
+
if cursor:
|
|
185
|
+
params["cursor"] = cursor
|
|
186
|
+
envelope = self._get("/export", params)
|
|
187
|
+
data = envelope.get("data") or {}
|
|
188
|
+
phase = data.get("phase")
|
|
189
|
+
for record in data.get("records") or []:
|
|
190
|
+
yield {**record, "phase": phase}
|
|
191
|
+
if data.get("done") or not data.get("next_cursor"):
|
|
192
|
+
return
|
|
193
|
+
cursor = data["next_cursor"]
|
|
194
|
+
|
|
195
|
+
def export_to_file(self, path: str, page_size: int = 200) -> int:
|
|
196
|
+
"""Write the full export to a JSONL file. Returns the record count."""
|
|
197
|
+
count = 0
|
|
198
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
199
|
+
for record in self.export(page_size=page_size):
|
|
200
|
+
handle.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
|
201
|
+
count += 1
|
|
202
|
+
return count
|
|
203
|
+
|
|
204
|
+
# ── transport plumbing ────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def _headers(self) -> Dict[str, str]:
|
|
207
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
208
|
+
if self.api_key:
|
|
209
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
210
|
+
if self.tenant_id:
|
|
211
|
+
headers["X-Tenant-ID"] = str(self.tenant_id)
|
|
212
|
+
if self.project:
|
|
213
|
+
headers["X-Hivemind-Project"] = str(self.project)
|
|
214
|
+
return headers
|
|
215
|
+
|
|
216
|
+
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
217
|
+
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
|
218
|
+
request = urllib.request.Request( # noqa: S310
|
|
219
|
+
f"{self.base_url}{API_PREFIX}{path}",
|
|
220
|
+
data=body,
|
|
221
|
+
headers=self._headers(),
|
|
222
|
+
method="POST",
|
|
223
|
+
)
|
|
224
|
+
return self._execute(request)
|
|
225
|
+
|
|
226
|
+
def _get(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
227
|
+
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
|
228
|
+
url = f"{self.base_url}{API_PREFIX}{path}"
|
|
229
|
+
if query:
|
|
230
|
+
url = f"{url}?{query}"
|
|
231
|
+
request = urllib.request.Request(url, headers=self._headers(), method="GET") # noqa: S310
|
|
232
|
+
return self._execute(request)
|
|
233
|
+
|
|
234
|
+
def _execute(self, request: urllib.request.Request) -> Dict[str, Any]:
|
|
235
|
+
try:
|
|
236
|
+
status, raw = self._transport(request, self.timeout)
|
|
237
|
+
except urllib.error.URLError as exc:
|
|
238
|
+
raise HivemindClientError(
|
|
239
|
+
f"HiveMind service unreachable at {self.base_url}: {exc.reason}"
|
|
240
|
+
) from exc
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
parsed = json.loads(raw.decode("utf-8")) if raw else {}
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
raise HivemindClientError(
|
|
246
|
+
f"HiveMind returned non-JSON response (HTTP {status}).", status=status
|
|
247
|
+
) from exc
|
|
248
|
+
|
|
249
|
+
if status >= 400 or (isinstance(parsed, dict) and parsed.get("ok") is False):
|
|
250
|
+
detail = parsed.get("detail") if isinstance(parsed, dict) else parsed
|
|
251
|
+
raise HivemindClientError(
|
|
252
|
+
f"HiveMind request failed (HTTP {status}): {json.dumps(detail, default=str)[:500]}",
|
|
253
|
+
status=status,
|
|
254
|
+
detail=detail,
|
|
255
|
+
)
|
|
256
|
+
return parsed if isinstance(parsed, dict) else {"ok": True, "data": parsed}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Turn-loop session layer for the HiveMind SDK.
|
|
2
|
+
|
|
3
|
+
The integration contract, inside the customer's own agent loop:
|
|
4
|
+
|
|
5
|
+
from hivemind import HiveMind
|
|
6
|
+
|
|
7
|
+
mind = HiveMind(base_url="...", api_key="...", tenant_id="...")
|
|
8
|
+
session = mind.session(budget_total=8192)
|
|
9
|
+
|
|
10
|
+
result = session.turn(user_input) # store -> recall -> compile
|
|
11
|
+
reply = call_your_llm(result.messages) # your model, your key
|
|
12
|
+
session.record(reply) # completes the exchange
|
|
13
|
+
|
|
14
|
+
``result.messages`` is the ENTIRE prompt payload: send it to your model
|
|
15
|
+
as-is and do not splice anything else in. Every step is receipted
|
|
16
|
+
server-side; ``result.receipt`` is the audit record for the compile and
|
|
17
|
+
``result.bundle["decisions"]`` explains every admission under the token
|
|
18
|
+
budget.
|
|
19
|
+
|
|
20
|
+
One ``turn()`` call performs, in order:
|
|
21
|
+
|
|
22
|
+
1. stores the user message as the user half of a conversation exchange
|
|
23
|
+
2. semantically recalls relevant memories and past conversation
|
|
24
|
+
3. compiles local history + recalled records + active holds + operator
|
|
25
|
+
briefing into a budgeted working-context bundle
|
|
26
|
+
|
|
27
|
+
``record(reply)`` stores the assistant half of the exchange and keeps the
|
|
28
|
+
local history that feeds the next compile.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import uuid
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
from hivemind.client import HivemindClient, HivemindClientError
|
|
39
|
+
|
|
40
|
+
DEFAULT_BUDGET_TOTAL = 4096
|
|
41
|
+
DEFAULT_RECALL_TOP_K = 5
|
|
42
|
+
DEFAULT_LOCAL_HISTORY = 40
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _records(envelope: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
46
|
+
data = envelope.get("data") or {}
|
|
47
|
+
for key in ("memories", "exchanges", "results"):
|
|
48
|
+
value = data.get(key)
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return value
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TurnResult:
|
|
56
|
+
"""What one turn() call returns.
|
|
57
|
+
|
|
58
|
+
``messages`` is the compiled prompt payload — the only field to send
|
|
59
|
+
to the model. Everything else is audit and diagnostics.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
messages: List[Dict[str, Any]]
|
|
63
|
+
bundle: Dict[str, Any]
|
|
64
|
+
receipt: Dict[str, Any]
|
|
65
|
+
session_id: str
|
|
66
|
+
turn_number: int
|
|
67
|
+
exchange_pair_id: str
|
|
68
|
+
recalled_memories: int
|
|
69
|
+
recalled_conversation: int
|
|
70
|
+
warnings: List[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def token_count(self) -> Optional[int]:
|
|
74
|
+
return self.bundle.get("token_count")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def budget_total(self) -> Optional[int]:
|
|
78
|
+
return self.bundle.get("budget_total")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def decisions(self) -> List[Dict[str, Any]]:
|
|
82
|
+
return list(self.bundle.get("decisions") or [])
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def receipt_id(self) -> Optional[str]:
|
|
86
|
+
return self.receipt.get("receipt_id") or self.receipt.get("id")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Session:
|
|
90
|
+
"""One conversation with memory maintained for you.
|
|
91
|
+
|
|
92
|
+
Not thread-safe; use one Session per concurrent conversation.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
client: HivemindClient,
|
|
98
|
+
*,
|
|
99
|
+
session_id: Optional[str] = None,
|
|
100
|
+
budget_total: int = DEFAULT_BUDGET_TOTAL,
|
|
101
|
+
operator_briefing: Optional[str] = None,
|
|
102
|
+
recall_top_k: int = DEFAULT_RECALL_TOP_K,
|
|
103
|
+
local_history_limit: int = DEFAULT_LOCAL_HISTORY,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._client = client
|
|
106
|
+
self.session_id = (session_id or "").strip() or f"sdk-{uuid.uuid4().hex[:12]}"
|
|
107
|
+
self.budget_total = int(budget_total)
|
|
108
|
+
self.operator_briefing = operator_briefing
|
|
109
|
+
self.recall_top_k = int(recall_top_k)
|
|
110
|
+
self.local_history_limit = int(local_history_limit)
|
|
111
|
+
|
|
112
|
+
self._turn = 0
|
|
113
|
+
self._history: List[Dict[str, str]] = []
|
|
114
|
+
self._open_exchange: Optional[Dict[str, Any]] = None
|
|
115
|
+
self._holds: Dict[str, Dict[str, Any]] = {}
|
|
116
|
+
|
|
117
|
+
# ── the loop ──────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def turn(
|
|
120
|
+
self,
|
|
121
|
+
user_input: str,
|
|
122
|
+
*,
|
|
123
|
+
budget_total: Optional[int] = None,
|
|
124
|
+
operator_briefing: Optional[str] = None,
|
|
125
|
+
artifacts_context: Optional[str] = None,
|
|
126
|
+
policy: Optional[Dict[str, Any]] = None,
|
|
127
|
+
) -> TurnResult:
|
|
128
|
+
"""Run store -> recall -> compile for one turn.
|
|
129
|
+
|
|
130
|
+
Returns a TurnResult whose ``messages`` are the entire prompt for
|
|
131
|
+
your model call. Recall failures degrade to empty recall (noted in
|
|
132
|
+
``warnings``); a compile failure raises HivemindClientError.
|
|
133
|
+
"""
|
|
134
|
+
warnings: List[str] = []
|
|
135
|
+
self._turn += 1
|
|
136
|
+
turn_number = self._turn
|
|
137
|
+
exchange_id = f"xp-{uuid.uuid4().hex[:8]}"
|
|
138
|
+
|
|
139
|
+
# 1. Capture the user half of this exchange.
|
|
140
|
+
try:
|
|
141
|
+
self._client.store_conversation(
|
|
142
|
+
user_input, self.session_id, turn_number, "user", exchange_id
|
|
143
|
+
)
|
|
144
|
+
self._open_exchange = {"exchange_id": exchange_id, "turn": turn_number}
|
|
145
|
+
except HivemindClientError as exc:
|
|
146
|
+
warnings.append(f"user input not stored: {exc}")
|
|
147
|
+
|
|
148
|
+
# 2. Recall both lanes. 404 means "nothing stored yet", not failure.
|
|
149
|
+
recalled_memories = self._safe_recall(
|
|
150
|
+
lambda: self._client.recall_memory(user_input, self.recall_top_k),
|
|
151
|
+
warnings,
|
|
152
|
+
"memory recall",
|
|
153
|
+
)
|
|
154
|
+
recalled_conversation = self._safe_recall(
|
|
155
|
+
lambda: self._client.recall_conversation(user_input, None, self.recall_top_k),
|
|
156
|
+
warnings,
|
|
157
|
+
"conversation recall",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 3. Compile local history + recall + holds under the budget.
|
|
161
|
+
current = {"role": "user", "content": user_input}
|
|
162
|
+
envelope = self._client.compile_context(
|
|
163
|
+
self.session_id,
|
|
164
|
+
[*self._history, current],
|
|
165
|
+
turn_number,
|
|
166
|
+
int(budget_total or self.budget_total),
|
|
167
|
+
user_input=user_input,
|
|
168
|
+
operator_briefing=operator_briefing or self.operator_briefing,
|
|
169
|
+
holds=self.holds() or None,
|
|
170
|
+
artifacts_context=artifacts_context,
|
|
171
|
+
recalled_conversation=recalled_conversation or None,
|
|
172
|
+
recalled_memories=recalled_memories or None,
|
|
173
|
+
policy=policy,
|
|
174
|
+
)
|
|
175
|
+
data = envelope.get("data") or {}
|
|
176
|
+
bundle = data.get("bundle") or {}
|
|
177
|
+
|
|
178
|
+
self._append_history(current)
|
|
179
|
+
return TurnResult(
|
|
180
|
+
messages=list(bundle.get("messages") or []),
|
|
181
|
+
bundle=bundle,
|
|
182
|
+
receipt=data.get("receipt") or {},
|
|
183
|
+
session_id=self.session_id,
|
|
184
|
+
turn_number=turn_number,
|
|
185
|
+
exchange_pair_id=exchange_id,
|
|
186
|
+
recalled_memories=len(recalled_memories),
|
|
187
|
+
recalled_conversation=len(recalled_conversation),
|
|
188
|
+
warnings=warnings,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def record(self, assistant_reply: str) -> Dict[str, Any]:
|
|
192
|
+
"""Store the assistant half of the current exchange.
|
|
193
|
+
|
|
194
|
+
Call once per turn, after your model call. Uses the exchange the
|
|
195
|
+
last turn() opened; if none is open, stores under a fresh exchange
|
|
196
|
+
at the current turn.
|
|
197
|
+
"""
|
|
198
|
+
open_exchange = self._open_exchange
|
|
199
|
+
self._open_exchange = None
|
|
200
|
+
turn_number = open_exchange["turn"] if open_exchange else max(1, self._turn)
|
|
201
|
+
exchange_id = (
|
|
202
|
+
open_exchange["exchange_id"] if open_exchange else f"xp-{uuid.uuid4().hex[:8]}"
|
|
203
|
+
)
|
|
204
|
+
result = self._client.store_conversation(
|
|
205
|
+
assistant_reply, self.session_id, turn_number, "assistant", exchange_id
|
|
206
|
+
)
|
|
207
|
+
self._append_history({"role": "assistant", "content": assistant_reply})
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
# ── holds (pins injected into every compile) ─────────────────────────
|
|
211
|
+
|
|
212
|
+
def hold_set(self, key: str, content: str, title: Optional[str] = None) -> None:
|
|
213
|
+
key = str(key).strip()
|
|
214
|
+
if not key:
|
|
215
|
+
raise ValueError("hold key must be non-empty")
|
|
216
|
+
self._holds[key] = {
|
|
217
|
+
"id": key,
|
|
218
|
+
"title": (title or key).strip(),
|
|
219
|
+
"content": content,
|
|
220
|
+
"held_since": max(1, self._turn),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def hold_clear(self, key: str) -> bool:
|
|
224
|
+
return self._holds.pop(str(key).strip(), None) is not None
|
|
225
|
+
|
|
226
|
+
def holds(self) -> List[Dict[str, Any]]:
|
|
227
|
+
return [dict(value) for value in self._holds.values()]
|
|
228
|
+
|
|
229
|
+
# ── plumbing ──────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def _append_history(self, message: Dict[str, str]) -> None:
|
|
232
|
+
self._history.append(message)
|
|
233
|
+
if len(self._history) > self.local_history_limit:
|
|
234
|
+
del self._history[: len(self._history) - self.local_history_limit]
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _safe_recall(
|
|
238
|
+
call: Callable[[], Dict[str, Any]],
|
|
239
|
+
warnings: List[str],
|
|
240
|
+
label: str,
|
|
241
|
+
) -> List[Dict[str, Any]]:
|
|
242
|
+
try:
|
|
243
|
+
return _records(call())
|
|
244
|
+
except HivemindClientError as exc:
|
|
245
|
+
if exc.status == 404:
|
|
246
|
+
return []
|
|
247
|
+
warnings.append(f"{label} failed: {exc}")
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class HiveMind:
|
|
252
|
+
"""Entry point. Holds credentials; makes sessions and deliberate calls.
|
|
253
|
+
|
|
254
|
+
Constructor arguments override environment (HIVEMIND_BASE_URL,
|
|
255
|
+
HIVEMIND_API_KEY, HIVEMIND_TENANT_ID, HIVEMIND_TIMEOUT). The same code
|
|
256
|
+
works against the hosted service and a local one — only base_url
|
|
257
|
+
changes.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
base_url: Optional[str] = None,
|
|
263
|
+
*,
|
|
264
|
+
api_key: Optional[str] = None,
|
|
265
|
+
tenant_id: Optional[str] = None,
|
|
266
|
+
timeout: Optional[float] = None,
|
|
267
|
+
client: Optional[HivemindClient] = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
self._client = client or HivemindClient(
|
|
270
|
+
base_url, api_key=api_key, tenant_id=tenant_id, timeout=timeout
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def session(
|
|
274
|
+
self,
|
|
275
|
+
session_id: Optional[str] = None,
|
|
276
|
+
*,
|
|
277
|
+
budget_total: Optional[int] = None,
|
|
278
|
+
operator_briefing: Optional[str] = None,
|
|
279
|
+
recall_top_k: int = DEFAULT_RECALL_TOP_K,
|
|
280
|
+
) -> Session:
|
|
281
|
+
env_budget = os.getenv("HIVEMIND_BUDGET_TOTAL")
|
|
282
|
+
resolved_budget = (
|
|
283
|
+
int(budget_total)
|
|
284
|
+
if budget_total is not None
|
|
285
|
+
else int(env_budget) if env_budget else DEFAULT_BUDGET_TOTAL
|
|
286
|
+
)
|
|
287
|
+
return Session(
|
|
288
|
+
self._client,
|
|
289
|
+
session_id=session_id,
|
|
290
|
+
budget_total=resolved_budget,
|
|
291
|
+
operator_briefing=operator_briefing,
|
|
292
|
+
recall_top_k=recall_top_k,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# ── deliberate memory (outside the turn loop) ─────────────────────────
|
|
296
|
+
|
|
297
|
+
def remember(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
298
|
+
"""Store a durable memory: a decision, lesson, fact, or outcome."""
|
|
299
|
+
return self._client.create_memory(content, metadata)
|
|
300
|
+
|
|
301
|
+
def recall(self, query: str, top_k: int = DEFAULT_RECALL_TOP_K) -> List[Dict[str, Any]]:
|
|
302
|
+
"""Semantic recall. Returns [] when nothing matches."""
|
|
303
|
+
try:
|
|
304
|
+
return _records(self._client.recall_memory(query, top_k))
|
|
305
|
+
except HivemindClientError as exc:
|
|
306
|
+
if exc.status == 404:
|
|
307
|
+
return []
|
|
308
|
+
raise
|
|
309
|
+
|
|
310
|
+
def recall_filtered(
|
|
311
|
+
self, query: str, metadata: Dict[str, Any], top_k: int = 10
|
|
312
|
+
) -> List[Dict[str, Any]]:
|
|
313
|
+
"""Recall constrained to exact metadata matches. Returns [] when empty."""
|
|
314
|
+
try:
|
|
315
|
+
return _records(self._client.recall_with_metadata(query, metadata, top_k))
|
|
316
|
+
except HivemindClientError as exc:
|
|
317
|
+
if exc.status == 404:
|
|
318
|
+
return []
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
def receipts(
|
|
322
|
+
self,
|
|
323
|
+
limit: int = 50,
|
|
324
|
+
session_id: Optional[str] = None,
|
|
325
|
+
operation: Optional[str] = None,
|
|
326
|
+
) -> List[Dict[str, Any]]:
|
|
327
|
+
"""The audit trail: what ran, what it consumed, what it produced."""
|
|
328
|
+
envelope = self._client.list_receipts(limit, session_id, operation)
|
|
329
|
+
data = envelope.get("data") or {}
|
|
330
|
+
receipts = data.get("receipts") or data.get("results") or []
|
|
331
|
+
return list(receipts) if isinstance(receipts, list) else []
|
|
332
|
+
|
|
333
|
+
def delete_by_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
334
|
+
"""DESTRUCTIVE, audited: soft-delete memories matching all keys."""
|
|
335
|
+
return self._client.delete_by_metadata(metadata)
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def client(self) -> HivemindClient:
|
|
339
|
+
"""The raw HTTP client, for anything the SDK doesn't wrap."""
|
|
340
|
+
return self._client
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivemind-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client SDK for HiveMind: persistent shared memory and a working-context compiler for agent systems.
|
|
5
|
+
Author-email: "Militant.AI" <support@militant.ai>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://hivemind.militant.ai
|
|
8
|
+
Project-URL: Documentation, https://hivemind.militant.ai/docs
|
|
9
|
+
Keywords: ai,agents,memory,llm,context,rag
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# HiveMind Python SDK
|
|
22
|
+
|
|
23
|
+
Persistent memory and a working-context compiler for your agent, in three
|
|
24
|
+
lines inside your own loop. Your model, your key — HiveMind never calls an
|
|
25
|
+
LLM.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install hivemind-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python ≥ 3.10, zero dependencies. The import name is `hivemind`.
|
|
32
|
+
Docs: https://hivemind.militant.ai/docs
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from hivemind import HiveMind
|
|
36
|
+
|
|
37
|
+
mind = HiveMind(base_url="...", api_key="...", tenant_id="...")
|
|
38
|
+
session = mind.session(budget_total=8192)
|
|
39
|
+
|
|
40
|
+
result = session.turn(user_input) # store -> recall -> compile
|
|
41
|
+
reply = call_your_llm(result.messages) # your model, your key
|
|
42
|
+
session.record(reply) # completes the exchange
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What one `turn()` does
|
|
46
|
+
|
|
47
|
+
1. Stores the user message (receipted).
|
|
48
|
+
2. Semantically recalls relevant memories and past conversation.
|
|
49
|
+
3. Compiles local history + recalled records + active holds + your
|
|
50
|
+
operator briefing into a token-budgeted bundle.
|
|
51
|
+
|
|
52
|
+
`result.messages` is the **entire** prompt payload — send it as-is, splice
|
|
53
|
+
nothing in. `result.bundle["decisions"]` explains every admission under
|
|
54
|
+
the budget; `result.receipt` is the audit record. Empty recall on a young
|
|
55
|
+
tenant is normal, not an error.
|
|
56
|
+
|
|
57
|
+
`session.record(reply)` stores your model's reply as the other half of the
|
|
58
|
+
exchange, so the next turn — and every future session — remembers it.
|
|
59
|
+
|
|
60
|
+
## Beyond the loop
|
|
61
|
+
|
|
62
|
+
- `mind.remember(content, metadata)` — deliberately store a durable
|
|
63
|
+
lesson, decision, fact, or outcome.
|
|
64
|
+
- `mind.recall(query)` / `mind.recall_filtered(query, metadata)` —
|
|
65
|
+
explicit recall, `[]` when nothing matches.
|
|
66
|
+
- `session.hold_set(key, content)` / `hold_clear(key)` — pin operational
|
|
67
|
+
state ("stop-order", "API is down") into every compile until cleared.
|
|
68
|
+
- `mind.receipts(session_id=...)` — the audit trail: what ran, what it
|
|
69
|
+
consumed, what it produced, with lineage.
|
|
70
|
+
- `mind.delete_by_metadata(metadata)` — destructive, audited deletion.
|
|
71
|
+
- `mind.client` — the raw HTTP client for anything not wrapped.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Constructor arguments override environment:
|
|
76
|
+
|
|
77
|
+
| Env var | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `HIVEMIND_BASE_URL` | Service root (hosted or local — same API) |
|
|
80
|
+
| `HIVEMIND_API_KEY` | Sent as `Authorization: Bearer <key>` |
|
|
81
|
+
| `HIVEMIND_TENANT_ID` | Your tenant (`X-Tenant-ID`) |
|
|
82
|
+
| `HIVEMIND_TIMEOUT` | Request timeout, seconds (default 30) |
|
|
83
|
+
| `HIVEMIND_BUDGET_TOTAL` | Default compile token budget (default 4096) |
|
|
84
|
+
|
|
85
|
+
## Examples
|
|
86
|
+
|
|
87
|
+
- [`examples/quickstart.py`](examples/quickstart.py) — the full loop with
|
|
88
|
+
no LLM key needed.
|
|
89
|
+
- [`examples/chat_loop.py`](examples/chat_loop.py) — a real chat with any
|
|
90
|
+
OpenAI-compatible model.
|
|
91
|
+
|
|
92
|
+
## Development note (this repo)
|
|
93
|
+
|
|
94
|
+
The import name `hivemind` collides with the service package at the repo
|
|
95
|
+
root, so run SDK tests as their own invocation:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python -m pytest sdk/python/tests
|
|
99
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
hivemind/__init__.py
|
|
4
|
+
hivemind/client.py
|
|
5
|
+
hivemind/session.py
|
|
6
|
+
hivemind_sdk.egg-info/PKG-INFO
|
|
7
|
+
hivemind_sdk.egg-info/SOURCES.txt
|
|
8
|
+
hivemind_sdk.egg-info/dependency_links.txt
|
|
9
|
+
hivemind_sdk.egg-info/top_level.txt
|
|
10
|
+
tests/test_session.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hivemind
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution name: "hivemind" on PyPI belongs to an unrelated distributed
|
|
7
|
+
# deep-learning library, so the package installs as hivemind-sdk. The
|
|
8
|
+
# import name stays `hivemind` (which also means our SDK and that library
|
|
9
|
+
# cannot coexist in one environment — acceptable, non-overlapping audience).
|
|
10
|
+
name = "hivemind-sdk"
|
|
11
|
+
version = "0.1.0"
|
|
12
|
+
description = "Client SDK for HiveMind: persistent shared memory and a working-context compiler for agent systems."
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
license = { text = "Proprietary" }
|
|
16
|
+
authors = [{ name = "Militant.AI", email = "support@militant.ai" }]
|
|
17
|
+
keywords = ["ai", "agents", "memory", "llm", "context", "rag"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
27
|
+
]
|
|
28
|
+
# No runtime dependencies: the client is pure standard library.
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://hivemind.militant.ai"
|
|
33
|
+
Documentation = "https://hivemind.militant.ai/docs"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
packages = ["hivemind"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hivemind.client import HivemindClientError
|
|
8
|
+
from hivemind.session import DEFAULT_BUDGET_TOTAL, HiveMind, Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeClient:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.stores: List[Dict[str, Any]] = []
|
|
14
|
+
self.compiles: List[Dict[str, Any]] = []
|
|
15
|
+
self.memory_error: Optional[Exception] = None
|
|
16
|
+
self.conversation_error: Optional[Exception] = None
|
|
17
|
+
self.compile_error: Optional[Exception] = None
|
|
18
|
+
|
|
19
|
+
def store_conversation(
|
|
20
|
+
self,
|
|
21
|
+
content: str,
|
|
22
|
+
session_id: str,
|
|
23
|
+
turn_number: int,
|
|
24
|
+
role: str,
|
|
25
|
+
exchange_pair_id: str,
|
|
26
|
+
additional_metadata: Optional[Dict[str, Any]] = None,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
self.stores.append(
|
|
29
|
+
{
|
|
30
|
+
"content": content,
|
|
31
|
+
"session_id": session_id,
|
|
32
|
+
"turn_number": turn_number,
|
|
33
|
+
"role": role,
|
|
34
|
+
"exchange_pair_id": exchange_pair_id,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return {"ok": True, "data": {"id": f"conv-{len(self.stores)}", "receipt_id": "r"}}
|
|
38
|
+
|
|
39
|
+
def recall_memory(self, query: str, top_k: int = 5) -> Dict[str, Any]:
|
|
40
|
+
if self.memory_error is not None:
|
|
41
|
+
raise self.memory_error
|
|
42
|
+
return {"ok": True, "data": {"memories": [{"id": "m1", "content": "a lesson"}]}}
|
|
43
|
+
|
|
44
|
+
def recall_conversation(
|
|
45
|
+
self, query: str, session_id: Optional[str] = None, top_k: int = 10
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
if self.conversation_error is not None:
|
|
48
|
+
raise self.conversation_error
|
|
49
|
+
return {"ok": True, "data": {"exchanges": [{"id": "c1", "content": "earlier"}]}}
|
|
50
|
+
|
|
51
|
+
def compile_context(
|
|
52
|
+
self,
|
|
53
|
+
session_id: str,
|
|
54
|
+
messages,
|
|
55
|
+
turn_number: int,
|
|
56
|
+
budget_total: int,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> Dict[str, Any]:
|
|
59
|
+
if self.compile_error is not None:
|
|
60
|
+
raise self.compile_error
|
|
61
|
+
self.compiles.append(
|
|
62
|
+
{
|
|
63
|
+
"session_id": session_id,
|
|
64
|
+
"messages": list(messages),
|
|
65
|
+
"turn_number": turn_number,
|
|
66
|
+
"budget_total": budget_total,
|
|
67
|
+
"holds": kwargs.get("holds"),
|
|
68
|
+
"recalled_memories": kwargs.get("recalled_memories"),
|
|
69
|
+
"recalled_conversation": kwargs.get("recalled_conversation"),
|
|
70
|
+
"operator_briefing": kwargs.get("operator_briefing"),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return {
|
|
74
|
+
"ok": True,
|
|
75
|
+
"data": {
|
|
76
|
+
"bundle": {
|
|
77
|
+
"messages": [{"role": "system", "content": "compiled"}],
|
|
78
|
+
"token_count": 42,
|
|
79
|
+
"budget_total": budget_total,
|
|
80
|
+
"decisions": [{"action": "included"}],
|
|
81
|
+
},
|
|
82
|
+
"receipt": {"receipt_id": "rcpt-compile"},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture()
|
|
88
|
+
def fake() -> FakeClient:
|
|
89
|
+
return FakeClient()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture()
|
|
93
|
+
def session(fake: FakeClient) -> Session:
|
|
94
|
+
return Session(fake, session_id="s1")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_turn_stores_recalls_and_compiles(fake: FakeClient, session: Session) -> None:
|
|
98
|
+
result = session.turn("what do we know?")
|
|
99
|
+
|
|
100
|
+
assert fake.stores[0]["role"] == "user"
|
|
101
|
+
assert fake.stores[0]["turn_number"] == 1
|
|
102
|
+
compile_call = fake.compiles[0]
|
|
103
|
+
assert compile_call["messages"] == [{"role": "user", "content": "what do we know?"}]
|
|
104
|
+
assert compile_call["recalled_memories"] == [{"id": "m1", "content": "a lesson"}]
|
|
105
|
+
assert compile_call["recalled_conversation"] == [{"id": "c1", "content": "earlier"}]
|
|
106
|
+
assert compile_call["budget_total"] == DEFAULT_BUDGET_TOTAL
|
|
107
|
+
|
|
108
|
+
assert result.messages == [{"role": "system", "content": "compiled"}]
|
|
109
|
+
assert result.token_count == 42
|
|
110
|
+
assert result.receipt_id == "rcpt-compile"
|
|
111
|
+
assert result.turn_number == 1
|
|
112
|
+
assert result.recalled_memories == 1
|
|
113
|
+
assert result.warnings == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_record_completes_the_exchange_and_feeds_next_compile(
|
|
117
|
+
fake: FakeClient, session: Session
|
|
118
|
+
) -> None:
|
|
119
|
+
first = session.turn("hello")
|
|
120
|
+
session.record("hi, I remember you")
|
|
121
|
+
session.turn("second question")
|
|
122
|
+
|
|
123
|
+
roles = [(s["role"], s["turn_number"], s["exchange_pair_id"]) for s in fake.stores]
|
|
124
|
+
assert roles[0] == ("user", 1, first.exchange_pair_id)
|
|
125
|
+
assert roles[1] == ("assistant", 1, first.exchange_pair_id)
|
|
126
|
+
assert roles[2][0] == "user" and roles[2][1] == 2
|
|
127
|
+
|
|
128
|
+
# Local history flows into the next compile.
|
|
129
|
+
assert fake.compiles[1]["messages"] == [
|
|
130
|
+
{"role": "user", "content": "hello"},
|
|
131
|
+
{"role": "assistant", "content": "hi, I remember you"},
|
|
132
|
+
{"role": "user", "content": "second question"},
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_holds_are_injected_until_cleared(fake: FakeClient, session: Session) -> None:
|
|
137
|
+
session.hold_set("stop-order", "Do not deploy until QA signs off.")
|
|
138
|
+
session.turn("status?")
|
|
139
|
+
assert fake.compiles[0]["holds"][0]["id"] == "stop-order"
|
|
140
|
+
|
|
141
|
+
assert session.hold_clear("stop-order") is True
|
|
142
|
+
session.turn("and now?")
|
|
143
|
+
assert fake.compiles[1]["holds"] is None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_empty_recall_is_not_an_error(fake: FakeClient, session: Session) -> None:
|
|
147
|
+
fake.memory_error = HivemindClientError("nothing", status=404)
|
|
148
|
+
fake.conversation_error = HivemindClientError("nothing", status=404)
|
|
149
|
+
|
|
150
|
+
result = session.turn("first ever message")
|
|
151
|
+
|
|
152
|
+
assert result.warnings == []
|
|
153
|
+
assert result.recalled_memories == 0
|
|
154
|
+
assert fake.compiles[0]["recalled_memories"] is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_recall_outage_degrades_with_warning(fake: FakeClient, session: Session) -> None:
|
|
158
|
+
fake.memory_error = HivemindClientError("boom", status=500)
|
|
159
|
+
|
|
160
|
+
result = session.turn("hello")
|
|
161
|
+
|
|
162
|
+
assert len(result.warnings) == 1
|
|
163
|
+
assert "memory recall failed" in result.warnings[0]
|
|
164
|
+
assert fake.compiles # compile still ran
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_compile_failure_raises(fake: FakeClient, session: Session) -> None:
|
|
168
|
+
fake.compile_error = HivemindClientError("compiler down", status=500)
|
|
169
|
+
with pytest.raises(HivemindClientError):
|
|
170
|
+
session.turn("hello")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_hivemind_entrypoint_builds_sessions_and_passthroughs(fake: FakeClient) -> None:
|
|
174
|
+
mind = HiveMind(client=fake)
|
|
175
|
+
session = mind.session("named-session", budget_total=1024)
|
|
176
|
+
|
|
177
|
+
assert session.session_id == "named-session"
|
|
178
|
+
assert session.budget_total == 1024
|
|
179
|
+
|
|
180
|
+
generated = mind.session()
|
|
181
|
+
assert generated.session_id.startswith("sdk-")
|