taisce 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.
taisce-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: taisce
3
+ Version: 0.1.0
4
+ Summary: Client for a Taisce memory deployment: the v1 contract, no agent framework.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+
10
+ # taisce
11
+
12
+ The client for a Taisce memory deployment over the v1 contract: `observe`, `freshness`, `recall`
13
+ and `resolve_citation`, asynchronous, with `httpx` as its only dependency. The agent framework
14
+ adapters in this repository are built on it.
taisce-0.1.0/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # taisce
2
+
3
+ The client for a Taisce memory deployment over the v1 contract: `observe`, `freshness`, `recall`
4
+ and `resolve_citation`, asynchronous, with `httpx` as its only dependency. The agent framework
5
+ adapters in this repository are built on it.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "taisce"
3
+ version = "0.1.0"
4
+ description = "Client for a Taisce memory deployment: the v1 contract, no agent framework."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "Apache-2.0"
8
+ dependencies = ["httpx>=0.27"]
9
+
10
+ [build-system]
11
+ requires = ["setuptools>=61"]
12
+ build-backend = "setuptools.build_meta"
13
+
14
+ [tool.setuptools.packages.find]
15
+ include = ["taisce*"]
taisce-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ # Copyright 2026 The Taisce Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Taisce for Python: the client over the v1 contract, and what every adapter renders the same."""
4
+ from .client import Client, Freshness, TaisceError, bundle_is_empty
5
+ from .memory import MEMORY_MESSAGE_PREFIX, is_memory_text, render_context_message, render_memory_message, turn_key
6
+
7
+ __all__ = ["Client", "Freshness", "TaisceError", "bundle_is_empty", "MEMORY_MESSAGE_PREFIX", "is_memory_text", "render_context_message", "render_memory_message", "turn_key"]
@@ -0,0 +1,187 @@
1
+ # Copyright 2026 The Taisce Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The client: the v1 contract of a Taisce deployment, and nothing about any agent framework.
4
+
5
+ The adapters are built on this, so a caller who wants governed memory without a framework does not
6
+ acquire one by asking. It is asynchronous because the frameworks it serves are; the sync wrapper
7
+ exists for scripts. The credential is a bearer token bound to one project, sent nowhere but the
8
+ deployment this client was built for, and never logged.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Mapping, Optional, Sequence
14
+
15
+ import base64
16
+
17
+ import httpx
18
+
19
+
20
+ class TaisceError(Exception):
21
+ """A refusal from the deployment, carrying the contract's own code. Branch on ``code``."""
22
+
23
+ def __init__(self, status: int, code: str, message: str) -> None:
24
+ super().__init__(f"{status} {code}: {message}")
25
+ self.status = status
26
+ self.code = code
27
+ self.message = message
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Freshness:
32
+ """How far behind memory is: the highest offset stored, the highest formed, the parked count."""
33
+
34
+ scope: str
35
+ stored: Optional[int]
36
+ formed: Optional[int]
37
+ parked: int
38
+
39
+
40
+ class Client:
41
+ """An asynchronous client over the v1 contract."""
42
+
43
+ def __init__(self, base_url: str, token: str, *, http: Optional[httpx.AsyncClient] = None, timeout: float = 30.0) -> None:
44
+ if not base_url or not base_url.strip():
45
+ raise ValueError("a deployment address is required")
46
+ if not token or not token.strip():
47
+ raise ValueError("a credential is required")
48
+ self.base_url = base_url.rstrip("/")
49
+ self._owns_http = http is None
50
+ self._http = http or httpx.AsyncClient(timeout=timeout)
51
+ self._headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
52
+
53
+ async def aclose(self) -> None:
54
+ if self._owns_http:
55
+ await self._http.aclose()
56
+
57
+ async def __aenter__(self) -> "Client":
58
+ return self
59
+
60
+ async def __aexit__(self, *exc: object) -> None:
61
+ await self.aclose()
62
+
63
+ async def observe(self, *, idempotency_key: str, messages: Sequence[Mapping[str, Any]],
64
+ data_subject_id: Optional[str] = None, occurred_at: Optional[str] = None) -> dict:
65
+ """Records a turn. Durable when it returns; formation follows."""
66
+ body: dict = {"idempotency_key": idempotency_key, "messages": list(messages)}
67
+ if data_subject_id:
68
+ body["data_subject_id"] = data_subject_id
69
+ if occurred_at:
70
+ body["occurred_at"] = occurred_at
71
+ return await self._post("/v1/observations", body, 201)
72
+
73
+ async def freshness(self) -> Freshness:
74
+ """How far behind memory is."""
75
+ response = await self._http.get(self.base_url + "/v1/freshness", headers=self._headers)
76
+ data = self._read(response, 200)
77
+ return Freshness(scope=data.get("scope", ""), stored=data.get("stored"), formed=data.get("formed"), parked=int(data.get("parked", 0)))
78
+
79
+ async def recall(self, *, question: str, data_subject_id: Optional[str] = None, **controls: Any) -> dict:
80
+ """Asks memory a question. ``controls`` are the contract's: max_characters, source_roles, hops, surfaces, themes, as_of, as_known_at."""
81
+ body: dict = {"question": question}
82
+ if data_subject_id:
83
+ body["data_subject_id"] = data_subject_id
84
+ body.update({k: v for k, v in controls.items() if v is not None})
85
+ return await self._post("/v1/recalls", body, 200)
86
+
87
+ async def put_artifact(self, *, artifact_id: str, data_subject_id: str, kind: str, content: bytes,
88
+ name: Optional[str] = None, expected_version: Optional[str] = None) -> dict:
89
+ """Stores an opaque object under a person, replacing the version it names.
90
+
91
+ The bytes are the application's and the deployment never interprets them. It holds them
92
+ under a project and a person, expires them with that person's retention and removes them
93
+ with that person's erasure — which is the whole reason to keep session state here rather
94
+ than in an application's own database, where a deletion request would have two places to
95
+ sweep and a counted residual for only one of them.
96
+ """
97
+ body: dict = {"id": artifact_id, "data_subject_id": data_subject_id, "kind": kind,
98
+ "content": base64.b64encode(content).decode("ascii")}
99
+ if name:
100
+ body["name"] = name
101
+ if expected_version:
102
+ body["expected_version"] = expected_version
103
+ return await self._post("/v1/artifacts/put", body, 200)
104
+
105
+ async def get_artifact(self, *, artifact_id: str, data_subject_id: Optional[str] = None) -> dict:
106
+ """Reads one object, optionally requiring it to belong to the person named.
107
+
108
+ One credential opens a project, and a project holds every end user's objects. Naming the
109
+ subject makes "this one is theirs" a requirement the deployment enforces rather than a habit
110
+ the application keeps: another person's object is answered exactly as one that is not there.
111
+ """
112
+ body: dict = {"id": artifact_id}
113
+ if data_subject_id:
114
+ body["data_subject_id"] = data_subject_id
115
+ return await self._post("/v1/artifacts/get", body, 200)
116
+
117
+ async def delete_artifact(self, *, artifact_id: str, data_subject_id: Optional[str] = None,
118
+ expected_version: Optional[str] = None) -> dict:
119
+ """Removes one object, optionally requiring it to belong to the person named."""
120
+ body: dict = {"id": artifact_id}
121
+ if data_subject_id:
122
+ body["data_subject_id"] = data_subject_id
123
+ if expected_version:
124
+ body["expected_version"] = expected_version
125
+ return await self._post("/v1/artifacts/delete", body, 200)
126
+
127
+ async def search_passages(self, *, question: str, limit: Optional[int] = None,
128
+ data_subject_id: Optional[str] = None,
129
+ source_role: Optional[str] = None) -> dict:
130
+ """Searches the stored words themselves, for a question no fact answers.
131
+
132
+ This is the other half of retrieval and deliberately separate from :meth:`recall`: recall
133
+ answers from what was inferred, this answers from what was said. A caller that wants
134
+ grounding without adopting the memory model uses only this.
135
+
136
+ The answer carries ``approximate`` and ``covered_through_offset`` because a passage search
137
+ reads an embedding generation, and a generation is built up to a point in the log. Treating
138
+ an answer as complete while the build is behind quotes an index rather than the memory.
139
+ """
140
+ body: dict = {"question": question}
141
+ if limit is not None:
142
+ body["limit"] = limit
143
+ if data_subject_id:
144
+ body["data_subject_id"] = data_subject_id
145
+ if source_role:
146
+ body["source_role"] = source_role
147
+ return await self._post("/v1/passages/search", body, 200)
148
+
149
+ async def context(self, *, data_subject_id: str, max_characters: Optional[int] = None) -> dict:
150
+ """One subject's history under a budget: the newest turns verbatim and, over the rest, the
151
+ segments the deployment wrote. No model call is made; what the deployment has not rolled up
152
+ yet comes back verbatim and cut from the oldest end."""
153
+ if not data_subject_id or not data_subject_id.strip():
154
+ raise ValueError("a data subject is required: a context is one subject's history")
155
+ body: dict = {"data_subject_id": data_subject_id}
156
+ if max_characters is not None:
157
+ body["max_characters"] = max_characters
158
+ return await self._post("/v1/contexts", body, 200)
159
+
160
+ async def resolve_citation(self, *, fact_id: str, limit: Optional[int] = None) -> dict:
161
+ """Resolves a fact to its record."""
162
+ body: dict = {"id": fact_id}
163
+ if limit is not None:
164
+ body["limit"] = limit
165
+ return await self._post("/v1/citations/resolve", body, 200)
166
+
167
+ async def _post(self, path: str, body: dict, success: int) -> dict:
168
+ response = await self._http.post(self.base_url + path, json=body, headers=self._headers)
169
+ return self._read(response, success)
170
+
171
+ @staticmethod
172
+ def _read(response: httpx.Response, success: int) -> dict:
173
+ if response.status_code == success:
174
+ return response.json()
175
+ code, message = "unexpected_response", "the deployment answered " + str(response.status_code)
176
+ try:
177
+ error = response.json().get("error") or {}
178
+ code = error.get("code") or code
179
+ message = error.get("message") or message
180
+ except ValueError:
181
+ pass
182
+ raise TaisceError(response.status_code, code, message)
183
+
184
+
185
+ def bundle_is_empty(bundle: Mapping[str, Any]) -> bool:
186
+ """Whether a recall returned nothing at all: no facts, no reports, no passages."""
187
+ return not (bundle.get("facts") or bundle.get("reports") or bundle.get("passages"))
@@ -0,0 +1,64 @@
1
+ # Copyright 2026 The Taisce Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """What every adapter renders the same way: the memory message and the turn key.
4
+
5
+ These live in the client so that the two Python adapters share one implementation, and so that
6
+ the bytes agree with the .NET adapter's, which derives them the same way: a turn stored by two
7
+ adapters is one observation, and a memory message from either parses the same.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import uuid
14
+ from typing import Mapping, Optional, Sequence
15
+
16
+ #: The first line of every injected memory message; the conformance suite parses it.
17
+ MEMORY_MESSAGE_PREFIX = "taisce-memory/v1 untrusted"
18
+
19
+
20
+ def render_memory_message(watermark: Mapping[str, object], bundle: Mapping[str, object]) -> str:
21
+ """The prefix line, then a JSON document with the watermark, the plan (what the recall did)
22
+ and the recall's own arrays unchanged."""
23
+ document = {
24
+ "watermark": {"stored": watermark.get("stored"), "formed": watermark.get("formed"), "parked": watermark.get("parked", 0)},
25
+ "plan": {"controls": bundle.get("controls"), "degraded": bundle.get("degraded") or [], "reach": bundle.get("reach")},
26
+ "facts": bundle.get("facts") or [],
27
+ "reports": bundle.get("reports") or [],
28
+ "passages": bundle.get("passages") or [],
29
+ }
30
+ return MEMORY_MESSAGE_PREFIX + "\n" + json.dumps(document, separators=(",", ":"))
31
+
32
+
33
+ def render_context_message(context: Mapping[str, object]) -> str:
34
+ """The prefix line, then a JSON document with the watermark, the assembly's cost as the plan,
35
+ and the context's segments and turns unchanged: what replaces a history is the deployment's
36
+ answer and nothing an adapter wrote."""
37
+ watermark = context.get("watermark") or {}
38
+ if not isinstance(watermark, Mapping):
39
+ watermark = {}
40
+ document = {
41
+ "watermark": {"stored": watermark.get("stored"), "formed": watermark.get("formed"), "parked": watermark.get("parked", 0)},
42
+ "plan": {"characters": context.get("characters") or 0, "truncated": bool(context.get("truncated"))},
43
+ "segments": context.get("segments") or [],
44
+ "turns": context.get("turns") or [],
45
+ }
46
+ return MEMORY_MESSAGE_PREFIX + "\n" + json.dumps(document, separators=(",", ":"))
47
+
48
+
49
+ def is_memory_text(text: Optional[str]) -> bool:
50
+ """Whether a message's text is one an adapter injected, by its first line."""
51
+ return bool(text) and text.startswith(MEMORY_MESSAGE_PREFIX)
52
+
53
+
54
+ def turn_key(data_subject_id: Optional[str], run_id: Optional[str], messages: Sequence[Mapping[str, str]]) -> str:
55
+ """A turn's key: a version-5 UUID over the subject, the run and the messages, so a retried
56
+ store is one observation and a different turn is another."""
57
+ text = (data_subject_id or "") + "\n" + (run_id or "") + "\n"
58
+ for m in messages:
59
+ text += m["role"] + "\n" + m["content"] + "\n"
60
+ digest = hashlib.sha256(text.encode("utf-8")).digest()
61
+ raw = bytearray(digest[:16])
62
+ raw[6] = (raw[6] & 0x0F) | 0x50
63
+ raw[8] = (raw[8] & 0x3F) | 0x80
64
+ return str(uuid.UUID(bytes=bytes(raw)))
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: taisce
3
+ Version: 0.1.0
4
+ Summary: Client for a Taisce memory deployment: the v1 contract, no agent framework.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+
10
+ # taisce
11
+
12
+ The client for a Taisce memory deployment over the v1 contract: `observe`, `freshness`, `recall`
13
+ and `resolve_citation`, asynchronous, with `httpx` as its only dependency. The agent framework
14
+ adapters in this repository are built on it.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ taisce/__init__.py
4
+ taisce/client.py
5
+ taisce/memory.py
6
+ taisce.egg-info/PKG-INFO
7
+ taisce.egg-info/SOURCES.txt
8
+ taisce.egg-info/dependency_links.txt
9
+ taisce.egg-info/requires.txt
10
+ taisce.egg-info/top_level.txt
11
+ tests/test_client.py
12
+ tests/test_context_message.py
@@ -0,0 +1 @@
1
+ httpx>=0.27
@@ -0,0 +1 @@
1
+ taisce
@@ -0,0 +1,20 @@
1
+ # Copyright 2026 The Taisce Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import pytest
4
+
5
+ from taisce import Client, bundle_is_empty
6
+
7
+
8
+ def test_a_client_refuses_to_be_built_without_a_deployment_or_a_credential():
9
+ with pytest.raises(ValueError):
10
+ Client("", "tsk")
11
+ with pytest.raises(ValueError):
12
+ Client("http://127.0.0.1:1", " ")
13
+ client = Client("http://127.0.0.1:1/", "tsk")
14
+ assert client.base_url == "http://127.0.0.1:1"
15
+
16
+
17
+ def test_an_empty_bundle_is_empty():
18
+ assert bundle_is_empty({"facts": [], "reports": [], "passages": []})
19
+ assert not bundle_is_empty({"facts": [{"fact_id": "f1"}], "reports": [], "passages": []})
20
+ assert not bundle_is_empty({"facts": [], "reports": [{"title": "t"}], "passages": []})
@@ -0,0 +1,29 @@
1
+ # Copyright 2026 The Taisce Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from taisce import Client, MEMORY_MESSAGE_PREFIX, is_memory_text, render_context_message
8
+
9
+
10
+ def test_the_context_message_is_the_prefix_line_and_the_deployments_arrays_unchanged():
11
+ context = {"watermark": {"stored": 12, "formed": 12}, "characters": 140, "truncated": False,
12
+ "segments": [{"level": 1, "summary": "The office moved."}],
13
+ "turns": [{"log_offset": 9, "messages": [{"role": "user", "content": "Book it."}]}]}
14
+ rendered = render_context_message(context)
15
+ assert rendered.startswith(MEMORY_MESSAGE_PREFIX + "\n") and is_memory_text(rendered)
16
+ document = json.loads(rendered.split("\n", 1)[1])
17
+ assert document["watermark"] == {"stored": 12, "formed": 12, "parked": 0}
18
+ assert document["plan"] == {"characters": 140, "truncated": False}
19
+ assert document["segments"] == context["segments"] and document["turns"] == context["turns"]
20
+ # A context with nothing in it renders empty arrays, never a missing field.
21
+ bare = json.loads(render_context_message({}).split("\n", 1)[1])
22
+ assert bare["segments"] == [] and bare["turns"] == [] and bare["watermark"]["stored"] is None
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ async def test_a_context_needs_a_subject():
27
+ async with Client("http://127.0.0.1:1", "tsk") as client:
28
+ with pytest.raises(ValueError):
29
+ await client.context(data_subject_id=" ")