corecrux-client 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,55 @@
1
+ # Rust build artifacts
2
+ /target
3
+ **/*.rs.bk
4
+ *.pdb
5
+
6
+ # IDE
7
+ .idea/
8
+ .vscode/
9
+ *.swp
10
+ *.swo
11
+ *~
12
+
13
+ # OS
14
+ .DS_Store
15
+ Thumbs.db
16
+
17
+ # GPU logs
18
+ cufile.log
19
+
20
+ # Generated proto code
21
+ proto/gen/
22
+
23
+ # Environment
24
+ .env
25
+ .env.local
26
+ sdks/typescript/node_modules/
27
+ sdks/typescript/dist/
28
+ sdks/python/dist/
29
+ __pycache__/
30
+ *.pyc
31
+
32
+ # Daemon data — NEVER commit. The fact store, passport keys, sealed
33
+ # integration credentials, selected-repo lists and sync cursors all live
34
+ # under the data dir. Default location is `./data/` when CORECRUXD_DATA_DIR
35
+ # is unset (e.g. `cargo run` outside docker); the docker-compose stack uses
36
+ # a named volume which is already outside the repo. Belt-and-braces here so
37
+ # a stray `git add .` never pulls them in.
38
+ /data/
39
+ **/data/
40
+ *.jsonl
41
+ crux-data/
42
+ passport.key
43
+ passports/
44
+ **/credentials.json
45
+ **/selected_repos.json
46
+ sync_cursor.json
47
+ console/settings.json
48
+ LOCK
49
+ .install-uuid
50
+
51
+ # Claude Code subagent scratch worktrees (orphaned isolation:worktree dirs)
52
+ .claude/
53
+
54
+ # crux-console-ui build artifacts (WASM/JS bundles are generated, not source)
55
+ crates/crux-console-ui/dist/
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: corecrux-client
3
+ Version: 0.1.0
4
+ Summary: Python client for Crux Daemon
5
+ Project-URL: Homepage, https://github.com/CueCrux/Crux
6
+ Project-URL: Documentation, https://github.com/CueCrux/Crux/tree/main/sdks/python
7
+ Project-URL: Repository, https://github.com/CueCrux/Crux
8
+ License-Expression: LicenseRef-CCL-1.0
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27
11
+ Description-Content-Type: text/markdown
12
+
13
+ # CoreCrux Python Client
14
+
15
+ Python client for the [Crux Daemon](https://github.com/CueCrux/Crux) HTTP API.
16
+
17
+ Provides both synchronous and asynchronous interfaces using [httpx](https://www.python-httpx.org/).
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install corecrux-client
23
+ ```
24
+
25
+ Or install from source:
26
+
27
+ ```bash
28
+ cd sdks/python
29
+ pip install -e .
30
+ ```
31
+
32
+ ## Quick start (sync)
33
+
34
+ ```python
35
+ from corecrux_client import CoreCruxClient, StoreFact
36
+
37
+ with CoreCruxClient("http://localhost:14800", token="my-token") as client:
38
+ # Health check
39
+ print(client.healthz())
40
+
41
+ # Store a fact
42
+ fact = client.store_fact(StoreFact(
43
+ entity="user::alice",
44
+ key="preferred_language",
45
+ value="Python",
46
+ ))
47
+ print(fact.fact_id, fact.version)
48
+
49
+ # Query facts
50
+ result = client.query_facts("Python", top_k=5)
51
+ for f in result.facts:
52
+ print(f.entity, f.key, f.value)
53
+
54
+ # Text search
55
+ hits = client.text_search("my-tenant", "deployment guide")
56
+ for h in hits.results:
57
+ print(h.doc_id, h.score)
58
+ ```
59
+
60
+ ## Quick start (async)
61
+
62
+ ```python
63
+ import asyncio
64
+ from corecrux_client import AsyncCoreCruxClient, StoreFact
65
+
66
+ async def main():
67
+ async with AsyncCoreCruxClient("http://localhost:14800", token="my-token") as client:
68
+ fact = await client.store_fact(StoreFact(
69
+ entity="user::alice",
70
+ key="preferred_language",
71
+ value="Python",
72
+ ))
73
+ print(fact.fact_id)
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Authentication
79
+
80
+ Pass a bearer token when constructing the client:
81
+
82
+ ```python
83
+ client = CoreCruxClient(token="my-bearer-token")
84
+ ```
85
+
86
+ The token is sent as `Authorization: Bearer <token>` on every request.
87
+
88
+ ## Error handling
89
+
90
+ All non-2xx responses raise `CoreCruxError`:
91
+
92
+ ```python
93
+ from corecrux_client import CoreCruxClient, CoreCruxError
94
+
95
+ with CoreCruxClient() as client:
96
+ try:
97
+ client.get_fact("nonexistent-id")
98
+ except CoreCruxError as e:
99
+ print(e.status_code) # 404
100
+ print(e.detail) # "fact 'nonexistent-id' not found"
101
+ ```
102
+
103
+ Methods that naturally return "not found" (`get_fact`, `get_session`, `delete_fact`) return `None` or `False` instead of raising on 404.
104
+
105
+ ## API coverage
106
+
107
+ | Endpoint | Sync | Async |
108
+ |---|---|---|
109
+ | `GET /healthz` | `healthz()` | `healthz()` |
110
+ | `GET /readyz` | `readyz()` | `readyz()` |
111
+ | `GET /v1/version` | `version()` | `version()` |
112
+ | `PUT /v1/facts` | `store_fact()` | `store_fact()` |
113
+ | `PUT /v1/facts/bulk` | `store_facts()` | `store_facts()` |
114
+ | `GET /v1/facts/{id}` | `get_fact()` | `get_fact()` |
115
+ | `DELETE /v1/facts/{id}` | `delete_fact()` | `delete_fact()` |
116
+ | `GET /v1/facts/entity/{e}` | `get_facts_by_entity()` | `get_facts_by_entity()` |
117
+ | `GET /v1/facts` | `query_facts()` | `query_facts()` |
118
+ | `GET /v1/facts/export` | `export_facts()` | `export_facts()` |
119
+ | `PUT /v1/sessions/{id}/state` | `put_session()` | `put_session()` |
120
+ | `GET /v1/sessions/{id}/state` | `get_session()` | `get_session()` |
121
+ | `POST /v1/query/text-search` | `text_search()` | `text_search()` |
122
+ | `POST /v1/query/text-search/expand` | `text_search_expand()` | `text_search_expand()` |
123
+ | `POST /v1/query/graph-expand` | `graph_expand()` | `graph_expand()` |
124
+ | `POST /v1/query/time-range` | `time_range()` | `time_range()` |
125
+
126
+ ## Requirements
127
+
128
+ - Python 3.10+
129
+ - httpx >= 0.27
130
+
131
+ ## Licence
132
+
133
+ CueCrux Community Licence (CCL v1.0). See [LICENCE.md](../../LICENCE.md).
@@ -0,0 +1,121 @@
1
+ # CoreCrux Python Client
2
+
3
+ Python client for the [Crux Daemon](https://github.com/CueCrux/Crux) HTTP API.
4
+
5
+ Provides both synchronous and asynchronous interfaces using [httpx](https://www.python-httpx.org/).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install corecrux-client
11
+ ```
12
+
13
+ Or install from source:
14
+
15
+ ```bash
16
+ cd sdks/python
17
+ pip install -e .
18
+ ```
19
+
20
+ ## Quick start (sync)
21
+
22
+ ```python
23
+ from corecrux_client import CoreCruxClient, StoreFact
24
+
25
+ with CoreCruxClient("http://localhost:14800", token="my-token") as client:
26
+ # Health check
27
+ print(client.healthz())
28
+
29
+ # Store a fact
30
+ fact = client.store_fact(StoreFact(
31
+ entity="user::alice",
32
+ key="preferred_language",
33
+ value="Python",
34
+ ))
35
+ print(fact.fact_id, fact.version)
36
+
37
+ # Query facts
38
+ result = client.query_facts("Python", top_k=5)
39
+ for f in result.facts:
40
+ print(f.entity, f.key, f.value)
41
+
42
+ # Text search
43
+ hits = client.text_search("my-tenant", "deployment guide")
44
+ for h in hits.results:
45
+ print(h.doc_id, h.score)
46
+ ```
47
+
48
+ ## Quick start (async)
49
+
50
+ ```python
51
+ import asyncio
52
+ from corecrux_client import AsyncCoreCruxClient, StoreFact
53
+
54
+ async def main():
55
+ async with AsyncCoreCruxClient("http://localhost:14800", token="my-token") as client:
56
+ fact = await client.store_fact(StoreFact(
57
+ entity="user::alice",
58
+ key="preferred_language",
59
+ value="Python",
60
+ ))
61
+ print(fact.fact_id)
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ## Authentication
67
+
68
+ Pass a bearer token when constructing the client:
69
+
70
+ ```python
71
+ client = CoreCruxClient(token="my-bearer-token")
72
+ ```
73
+
74
+ The token is sent as `Authorization: Bearer <token>` on every request.
75
+
76
+ ## Error handling
77
+
78
+ All non-2xx responses raise `CoreCruxError`:
79
+
80
+ ```python
81
+ from corecrux_client import CoreCruxClient, CoreCruxError
82
+
83
+ with CoreCruxClient() as client:
84
+ try:
85
+ client.get_fact("nonexistent-id")
86
+ except CoreCruxError as e:
87
+ print(e.status_code) # 404
88
+ print(e.detail) # "fact 'nonexistent-id' not found"
89
+ ```
90
+
91
+ Methods that naturally return "not found" (`get_fact`, `get_session`, `delete_fact`) return `None` or `False` instead of raising on 404.
92
+
93
+ ## API coverage
94
+
95
+ | Endpoint | Sync | Async |
96
+ |---|---|---|
97
+ | `GET /healthz` | `healthz()` | `healthz()` |
98
+ | `GET /readyz` | `readyz()` | `readyz()` |
99
+ | `GET /v1/version` | `version()` | `version()` |
100
+ | `PUT /v1/facts` | `store_fact()` | `store_fact()` |
101
+ | `PUT /v1/facts/bulk` | `store_facts()` | `store_facts()` |
102
+ | `GET /v1/facts/{id}` | `get_fact()` | `get_fact()` |
103
+ | `DELETE /v1/facts/{id}` | `delete_fact()` | `delete_fact()` |
104
+ | `GET /v1/facts/entity/{e}` | `get_facts_by_entity()` | `get_facts_by_entity()` |
105
+ | `GET /v1/facts` | `query_facts()` | `query_facts()` |
106
+ | `GET /v1/facts/export` | `export_facts()` | `export_facts()` |
107
+ | `PUT /v1/sessions/{id}/state` | `put_session()` | `put_session()` |
108
+ | `GET /v1/sessions/{id}/state` | `get_session()` | `get_session()` |
109
+ | `POST /v1/query/text-search` | `text_search()` | `text_search()` |
110
+ | `POST /v1/query/text-search/expand` | `text_search_expand()` | `text_search_expand()` |
111
+ | `POST /v1/query/graph-expand` | `graph_expand()` | `graph_expand()` |
112
+ | `POST /v1/query/time-range` | `time_range()` | `time_range()` |
113
+
114
+ ## Requirements
115
+
116
+ - Python 3.10+
117
+ - httpx >= 0.27
118
+
119
+ ## Licence
120
+
121
+ CueCrux Community Licence (CCL v1.0). See [LICENCE.md](../../LICENCE.md).
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "corecrux-client"
7
+ version = "0.1.0"
8
+ description = "Python client for Crux Daemon"
9
+ readme = "README.md"
10
+ license = "LicenseRef-CCL-1.0"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["httpx>=0.27"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/CueCrux/Crux"
16
+ Documentation = "https://github.com/CueCrux/Crux/tree/main/sdks/python"
17
+ Repository = "https://github.com/CueCrux/Crux"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/corecrux_client"]
@@ -0,0 +1,32 @@
1
+ # Copyright (c) 2026 CueCrux Ltd. All rights reserved.
2
+ # Licensed under the CueCrux Community Licence (CCL v1.0).
3
+ # See LICENCE.md in the repository root.
4
+
5
+ """Crux Daemon Python client."""
6
+
7
+ from .client import AsyncCoreCruxClient, CoreCruxClient
8
+ from .errors import CoreCruxError
9
+ from .types import (
10
+ Fact,
11
+ FactQueryResult,
12
+ SessionState,
13
+ StoreFact,
14
+ TextSearchCoverage,
15
+ TextSearchHit,
16
+ TextSearchMeta,
17
+ TextSearchResult,
18
+ )
19
+
20
+ __all__ = [
21
+ "AsyncCoreCruxClient",
22
+ "CoreCruxClient",
23
+ "CoreCruxError",
24
+ "Fact",
25
+ "FactQueryResult",
26
+ "SessionState",
27
+ "StoreFact",
28
+ "TextSearchCoverage",
29
+ "TextSearchHit",
30
+ "TextSearchMeta",
31
+ "TextSearchResult",
32
+ ]
@@ -0,0 +1,632 @@
1
+ # Copyright (c) 2026 CueCrux Ltd. All rights reserved.
2
+ # Licensed under the CueCrux Community Licence (CCL v1.0).
3
+ # See LICENCE.md in the repository root.
4
+
5
+ """Synchronous and asynchronous CoreCrux HTTP clients."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .errors import CoreCruxError
14
+ from .types import (
15
+ Fact,
16
+ FactQueryResult,
17
+ SessionState,
18
+ StoreFact,
19
+ TextSearchCoverage,
20
+ TextSearchHit,
21
+ TextSearchMeta,
22
+ TextSearchResult,
23
+ )
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _headers(token: str | None) -> dict[str, str]:
31
+ h: dict[str, str] = {"Content-Type": "application/json"}
32
+ if token:
33
+ h["Authorization"] = f"Bearer {token}"
34
+ return h
35
+
36
+
37
+ def _raise_for_status(resp: httpx.Response) -> None:
38
+ if resp.status_code < 400:
39
+ return
40
+ ct = resp.headers.get("content-type", "")
41
+ if ct.startswith("application/json") or ct.startswith("application/problem+json"):
42
+ body = resp.json()
43
+ else:
44
+ body = {}
45
+ raise CoreCruxError(
46
+ resp.status_code,
47
+ body.get("detail", resp.text),
48
+ body.get("type", ""),
49
+ )
50
+
51
+
52
+ def _parse_json(resp: httpx.Response) -> dict[str, Any]:
53
+ _raise_for_status(resp)
54
+ if not resp.content:
55
+ return {}
56
+ return resp.json()
57
+
58
+
59
+ def _to_fact(d: dict[str, Any]) -> Fact:
60
+ return Fact(
61
+ fact_id=d["fact_id"],
62
+ entity=d["entity"],
63
+ key=d["key"],
64
+ value=d["value"],
65
+ confidence=d["confidence"],
66
+ stored_at=d["stored_at"],
67
+ tokens=d["tokens"],
68
+ deleted=d["deleted"],
69
+ version=d.get("version", 1),
70
+ source_receipt=d.get("source_receipt"),
71
+ supersedes=d.get("supersedes"),
72
+ private=d.get("private", False),
73
+ )
74
+
75
+
76
+ def _to_session(d: dict[str, Any]) -> SessionState:
77
+ return SessionState(
78
+ session_id=d["session_id"],
79
+ state=d["state"],
80
+ updated_at=d["updated_at"],
81
+ total_tokens=d["total_tokens"],
82
+ expires_at=d.get("expires_at"),
83
+ )
84
+
85
+
86
+ def _to_text_search_result(d: dict[str, Any]) -> TextSearchResult:
87
+ hits = [
88
+ TextSearchHit(
89
+ segment_index=h["segment_index"],
90
+ doc_id=h["doc_id"],
91
+ score=h["score"],
92
+ frame_offset=h["frame_offset"],
93
+ token_count=h["token_count"],
94
+ )
95
+ for h in d.get("results", [])
96
+ ]
97
+ cov_raw = d.get("coverage", {})
98
+ coverage = TextSearchCoverage(
99
+ score=cov_raw.get("score", 0.0),
100
+ gaps=cov_raw.get("gaps", []),
101
+ below_floor=cov_raw.get("below_floor", 0),
102
+ )
103
+ meta_raw = d.get("meta", {})
104
+ meta = TextSearchMeta(
105
+ backend=meta_raw.get("backend", ""),
106
+ took_ms=meta_raw.get("took_ms", 0),
107
+ segments_searched=meta_raw.get("segments_searched", 0),
108
+ total_docs=meta_raw.get("total_docs", 0),
109
+ total_candidates=meta_raw.get("total_candidates", 0),
110
+ )
111
+ return TextSearchResult(
112
+ results=hits,
113
+ coverage=coverage,
114
+ meta=meta,
115
+ tokens_used=d.get("tokens_used"),
116
+ tokens_available=d.get("tokens_available"),
117
+ results_omitted=d.get("results_omitted"),
118
+ scan_mode=d.get("scan_mode", False),
119
+ )
120
+
121
+
122
+ def _store_fact_payload(fact: StoreFact) -> dict[str, Any]:
123
+ d: dict[str, Any] = {
124
+ "entity": fact.entity,
125
+ "key": fact.key,
126
+ "value": fact.value,
127
+ "confidence": fact.confidence,
128
+ "private": fact.private,
129
+ }
130
+ if fact.source_receipt is not None:
131
+ d["source_receipt"] = fact.source_receipt
132
+ return d
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Synchronous client
137
+ # ---------------------------------------------------------------------------
138
+
139
+ class CoreCruxClient:
140
+ """Synchronous CoreCrux HTTP client.
141
+
142
+ Usage::
143
+
144
+ with CoreCruxClient("http://localhost:14800", token="...") as client:
145
+ info = client.healthz()
146
+ fact = client.store_fact(StoreFact(entity="user", key="name", value="Alice"))
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ base_url: str = "http://localhost:14800",
152
+ token: str | None = None,
153
+ *,
154
+ timeout: float = 30.0,
155
+ ):
156
+ self._client = httpx.Client(
157
+ base_url=base_url,
158
+ headers=_headers(token),
159
+ timeout=timeout,
160
+ )
161
+
162
+ # -- context manager --
163
+
164
+ def __enter__(self) -> CoreCruxClient:
165
+ return self
166
+
167
+ def __exit__(self, *args: object) -> None:
168
+ self.close()
169
+
170
+ def close(self) -> None:
171
+ """Close the underlying HTTP connection pool."""
172
+ self._client.close()
173
+
174
+ # -- internal --
175
+
176
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
177
+ resp = self._client.request(method, path, **kwargs)
178
+ return _parse_json(resp)
179
+
180
+ # -- health --
181
+
182
+ def healthz(self) -> dict[str, Any]:
183
+ """GET /healthz -- node health status."""
184
+ return self._request("GET", "/healthz")
185
+
186
+ def readyz(self) -> dict[str, Any]:
187
+ """GET /readyz -- node readiness checks."""
188
+ return self._request("GET", "/readyz")
189
+
190
+ def version(self) -> dict[str, Any]:
191
+ """GET /v1/version -- build version and feature flags."""
192
+ return self._request("GET", "/v1/version")
193
+
194
+ # -- facts --
195
+
196
+ def store_fact(self, fact: StoreFact) -> Fact:
197
+ """PUT /v1/facts -- create or update a single fact."""
198
+ data = self._request("PUT", "/v1/facts", json=_store_fact_payload(fact))
199
+ return _to_fact(data)
200
+
201
+ def store_facts(self, facts: list[StoreFact]) -> list[Fact]:
202
+ """PUT /v1/facts/bulk -- create multiple facts at once."""
203
+ payload = [_store_fact_payload(f) for f in facts]
204
+ data = self._request("PUT", "/v1/facts/bulk", json=payload)
205
+ return [_to_fact(f) for f in data.get("facts", [])]
206
+
207
+ def get_fact(self, fact_id: str) -> Fact | None:
208
+ """GET /v1/facts/{factId} -- retrieve a fact by ID.
209
+
210
+ Returns ``None`` if the fact does not exist (404).
211
+ """
212
+ try:
213
+ data = self._request("GET", f"/v1/facts/{fact_id}")
214
+ return _to_fact(data)
215
+ except CoreCruxError as exc:
216
+ if exc.status_code == 404:
217
+ return None
218
+ raise
219
+
220
+ def delete_fact(self, fact_id: str) -> bool:
221
+ """DELETE /v1/facts/{factId} -- soft-delete a fact.
222
+
223
+ Returns ``True`` if deleted, ``False`` if the fact was not found.
224
+ """
225
+ try:
226
+ data = self._request("DELETE", f"/v1/facts/{fact_id}")
227
+ return data.get("deleted", False)
228
+ except CoreCruxError as exc:
229
+ if exc.status_code == 404:
230
+ return False
231
+ raise
232
+
233
+ def get_facts_by_entity(self, entity: str) -> list[Fact]:
234
+ """GET /v1/facts/entity/{entity} -- list all facts for an entity."""
235
+ data = self._request("GET", f"/v1/facts/entity/{entity}")
236
+ return [_to_fact(f) for f in data.get("facts", [])]
237
+
238
+ def query_facts(
239
+ self,
240
+ query: str | None = None,
241
+ *,
242
+ entity: str | None = None,
243
+ entity_prefix: str | None = None,
244
+ top_k: int | None = None,
245
+ token_budget: int | None = None,
246
+ ) -> FactQueryResult:
247
+ """GET /v1/facts -- query facts with BM25 text search and filters."""
248
+ params: dict[str, Any] = {}
249
+ if query is not None:
250
+ params["query"] = query
251
+ if entity is not None:
252
+ params["entity"] = entity
253
+ if entity_prefix is not None:
254
+ params["entity_prefix"] = entity_prefix
255
+ if top_k is not None:
256
+ params["top_k"] = top_k
257
+ if token_budget is not None:
258
+ params["token_budget"] = token_budget
259
+ data = self._request("GET", "/v1/facts", params=params)
260
+ return FactQueryResult(
261
+ facts=[_to_fact(f) for f in data.get("facts", [])],
262
+ total_tokens=data.get("total_tokens", 0),
263
+ )
264
+
265
+ def export_facts(
266
+ self,
267
+ *,
268
+ since: str | None = None,
269
+ cursor: str | None = None,
270
+ limit: int | None = None,
271
+ ) -> dict[str, Any]:
272
+ """GET /v1/facts/export -- paginated fact export (including tombstones)."""
273
+ params: dict[str, Any] = {}
274
+ if since is not None:
275
+ params["since"] = since
276
+ if cursor is not None:
277
+ params["cursor"] = cursor
278
+ if limit is not None:
279
+ params["limit"] = limit
280
+ return self._request("GET", "/v1/facts/export", params=params)
281
+
282
+ # -- sessions --
283
+
284
+ def put_session(self, session_id: str, state: dict[str, Any]) -> SessionState:
285
+ """PUT /v1/sessions/{sessionId}/state -- store session state."""
286
+ data = self._request("PUT", f"/v1/sessions/{session_id}/state", json=state)
287
+ return _to_session(data)
288
+
289
+ def get_session(self, session_id: str) -> SessionState | None:
290
+ """GET /v1/sessions/{sessionId}/state -- retrieve session state.
291
+
292
+ Returns ``None`` if the session does not exist (404).
293
+ """
294
+ try:
295
+ data = self._request("GET", f"/v1/sessions/{session_id}/state")
296
+ return _to_session(data)
297
+ except CoreCruxError as exc:
298
+ if exc.status_code == 404:
299
+ return None
300
+ raise
301
+
302
+ # -- query --
303
+
304
+ def text_search(
305
+ self,
306
+ tenant_id: str,
307
+ query: str,
308
+ *,
309
+ limit: int = 10,
310
+ token_budget: int | None = None,
311
+ min_score: float | None = None,
312
+ mode: str | None = None,
313
+ ) -> TextSearchResult:
314
+ """POST /v1/query/text-search -- BM25 full-text search over segments."""
315
+ body: dict[str, Any] = {
316
+ "tenant_id": tenant_id,
317
+ "query": query,
318
+ "limit": limit,
319
+ }
320
+ if token_budget is not None:
321
+ body["token_budget"] = token_budget
322
+ if min_score is not None:
323
+ body["min_score"] = min_score
324
+ if mode is not None:
325
+ body["mode"] = mode
326
+ data = self._request("POST", "/v1/query/text-search", json=body)
327
+ return _to_text_search_result(data)
328
+
329
+ def text_search_expand(
330
+ self,
331
+ tenant_id: str,
332
+ result_ids: list[dict[str, int]],
333
+ ) -> dict[str, Any]:
334
+ """POST /v1/query/text-search/expand -- expand scan-mode results.
335
+
336
+ ``result_ids`` should be a list of dicts with ``segment_index`` and ``doc_id`` keys.
337
+ """
338
+ body: dict[str, Any] = {
339
+ "tenant_id": tenant_id,
340
+ "result_ids": result_ids,
341
+ }
342
+ return self._request("POST", "/v1/query/text-search/expand", json=body)
343
+
344
+ def graph_expand(
345
+ self,
346
+ tenant_id: str,
347
+ seed_artifact_ids: list[int],
348
+ *,
349
+ edge_types: list[str] | None = None,
350
+ max_hops: int = 2,
351
+ budget: int = 50,
352
+ min_confidence: float = 0.0,
353
+ include_state: bool = False,
354
+ ) -> dict[str, Any]:
355
+ """POST /v1/query/graph-expand -- traverse the artifact relation graph."""
356
+ body: dict[str, Any] = {
357
+ "tenant_id": tenant_id,
358
+ "seed_artifact_ids": seed_artifact_ids,
359
+ "max_hops": max_hops,
360
+ "budget": budget,
361
+ "min_confidence": min_confidence,
362
+ "include_state": include_state,
363
+ }
364
+ if edge_types is not None:
365
+ body["edge_types"] = edge_types
366
+ return self._request("POST", "/v1/query/graph-expand", json=body)
367
+
368
+ def time_range(
369
+ self,
370
+ tenant_id: str,
371
+ start_micros: int,
372
+ end_micros: int,
373
+ *,
374
+ artifact_ids: list[int] | None = None,
375
+ include_relations: bool = False,
376
+ limit: int = 100,
377
+ ) -> dict[str, Any]:
378
+ """POST /v1/query/time-range -- query artifacts changed within a time window."""
379
+ body: dict[str, Any] = {
380
+ "tenant_id": tenant_id,
381
+ "start_micros": start_micros,
382
+ "end_micros": end_micros,
383
+ "include_relations": include_relations,
384
+ "limit": limit,
385
+ }
386
+ if artifact_ids is not None:
387
+ body["artifact_ids"] = artifact_ids
388
+ return self._request("POST", "/v1/query/time-range", json=body)
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # Asynchronous client
393
+ # ---------------------------------------------------------------------------
394
+
395
+ class AsyncCoreCruxClient:
396
+ """Asynchronous CoreCrux HTTP client (uses ``httpx.AsyncClient``).
397
+
398
+ Usage::
399
+
400
+ async with AsyncCoreCruxClient("http://localhost:14800", token="...") as client:
401
+ info = await client.healthz()
402
+ fact = await client.store_fact(StoreFact(entity="user", key="name", value="Alice"))
403
+ """
404
+
405
+ def __init__(
406
+ self,
407
+ base_url: str = "http://localhost:14800",
408
+ token: str | None = None,
409
+ *,
410
+ timeout: float = 30.0,
411
+ ):
412
+ self._client = httpx.AsyncClient(
413
+ base_url=base_url,
414
+ headers=_headers(token),
415
+ timeout=timeout,
416
+ )
417
+
418
+ # -- context manager --
419
+
420
+ async def __aenter__(self) -> AsyncCoreCruxClient:
421
+ return self
422
+
423
+ async def __aexit__(self, *args: object) -> None:
424
+ await self.close()
425
+
426
+ async def close(self) -> None:
427
+ """Close the underlying HTTP connection pool."""
428
+ await self._client.aclose()
429
+
430
+ # -- internal --
431
+
432
+ async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
433
+ resp = await self._client.request(method, path, **kwargs)
434
+ return _parse_json(resp)
435
+
436
+ # -- health --
437
+
438
+ async def healthz(self) -> dict[str, Any]:
439
+ """GET /healthz -- node health status."""
440
+ return await self._request("GET", "/healthz")
441
+
442
+ async def readyz(self) -> dict[str, Any]:
443
+ """GET /readyz -- node readiness checks."""
444
+ return await self._request("GET", "/readyz")
445
+
446
+ async def version(self) -> dict[str, Any]:
447
+ """GET /v1/version -- build version and feature flags."""
448
+ return await self._request("GET", "/v1/version")
449
+
450
+ # -- facts --
451
+
452
+ async def store_fact(self, fact: StoreFact) -> Fact:
453
+ """PUT /v1/facts -- create or update a single fact."""
454
+ data = await self._request("PUT", "/v1/facts", json=_store_fact_payload(fact))
455
+ return _to_fact(data)
456
+
457
+ async def store_facts(self, facts: list[StoreFact]) -> list[Fact]:
458
+ """PUT /v1/facts/bulk -- create multiple facts at once."""
459
+ payload = [_store_fact_payload(f) for f in facts]
460
+ data = await self._request("PUT", "/v1/facts/bulk", json=payload)
461
+ return [_to_fact(f) for f in data.get("facts", [])]
462
+
463
+ async def get_fact(self, fact_id: str) -> Fact | None:
464
+ """GET /v1/facts/{factId} -- retrieve a fact by ID."""
465
+ try:
466
+ data = await self._request("GET", f"/v1/facts/{fact_id}")
467
+ return _to_fact(data)
468
+ except CoreCruxError as exc:
469
+ if exc.status_code == 404:
470
+ return None
471
+ raise
472
+
473
+ async def delete_fact(self, fact_id: str) -> bool:
474
+ """DELETE /v1/facts/{factId} -- soft-delete a fact."""
475
+ try:
476
+ data = await self._request("DELETE", f"/v1/facts/{fact_id}")
477
+ return data.get("deleted", False)
478
+ except CoreCruxError as exc:
479
+ if exc.status_code == 404:
480
+ return False
481
+ raise
482
+
483
+ async def get_facts_by_entity(self, entity: str) -> list[Fact]:
484
+ """GET /v1/facts/entity/{entity} -- list all facts for an entity."""
485
+ data = await self._request("GET", f"/v1/facts/entity/{entity}")
486
+ return [_to_fact(f) for f in data.get("facts", [])]
487
+
488
+ async def query_facts(
489
+ self,
490
+ query: str | None = None,
491
+ *,
492
+ entity: str | None = None,
493
+ entity_prefix: str | None = None,
494
+ top_k: int | None = None,
495
+ token_budget: int | None = None,
496
+ ) -> FactQueryResult:
497
+ """GET /v1/facts -- query facts with BM25 text search and filters."""
498
+ params: dict[str, Any] = {}
499
+ if query is not None:
500
+ params["query"] = query
501
+ if entity is not None:
502
+ params["entity"] = entity
503
+ if entity_prefix is not None:
504
+ params["entity_prefix"] = entity_prefix
505
+ if top_k is not None:
506
+ params["top_k"] = top_k
507
+ if token_budget is not None:
508
+ params["token_budget"] = token_budget
509
+ data = await self._request("GET", "/v1/facts", params=params)
510
+ return FactQueryResult(
511
+ facts=[_to_fact(f) for f in data.get("facts", [])],
512
+ total_tokens=data.get("total_tokens", 0),
513
+ )
514
+
515
+ async def export_facts(
516
+ self,
517
+ *,
518
+ since: str | None = None,
519
+ cursor: str | None = None,
520
+ limit: int | None = None,
521
+ ) -> dict[str, Any]:
522
+ """GET /v1/facts/export -- paginated fact export (including tombstones)."""
523
+ params: dict[str, Any] = {}
524
+ if since is not None:
525
+ params["since"] = since
526
+ if cursor is not None:
527
+ params["cursor"] = cursor
528
+ if limit is not None:
529
+ params["limit"] = limit
530
+ return await self._request("GET", "/v1/facts/export", params=params)
531
+
532
+ # -- sessions --
533
+
534
+ async def put_session(self, session_id: str, state: dict[str, Any]) -> SessionState:
535
+ """PUT /v1/sessions/{sessionId}/state -- store session state."""
536
+ data = await self._request("PUT", f"/v1/sessions/{session_id}/state", json=state)
537
+ return _to_session(data)
538
+
539
+ async def get_session(self, session_id: str) -> SessionState | None:
540
+ """GET /v1/sessions/{sessionId}/state -- retrieve session state."""
541
+ try:
542
+ data = await self._request("GET", f"/v1/sessions/{session_id}/state")
543
+ return _to_session(data)
544
+ except CoreCruxError as exc:
545
+ if exc.status_code == 404:
546
+ return None
547
+ raise
548
+
549
+ # -- query --
550
+
551
+ async def text_search(
552
+ self,
553
+ tenant_id: str,
554
+ query: str,
555
+ *,
556
+ limit: int = 10,
557
+ token_budget: int | None = None,
558
+ min_score: float | None = None,
559
+ mode: str | None = None,
560
+ ) -> TextSearchResult:
561
+ """POST /v1/query/text-search -- BM25 full-text search over segments."""
562
+ body: dict[str, Any] = {
563
+ "tenant_id": tenant_id,
564
+ "query": query,
565
+ "limit": limit,
566
+ }
567
+ if token_budget is not None:
568
+ body["token_budget"] = token_budget
569
+ if min_score is not None:
570
+ body["min_score"] = min_score
571
+ if mode is not None:
572
+ body["mode"] = mode
573
+ data = await self._request("POST", "/v1/query/text-search", json=body)
574
+ return _to_text_search_result(data)
575
+
576
+ async def text_search_expand(
577
+ self,
578
+ tenant_id: str,
579
+ result_ids: list[dict[str, int]],
580
+ ) -> dict[str, Any]:
581
+ """POST /v1/query/text-search/expand -- expand scan-mode results."""
582
+ body: dict[str, Any] = {
583
+ "tenant_id": tenant_id,
584
+ "result_ids": result_ids,
585
+ }
586
+ return await self._request("POST", "/v1/query/text-search/expand", json=body)
587
+
588
+ async def graph_expand(
589
+ self,
590
+ tenant_id: str,
591
+ seed_artifact_ids: list[int],
592
+ *,
593
+ edge_types: list[str] | None = None,
594
+ max_hops: int = 2,
595
+ budget: int = 50,
596
+ min_confidence: float = 0.0,
597
+ include_state: bool = False,
598
+ ) -> dict[str, Any]:
599
+ """POST /v1/query/graph-expand -- traverse the artifact relation graph."""
600
+ body: dict[str, Any] = {
601
+ "tenant_id": tenant_id,
602
+ "seed_artifact_ids": seed_artifact_ids,
603
+ "max_hops": max_hops,
604
+ "budget": budget,
605
+ "min_confidence": min_confidence,
606
+ "include_state": include_state,
607
+ }
608
+ if edge_types is not None:
609
+ body["edge_types"] = edge_types
610
+ return await self._request("POST", "/v1/query/graph-expand", json=body)
611
+
612
+ async def time_range(
613
+ self,
614
+ tenant_id: str,
615
+ start_micros: int,
616
+ end_micros: int,
617
+ *,
618
+ artifact_ids: list[int] | None = None,
619
+ include_relations: bool = False,
620
+ limit: int = 100,
621
+ ) -> dict[str, Any]:
622
+ """POST /v1/query/time-range -- query artifacts changed within a time window."""
623
+ body: dict[str, Any] = {
624
+ "tenant_id": tenant_id,
625
+ "start_micros": start_micros,
626
+ "end_micros": end_micros,
627
+ "include_relations": include_relations,
628
+ "limit": limit,
629
+ }
630
+ if artifact_ids is not None:
631
+ body["artifact_ids"] = artifact_ids
632
+ return await self._request("POST", "/v1/query/time-range", json=body)
@@ -0,0 +1,21 @@
1
+ # Copyright (c) 2026 CueCrux Ltd. All rights reserved.
2
+ # Licensed under the CueCrux Community Licence (CCL v1.0).
3
+ # See LICENCE.md in the repository root.
4
+
5
+ """CoreCrux error types."""
6
+
7
+
8
+ class CoreCruxError(Exception):
9
+ """Raised when the CoreCrux API returns a non-2xx response.
10
+
11
+ Attributes:
12
+ status_code: HTTP status code from the server.
13
+ detail: Human-readable error detail string.
14
+ type: Optional problem type URI (RFC 7807).
15
+ """
16
+
17
+ def __init__(self, status_code: int, detail: str, type: str = ""):
18
+ self.status_code = status_code
19
+ self.detail = detail
20
+ self.type = type
21
+ super().__init__(f"CoreCrux error {status_code}: {detail}")
@@ -0,0 +1,103 @@
1
+ # Copyright (c) 2026 CueCrux Ltd. All rights reserved.
2
+ # Licensed under the CueCrux Community Licence (CCL v1.0).
3
+ # See LICENCE.md in the repository root.
4
+
5
+ """CoreCrux API data types."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class Fact:
15
+ """A stored fact returned by the CoreCrux API."""
16
+
17
+ fact_id: str
18
+ entity: str
19
+ key: str
20
+ value: str
21
+ confidence: float
22
+ stored_at: str
23
+ tokens: int
24
+ deleted: bool
25
+ version: int
26
+ source_receipt: str | None = None
27
+ supersedes: str | None = None
28
+ private: bool = False
29
+
30
+
31
+ @dataclass
32
+ class StoreFact:
33
+ """Payload for creating a new fact via the CoreCrux API."""
34
+
35
+ entity: str
36
+ key: str
37
+ value: str
38
+ confidence: float = 1.0
39
+ private: bool = False
40
+ source_receipt: str | None = None
41
+
42
+
43
+ @dataclass
44
+ class TextSearchHit:
45
+ """A single hit from a text-search query."""
46
+
47
+ segment_index: int
48
+ doc_id: int
49
+ score: float
50
+ frame_offset: int
51
+ token_count: int
52
+
53
+
54
+ @dataclass
55
+ class TextSearchCoverage:
56
+ """Coverage metadata for a text-search query."""
57
+
58
+ score: float
59
+ gaps: list[dict[str, Any]] = field(default_factory=list)
60
+ below_floor: int = 0
61
+
62
+
63
+ @dataclass
64
+ class TextSearchMeta:
65
+ """Execution metadata for a text-search query."""
66
+
67
+ backend: str
68
+ took_ms: int
69
+ segments_searched: int
70
+ total_docs: int
71
+ total_candidates: int = 0
72
+
73
+
74
+ @dataclass
75
+ class TextSearchResult:
76
+ """Full response from a text-search query."""
77
+
78
+ results: list[TextSearchHit]
79
+ coverage: TextSearchCoverage
80
+ meta: TextSearchMeta
81
+ tokens_used: int | None = None
82
+ tokens_available: int | None = None
83
+ results_omitted: int | None = None
84
+ scan_mode: bool = False
85
+
86
+
87
+ @dataclass
88
+ class FactQueryResult:
89
+ """Response from the query-facts endpoint."""
90
+
91
+ facts: list[Fact]
92
+ total_tokens: int
93
+
94
+
95
+ @dataclass
96
+ class SessionState:
97
+ """Stored session state returned by the CoreCrux API."""
98
+
99
+ session_id: str
100
+ state: dict[str, Any]
101
+ updated_at: str
102
+ total_tokens: int
103
+ expires_at: str | None = None