remember 0.2.0__py3-none-any.whl

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.
remember/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """Control-plane client for the remember.dev managed service.
2
+
3
+ PyPI project **`remember`**, import name **`remember`** (D43 left the import name
4
+ open and required the first publish to document it; this is that record). The
5
+ distribution installs **no** ``remember`` console script — that entry point
6
+ belongs to ``rememberstack`` alone, and two distributions competing for one name
7
+ on ``PATH`` is the collision D43 rule 3 forbids.
8
+
9
+ What this package is for
10
+ ------------------------
11
+
12
+ ``rememberstack`` answers *memory* questions: ingest a document, search claims,
13
+ run an assured operation. It cannot answer the questions an operator of that
14
+ memory also has —
15
+
16
+ * is my deployment ready?
17
+ * what is my balance, and what did that ingest cost?
18
+ * has spend safety parked my work?
19
+
20
+ Those live on the control plane, which until D53 accepted only a browser session
21
+ cookie. This client speaks to it with a **control-plane token** (``umc_cp_…``):
22
+ organisation-scoped, read-only, bounded lifetime, revocable by its bearer.
23
+
24
+ from remember import CloudClient
25
+
26
+ with CloudClient.from_env() as cloud:
27
+ status = cloud.billing_status()
28
+ print(status.state, status.balance)
29
+
30
+ Getting a credential
31
+ --------------------
32
+
33
+ A control-plane token is not a deployment API token: `umc_dp_…` authenticates
34
+ *memory* calls at a deployment's ingress and is rejected here. Mint one with
35
+ `POST /v1/orgs/<org>/control-tokens` while signed in to the app — there is no
36
+ button for it yet, the API landed before the interface did. Then:
37
+
38
+ export REMEMBER_CLOUD_TOKEN='umc_cp_…'
39
+ export REMEMBER_CLOUD_ORG='your-organisation-id'
40
+
41
+ The secret is shown once. `remember login` does not yet mint this kind — that
42
+ needs an amendment to the device grant (D40), tracked in D53 §6.
43
+
44
+ What it deliberately does not do
45
+ --------------------------------
46
+
47
+ No memory verbs. No second ingest, search, or envelope contract (D35). If you
48
+ want to *use* the memory, use ``rememberstack``; this package tells you what the
49
+ memory costs and whether it is ready.
50
+ """
51
+
52
+ from remember.client import CloudClient
53
+ from remember.errors import CloudError
54
+ from remember.errors import NotPermitted
55
+ from remember.errors import RateLimited
56
+ from remember.errors import Unauthenticated
57
+ from remember.models import BillingStatus
58
+ from remember.models import Deployment
59
+ from remember.models import LedgerEntry
60
+ from remember.models import SpendGate
61
+
62
+ __all__ = [
63
+ "BillingStatus",
64
+ "CloudClient",
65
+ "CloudError",
66
+ "Deployment",
67
+ "LedgerEntry",
68
+ "NotPermitted",
69
+ "RateLimited",
70
+ "SpendGate",
71
+ "Unauthenticated",
72
+ ]
73
+
74
+ __version__ = "0.2.0"
remember/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ """``remember-status`` — one command that answers the operator's question.
2
+
3
+ Named for what it does, and deliberately **not** ``remember``: that console
4
+ script belongs to ``rememberstack`` alone (D43 rule 3), and two installed
5
+ distributions competing for one name on ``PATH`` is a conflict no user should
6
+ have to debug.
7
+
8
+ $ remember-status
9
+ deployment active bb81063d-…dc57d4f1fe87
10
+ endpoint live bb81063d-….dp.remember.dev
11
+ billing active balance 42.10
12
+ spend allow
13
+
14
+ Exit status is meaningful, so a shell can branch: ``0`` ready, ``1`` not ready,
15
+ ``2`` could not ask.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ from collections.abc import Sequence
22
+ import sys
23
+
24
+ from remember.client import CloudClient
25
+ from remember.errors import CloudError
26
+ from remember.errors import RateLimited
27
+ from remember.errors import Unauthenticated
28
+
29
+
30
+ def main(argv: Sequence[str] | None = None) -> int:
31
+ """Print one organisation's status; return an exit code a script can use."""
32
+ parser = argparse.ArgumentParser(
33
+ prog="remember-status",
34
+ description=(
35
+ "Report a remember.dev organisation's deployment, billing, and "
36
+ "spend state. Reads REMEMBER_CLOUD_TOKEN and REMEMBER_CLOUD_ORG."
37
+ ),
38
+ )
39
+ parser.add_argument("--org", default=None, help="organisation id")
40
+ parser.add_argument("--url", default=None, help="control-plane base URL")
41
+ parser.add_argument(
42
+ "--quiet", action="store_true", help="print nothing; use the exit status only"
43
+ )
44
+ args = parser.parse_args(argv)
45
+
46
+ try:
47
+ overrides = {}
48
+ if args.org:
49
+ overrides["org_id"] = args.org
50
+ if args.url:
51
+ overrides["base_url"] = args.url
52
+ with CloudClient.from_env(**overrides) as cloud:
53
+ return _report(cloud=cloud, quiet=bool(args.quiet))
54
+ except ValueError as error:
55
+ # Missing configuration: tell the user what to set, not a stack trace.
56
+ print(f"remember-status: {error}", file=sys.stderr)
57
+ return 2
58
+ except Unauthenticated as error:
59
+ print(
60
+ f"remember-status: credential rejected ({error}). "
61
+ "Mint a fresh control-plane token with "
62
+ "POST /v1/orgs/<org>/control-tokens while signed in.",
63
+ file=sys.stderr,
64
+ )
65
+ return 2
66
+ except RateLimited as error:
67
+ wait = f" retry in {error.retry_after:.0f}s" if error.retry_after else ""
68
+ print(f"remember-status: rate limited{wait}", file=sys.stderr)
69
+ return 2
70
+ except CloudError as error:
71
+ print(f"remember-status: {error}", file=sys.stderr)
72
+ return 2
73
+
74
+
75
+ def _report(*, cloud: CloudClient, quiet: bool) -> int:
76
+ """Print the four facts worth knowing, and decide the exit code."""
77
+ deployment = cloud.deployment()
78
+ billing = cloud.billing_status()
79
+ gate = None
80
+ if deployment is not None:
81
+ gate = cloud.spend_gate(deployment_id=deployment.id)
82
+
83
+ if not quiet:
84
+ if deployment is None:
85
+ _line("deployment", "none", "no deployment provisioned yet")
86
+ else:
87
+ _line("deployment", deployment.state, deployment.id)
88
+ _line(
89
+ "endpoint",
90
+ "live" if deployment.hostname_live else "not serving",
91
+ deployment.hostname or "unknown",
92
+ )
93
+ balance = f"balance {billing.balance}" if billing.balance else ""
94
+ _line("billing", billing.state, balance)
95
+ if gate is not None:
96
+ _line("spend", gate.decision, gate.reason_code or "")
97
+
98
+ # Every fact printed above counts toward the exit code. A script that
99
+ # branches on `remember-status` is asking "can work run right now", and a
100
+ # zero exit while the spend gate says `park` would send it straight into a
101
+ # refusal. `is_ready` already covers endpoint liveness as well as state.
102
+ ready = (
103
+ deployment is not None
104
+ and deployment.is_ready
105
+ and billing.can_spend
106
+ and (gate is None or gate.allows_work)
107
+ )
108
+ return 0 if ready else 1
109
+
110
+
111
+ def _line(label: str, state: str, detail: str = "") -> None:
112
+ """One aligned row, so several runs stack readably in a terminal."""
113
+ print(f"{label:<11} {state:<10} {detail}".rstrip())
remember/client.py ADDED
@@ -0,0 +1,246 @@
1
+ """The control-plane client.
2
+
3
+ One class, a handful of read methods, and no memory verbs. Everything it can do
4
+ is what D53's ``status:read`` profile permits — which is deliberate: a credential
5
+ that could do more would be a credential worth stealing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from collections.abc import Mapping
12
+ from contextlib import contextmanager
13
+ import os
14
+ from types import TracebackType
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from remember.errors import CloudError
20
+ from remember.errors import NotPermitted
21
+ from remember.errors import RateLimited
22
+ from remember.errors import Unauthenticated
23
+ from remember.models import BillingStatus
24
+ from remember.models import Deployment
25
+ from remember.models import LedgerEntry
26
+ from remember.models import SpendGate
27
+
28
+ #: Where the managed control plane lives. Overridable for local dogfood.
29
+ DEFAULT_BASE_URL = "https://remember.dev/app/api"
30
+
31
+ #: Environment variables, named so they cannot be confused with the memory
32
+ #: client's ``REMEMBERSTACK_*`` pair — a machine often holds both.
33
+ TOKEN_ENV = "REMEMBER_CLOUD_TOKEN"
34
+ ORG_ENV = "REMEMBER_CLOUD_ORG"
35
+ BASE_URL_ENV = "REMEMBER_CLOUD_URL"
36
+
37
+
38
+ class CloudClient:
39
+ """Ask the control plane what it knows about one organisation.
40
+
41
+ The credential is organisation-bound, so the organisation is fixed for the
42
+ life of the client rather than passed per call: a control token cannot act
43
+ on another organisation, and an API that invited you to try would be
44
+ misleading.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ token: str,
51
+ org_id: str,
52
+ base_url: str = DEFAULT_BASE_URL,
53
+ timeout: float = 30.0,
54
+ transport: httpx.BaseTransport | None = None,
55
+ ) -> None:
56
+ """Bind a credential to one organisation."""
57
+ if not token:
58
+ raise ValueError("a control-plane token is required")
59
+ if not org_id:
60
+ raise ValueError("an organisation id is required")
61
+ self._org_id = org_id
62
+ self._http = httpx.Client(
63
+ base_url=base_url.rstrip("/"),
64
+ timeout=timeout,
65
+ transport=transport,
66
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
67
+ )
68
+
69
+ @classmethod
70
+ def from_env(cls, **overrides: Any) -> "CloudClient":
71
+ """Build from ``REMEMBER_CLOUD_TOKEN`` / ``_ORG`` / ``_URL``.
72
+
73
+ The usual shape for an agent: credentials in the environment, nothing in
74
+ the code.
75
+ """
76
+ token = overrides.pop("token", None) or os.getenv(TOKEN_ENV, "")
77
+ org_id = overrides.pop("org_id", None) or os.getenv(ORG_ENV, "")
78
+ base_url = (
79
+ overrides.pop("base_url", None)
80
+ or os.getenv(BASE_URL_ENV)
81
+ or DEFAULT_BASE_URL
82
+ )
83
+ if not token:
84
+ raise ValueError(
85
+ f"set {TOKEN_ENV} to a control-plane token (umc_cp_…). "
86
+ "Mint one with POST /v1/orgs/<org>/control-tokens while signed "
87
+ "in; a deployment token (umc_dp_…) is a different credential "
88
+ "and the control plane rejects it"
89
+ )
90
+ if not org_id:
91
+ raise ValueError(f"set {ORG_ENV} to your organisation id")
92
+ return cls(token=token, org_id=org_id, base_url=base_url, **overrides)
93
+
94
+ @property
95
+ def org_id(self) -> str:
96
+ """The organisation this credential is bound to."""
97
+ return self._org_id
98
+
99
+ # -- the questions -------------------------------------------------
100
+
101
+ def billing_status(self) -> BillingStatus:
102
+ """Whether chargeable work may run, and what the balance is."""
103
+ return BillingStatus.from_payload(
104
+ self._get(f"/v1/orgs/{self._org_id}/billing/status")
105
+ )
106
+
107
+ def deployments(self) -> list[Deployment]:
108
+ """Every deployment this organisation has (today, zero or one)."""
109
+ payload = self._get(f"/v1/orgs/{self._org_id}/deployments")
110
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
111
+ return [Deployment.from_payload(row) for row in rows]
112
+
113
+ def deployment(self) -> Deployment | None:
114
+ """The organisation's deployment, or None before one is provisioned."""
115
+ found = self.deployments()
116
+ return found[0] if found else None
117
+
118
+ def ledger(self, *, limit: int = 50) -> list[LedgerEntry]:
119
+ """The credit ledger: what was charged, newest first as the server sends.
120
+
121
+ ``limit`` is bounded by the server to 1..200; values outside that range
122
+ are rejected there rather than silently clamped here, so a caller sees
123
+ its own mistake.
124
+ """
125
+ payload = self._get(
126
+ f"/v1/orgs/{self._org_id}/billing/ledger", params={"limit": limit}
127
+ )
128
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
129
+ return [LedgerEntry.from_payload(row) for row in rows]
130
+
131
+ def spend_gate(self, *, deployment_id: str) -> SpendGate:
132
+ """May work dispatch right now — and if not, why.
133
+
134
+ Worth asking before a large ingest: a refusal here is cheaper than a
135
+ refusal halfway through one.
136
+ """
137
+ return SpendGate.from_payload(
138
+ self._get(
139
+ f"/v1/orgs/{self._org_id}/deployments/{deployment_id}/spend-safety/gate"
140
+ )
141
+ )
142
+
143
+ def is_ready(self) -> bool:
144
+ """One call an agent can branch on: is there a deployment able to serve.
145
+
146
+ Convenience over :meth:`deployment`, because "am I ready" is the
147
+ question actually being asked.
148
+ """
149
+ found = self.deployment()
150
+ return found is not None and found.is_ready
151
+
152
+ # -- plumbing ------------------------------------------------------
153
+
154
+ def _get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
155
+ """Perform a read, translating D41 error envelopes into exceptions."""
156
+ try:
157
+ response = self._http.get(path, params=params)
158
+ except httpx.TimeoutException as error:
159
+ raise CloudError(f"timed out calling {path}", retryable=True) from error
160
+ except httpx.HTTPError as error:
161
+ raise CloudError(f"could not reach {path}: {error}") from error
162
+
163
+ if response.is_success:
164
+ return response.json()
165
+ raise _as_error(response)
166
+
167
+ def close(self) -> None:
168
+ """Release the underlying connection pool."""
169
+ self._http.close()
170
+
171
+ def __enter__(self) -> "CloudClient":
172
+ """Support ``with CloudClient(...) as cloud:``."""
173
+ return self
174
+
175
+ def __exit__(
176
+ self,
177
+ exc_type: type[BaseException] | None,
178
+ exc: BaseException | None,
179
+ tb: TracebackType | None,
180
+ ) -> None:
181
+ """Close on exit."""
182
+ self.close()
183
+
184
+
185
+ def _as_error(response: httpx.Response) -> CloudError:
186
+ """Turn a non-success response into the narrowest exception that fits.
187
+
188
+ D41's envelope is ``{"detail": {code, message, retryable, request_id}}``. A
189
+ response that does not carry it — a proxy error page, say — still produces a
190
+ typed exception, so a caller never has to handle two failure shapes.
191
+ """
192
+ code: str | None = None
193
+ message = f"HTTP {response.status_code}"
194
+ retryable = False
195
+ request_id = response.headers.get("X-Request-Id")
196
+
197
+ with _tolerating_bad_json():
198
+ body = response.json()
199
+ detail = body.get("detail") if isinstance(body, dict) else None
200
+ if isinstance(detail, dict):
201
+ code = detail.get("code")
202
+ message = detail.get("message") or message
203
+ retryable = bool(detail.get("retryable", False))
204
+ request_id = detail.get("request_id") or request_id
205
+ elif isinstance(detail, str):
206
+ # Pre-D41 routes still answer with a bare string.
207
+ message = detail
208
+
209
+ shared = {
210
+ "status_code": response.status_code,
211
+ "code": code,
212
+ "retryable": retryable,
213
+ "request_id": request_id,
214
+ }
215
+ if response.status_code == 401:
216
+ return Unauthenticated(message, **shared) # type: ignore[arg-type]
217
+ if response.status_code == 403:
218
+ return NotPermitted(message, **shared) # type: ignore[arg-type]
219
+ if response.status_code == 429:
220
+ return RateLimited(
221
+ message,
222
+ retry_after=_retry_after(response),
223
+ **shared, # type: ignore[arg-type]
224
+ )
225
+ return CloudError(message, **shared) # type: ignore[arg-type]
226
+
227
+
228
+ def _retry_after(response: httpx.Response) -> float | None:
229
+ """Seconds from ``Retry-After``, when the server sent a usable one."""
230
+ raw = response.headers.get("Retry-After")
231
+ if not raw:
232
+ return None
233
+ try:
234
+ return float(raw)
235
+ except ValueError:
236
+ # HTTP-date form; the caller's own backoff is better than a bad guess.
237
+ return None
238
+
239
+
240
+ @contextmanager
241
+ def _tolerating_bad_json() -> Iterator[None]:
242
+ """Ignore an unparseable error body rather than masking the real failure."""
243
+ try:
244
+ yield
245
+ except (ValueError, AttributeError):
246
+ return
remember/errors.py ADDED
@@ -0,0 +1,76 @@
1
+ """Failures a control-plane call can produce, typed so a caller can branch.
2
+
3
+ D41 binds one error envelope for machine traffic:
4
+
5
+ {"detail": {"code": …, "message": …, "retryable": bool, "request_id": …}}
6
+
7
+ Clients branch on ``code``, never on ``message`` — the message is operator-safe
8
+ prose and explicitly non-authoritative. These exceptions carry the code through
9
+ so a caller can do the same, and expose ``retryable`` rather than making every
10
+ caller re-derive it from a status number.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ class CloudError(Exception):
17
+ """Base for every control-plane failure.
18
+
19
+ Carries the D41 envelope fields when the server sent them. A response that
20
+ is not shaped like the envelope still produces one of these — with
21
+ ``code=None`` — so a caller never has to handle two failure shapes.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ *,
28
+ status_code: int | None = None,
29
+ code: str | None = None,
30
+ retryable: bool = False,
31
+ request_id: str | None = None,
32
+ ) -> None:
33
+ """Bind the envelope fields that were present."""
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+ self.code = code
37
+ self.retryable = retryable
38
+ self.request_id = request_id
39
+
40
+ def __str__(self) -> str:
41
+ """Include the request id, because support will ask for it."""
42
+ base = super().__str__()
43
+ return f"{base} (request_id={self.request_id})" if self.request_id else base
44
+
45
+
46
+ class Unauthenticated(CloudError):
47
+ """No credential, or one the server will not accept (401).
48
+
49
+ Also raised for a revoked or expired credential, and for one whose
50
+ membership has ended — from a client's point of view these are the same
51
+ situation: sign in again and mint a fresh credential.
52
+ """
53
+
54
+
55
+ class NotPermitted(CloudError):
56
+ """Authenticated, but this credential may not do this (403).
57
+
58
+ Distinct from :class:`Unauthenticated` because retrying will not help and a
59
+ fresh credential of the same profile will not either — the profile itself
60
+ does not permit the call, or the credential belongs to a different
61
+ organisation.
62
+ """
63
+
64
+
65
+ class RateLimited(CloudError):
66
+ """Admission refused the call (429).
67
+
68
+ ``retry_after`` is the server's own advice in seconds when it gave any.
69
+ """
70
+
71
+ def __init__(
72
+ self, message: str, *, retry_after: float | None = None, **kwargs: object
73
+ ) -> None:
74
+ """Bind the retry hint alongside the envelope fields."""
75
+ super().__init__(message, **kwargs) # type: ignore[arg-type]
76
+ self.retry_after = retry_after
remember/models.py ADDED
@@ -0,0 +1,211 @@
1
+ """What the control plane returns, narrowed to what a caller needs.
2
+
3
+ These are deliberately *not* generated from OpenAPI. The generated client is the
4
+ browser app's contract and changes with it; a published wheel needs a surface
5
+ that stays still. So each model takes the fields this package promises and
6
+ ignores the rest, and an added server field is a non-event rather than a
7
+ breakage.
8
+
9
+ Unknown enum-ish values are kept as strings for the same reason: a new
10
+ deployment state should print, not raise.
11
+
12
+ **The field names here are a contract with the server, not a convention.** They
13
+ were wrong once — this client read ``balance`` and compared the billing state to
14
+ lowercase ``"active"``, while the control plane sends ``balance_credits`` and
15
+ ``ACTIVE`` — and every test passed, because the tests asserted the same invented
16
+ shape. ``src/tests/test_remember_client_contract.py`` now feeds real server
17
+ response models through these parsers, so the next such drift fails in CI
18
+ instead of in a user's terminal.
19
+
20
+ Two conventions in the API this file has to absorb, rather than pass on:
21
+
22
+ * the two state enums disagree about case. ``DeploymentState`` is lowercase
23
+ (``active``); ``BillingState`` is uppercase (``ACTIVE``). Comparisons here are
24
+ case-insensitive so a caller never has to know which is which.
25
+ * money arrives as a JSON string (Pydantic renders ``Decimal`` that way) and
26
+ stays a string here. It is displayed and compared, never arithmetic'd by this
27
+ client, and ``float`` would be the wrong type for money.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Mapping
33
+ from dataclasses import dataclass
34
+ from datetime import datetime
35
+ from typing import Any
36
+
37
+
38
+ def _text(payload: Mapping[str, Any], key: str) -> str | None:
39
+ """A string field, or None when absent or empty."""
40
+ value = payload.get(key)
41
+ return value if isinstance(value, str) and value else None
42
+
43
+
44
+ def _money(payload: Mapping[str, Any], *keys: str) -> str | None:
45
+ """A decimal amount as a string, from the first key that carries one.
46
+
47
+ Accepts a JSON number as well as a string: the server renders ``Decimal`` as
48
+ a string today, and a client that broke if that changed would be brittle for
49
+ no benefit. Several keys may be given where the API has a preferred field
50
+ and a deprecated alias.
51
+ """
52
+ for key in keys:
53
+ value = payload.get(key)
54
+ if isinstance(value, str) and value:
55
+ return value
56
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
57
+ return str(value)
58
+ return None
59
+
60
+
61
+ def _moment(payload: Mapping[str, Any], key: str) -> datetime | None:
62
+ """An ISO-8601 timestamp, or None when absent or unparseable."""
63
+ raw = _text(payload, key)
64
+ if raw is None:
65
+ return None
66
+ try:
67
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
68
+ except ValueError:
69
+ return None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Deployment:
74
+ """One deployment's identity and where a client reaches it."""
75
+
76
+ id: str
77
+ state: str
78
+ hostname: str | None
79
+ hostname_live: bool
80
+ created_at: datetime | None
81
+
82
+ @property
83
+ def is_ready(self) -> bool:
84
+ """True when this deployment can actually be reached.
85
+
86
+ Both halves are required. ``state == "active"`` is the control plane's
87
+ view of provisioning, but the hostname is a *designed* public name that
88
+ does not resolve to a serving process until the live flag says so
89
+ (D33) — so an active deployment whose flag is false would hand a caller
90
+ an endpoint that refuses connections.
91
+
92
+ The engine's own readiness — whether the memory is warm — is a separate
93
+ question, asked through ``rememberstack``.
94
+ """
95
+ return self.state.lower() == "active" and self.hostname_live
96
+
97
+ @classmethod
98
+ def from_payload(cls, payload: Mapping[str, Any]) -> Deployment:
99
+ """Narrow a deployment response."""
100
+ return cls(
101
+ id=_text(payload, "id") or "",
102
+ state=_text(payload, "state") or "unknown",
103
+ hostname=_text(payload, "data_plane_hostname"),
104
+ hostname_live=bool(payload.get("data_plane_hostname_live", False)),
105
+ created_at=_moment(payload, "created_at"),
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class BillingStatus:
111
+ """Whether this organisation may incur chargeable work, and what it has."""
112
+
113
+ state: str
114
+ balance: str | None
115
+ cap: str | None
116
+
117
+ @property
118
+ def can_spend(self) -> bool:
119
+ """True when billing is in a state that permits chargeable work.
120
+
121
+ Case-insensitive: ``BillingState`` sends ``ACTIVE``, and a caller
122
+ should not have to know that.
123
+ """
124
+ return self.state.upper() == "ACTIVE"
125
+
126
+ @classmethod
127
+ def from_payload(cls, payload: Mapping[str, Any]) -> BillingStatus:
128
+ """Narrow a billing-status response (``CommercialStatusResponse``)."""
129
+ return cls(
130
+ state=_text(payload, "billing_state") or "unknown",
131
+ balance=_money(payload, "balance_credits"),
132
+ cap=_money(payload, "monthly_cap_credits"),
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class SpendGate:
138
+ """The pre-dispatch decision: may work run right now, and if not, why.
139
+
140
+ This is the question an agent should ask before a large ingest, and the one
141
+ that explains a refusal after the fact.
142
+ """
143
+
144
+ decision: str
145
+ reason_code: str | None
146
+ spent_usd: str | None
147
+ ceiling_usd: str | None
148
+ parked: bool
149
+
150
+ @property
151
+ def allows_work(self) -> bool:
152
+ """True when the gate currently permits dispatch."""
153
+ return self.decision.lower() == "allow"
154
+
155
+ @property
156
+ def is_parked(self) -> bool:
157
+ """True when work is paused rather than refused.
158
+
159
+ Parked is recoverable — a cap raise or an operator action releases it —
160
+ where refused means the request will not succeed as sent.
161
+
162
+ Taken from the server's own ``is_parked`` flag rather than inferred from
163
+ the decision: a policy can be parked while the decision reads something
164
+ else, and the server is the authority on which.
165
+ """
166
+ return self.parked
167
+
168
+ @classmethod
169
+ def from_payload(cls, payload: Mapping[str, Any]) -> SpendGate:
170
+ """Narrow a spend-gate response (``DispatchGateResponse``)."""
171
+ decision = _text(payload, "decision") or "unknown"
172
+ return cls(
173
+ decision=decision,
174
+ reason_code=_text(payload, "reason_code"),
175
+ # ``spent_usd`` is the server's deprecated alias for
176
+ # ``estimate_spent_usd``; prefer the current name, accept the old.
177
+ spent_usd=_money(payload, "estimate_spent_usd", "spent_usd"),
178
+ ceiling_usd=_money(payload, "ceiling_usd"),
179
+ parked=bool(payload.get("is_parked", decision.lower() == "park")),
180
+ )
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class LedgerEntry:
185
+ """One append-only credit-ledger line: what was charged, and what remained.
186
+
187
+ The ledger answers "what did that cost me", which D43 expects this
188
+ distribution to be able to show alongside status and balance.
189
+ """
190
+
191
+ entry_id: str
192
+ position: int | None
193
+ entry_type: str
194
+ amount: str | None
195
+ balance_after: str | None
196
+ description: str | None
197
+ created_at: datetime | None
198
+
199
+ @classmethod
200
+ def from_payload(cls, payload: Mapping[str, Any]) -> LedgerEntry:
201
+ """Narrow a ledger entry (``LedgerEntryResponse``)."""
202
+ position = payload.get("ledger_position")
203
+ return cls(
204
+ entry_id=_text(payload, "credit_entry_id") or "",
205
+ position=position if isinstance(position, int) else None,
206
+ entry_type=_text(payload, "entry_type") or "unknown",
207
+ amount=_money(payload, "amount"),
208
+ balance_after=_money(payload, "balance_after"),
209
+ description=_text(payload, "description"),
210
+ created_at=_moment(payload, "created_at"),
211
+ )
remember/py.typed ADDED
File without changes
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: remember
3
+ Version: 0.2.0
4
+ Summary: Control-plane client for the remember.dev managed memory service.
5
+ Project-URL: Homepage, https://remember.dev
6
+ Project-URL: Documentation, https://remember.dev/docs
7
+ Project-URL: Source, https://github.com/writeitai/ultimate-memory-cloud
8
+ Author-email: "WriteIt.ai s.r.o." <info@writeit.ai>
9
+ License: Apache-2.0
10
+ Keywords: agents,memory,remember.dev,rememberstack
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: rememberstack>=0.8.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # remember
23
+
24
+ Control-plane client for the [remember.dev](https://remember.dev) managed memory service.
25
+
26
+ `rememberstack` answers *memory* questions — ingest a document, search claims, run an assured
27
+ operation. This package answers the questions an operator of that memory also has:
28
+
29
+ - is my deployment ready?
30
+ - what is my balance, and what did that ingest cost?
31
+ - has spend safety parked my work?
32
+
33
+ ```python
34
+ from remember import CloudClient
35
+
36
+ with CloudClient.from_env() as cloud:
37
+ if cloud.is_ready():
38
+ print(cloud.billing_status().balance)
39
+ ```
40
+
41
+ ```console
42
+ $ remember-status
43
+ deployment active bb81063d-6b7b-41d0-a1df-dc57d4f1fe87
44
+ endpoint live bb81063d-6b7b-41d0-a1df-dc57d4f1fe87.dp.remember.dev
45
+ billing active balance 42.10
46
+ spend allow
47
+ ```
48
+
49
+ ## Install
50
+
51
+ ```console
52
+ pip install remember
53
+ ```
54
+
55
+ This installs `rememberstack` too. The `remember` **command** comes from that package and is
56
+ unaffected by this one; this distribution adds only `remember-status`.
57
+
58
+ ## Credentials
59
+
60
+ This client authenticates with a **control-plane token** (`umc_cp_…`) — the D53 credential kind.
61
+ It is not the same thing as a deployment API token (`umc_dp_…`): that one authenticates *memory*
62
+ calls at the deployment's ingress and will be rejected here.
63
+
64
+ Mint one with `POST /v1/orgs/<org>/control-tokens` while signed in to the app. There is no button
65
+ for this yet — the API landed before the interface did — so today it is a call, not a click:
66
+
67
+ ```console
68
+ export REMEMBER_CLOUD_TOKEN='umc_cp_…'
69
+ export REMEMBER_CLOUD_ORG='your-organisation-id'
70
+ ```
71
+
72
+ The secret is shown once. The credential is organisation-scoped, **read-only**, expires (90 days by
73
+ default), and can be surrendered by its holder at any time.
74
+
75
+ ## What it will not do
76
+
77
+ No memory verbs, no second ingest or search contract. To *use* the memory, use `rememberstack`
78
+ pointed at your deployment; this package tells you what that memory costs and whether it is ready.
@@ -0,0 +1,10 @@
1
+ remember/__init__.py,sha256=v6mMsS-pndNfiQ6PyNI5miatwCMoCiwBQoYoEpGuorU,2617
2
+ remember/cli.py,sha256=ccGGEPce5J382n3MnzHlGIjW9TBBmYfm1gXhjYUo-lc,4266
3
+ remember/client.py,sha256=N9BcXCsgGTuKt-Rqhgw_CZtgxKk6fquL5cp8PJy1Ds8,9050
4
+ remember/errors.py,sha256=r9NqkeuGG8FCKk8w7jpxzGNsov0geGXj0kYdI7xz4pg,2643
5
+ remember/models.py,sha256=x977fyTNdcmwRathe0w5phXOAzBVx8DnlTFjSXHaJSk,7928
6
+ remember/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ remember-0.2.0.dist-info/METADATA,sha256=RP_T6cIRI3TZ22bi0Du-kc9xpCOzJWGsaBh-BE_94bE,2768
8
+ remember-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ remember-0.2.0.dist-info/entry_points.txt,sha256=DscNeNcF7FdWiAJMO-PUEPErgrBVnNZMbqkaZjXiBQM,54
10
+ remember-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ remember-status = remember.cli:main