remember 0.1__tar.gz → 0.2.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.
- remember-0.2.0/.gitignore +68 -0
- remember-0.2.0/PKG-INFO +78 -0
- remember-0.2.0/README.md +57 -0
- remember-0.2.0/pyproject.toml +53 -0
- remember-0.2.0/src/remember/__init__.py +74 -0
- remember-0.2.0/src/remember/cli.py +113 -0
- remember-0.2.0/src/remember/client.py +246 -0
- remember-0.2.0/src/remember/errors.py +76 -0
- remember-0.2.0/src/remember/models.py +211 -0
- remember-0.2.0/tests/test_cli.py +196 -0
- remember-0.2.0/tests/test_client.py +274 -0
- remember-0.2.0/uv.lock +598 -0
- remember-0.1/PKG-INFO +0 -14
- remember-0.1/remember/dicts.py +0 -241
- remember-0.1/remember/memoize.py +0 -259
- remember-0.1/remember/test/__init__.py +0 -0
- remember-0.1/remember/test/test_dicts.py +0 -303
- remember-0.1/remember/test/test_memoize.py +0 -53
- remember-0.1/setup.py +0 -21
- /remember-0.1/remember/__init__.py → /remember-0.2.0/src/remember/py.typed +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
.loopy_loop/sessions/
|
|
2
|
+
.loopy_loop/traces/
|
|
3
|
+
.loopy_loop/trace_finalization_outbox/
|
|
4
|
+
.loopy_loop/repository.json
|
|
5
|
+
.loopy_loop/state.json
|
|
6
|
+
.loopy_loop/state.json.lock
|
|
7
|
+
.loopy_loop/state.json.archive_*.json
|
|
8
|
+
|
|
9
|
+
# Disposable external research checkouts (never vendored)
|
|
10
|
+
.research/
|
|
11
|
+
|
|
12
|
+
# Python environments and generated caches
|
|
13
|
+
.venv/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
__pycache__/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
|
|
19
|
+
# IDE state
|
|
20
|
+
.idea/
|
|
21
|
+
|
|
22
|
+
# Cloudflare edge local secrets (never commit tokens)
|
|
23
|
+
ops/cloudflare_edge/config.local.toml
|
|
24
|
+
infra/benchmark-host/config.local.toml
|
|
25
|
+
infra/disposable-dp-host/config.local.toml
|
|
26
|
+
|
|
27
|
+
# Frontend install/build artifacts (source lives in fe/)
|
|
28
|
+
fe/node_modules/
|
|
29
|
+
fe/.next/
|
|
30
|
+
fe/out/
|
|
31
|
+
fe/*.tsbuildinfo
|
|
32
|
+
fe/.pages-deploy.digest
|
|
33
|
+
fe/.agent-browser-shots/
|
|
34
|
+
fe/.pen-exports/
|
|
35
|
+
|
|
36
|
+
# Observability host — secret-bearing / generated deploy artifacts (never commit)
|
|
37
|
+
infra/obs/.env
|
|
38
|
+
infra/obs/purge/purge.env
|
|
39
|
+
infra/obs/purge/projects.env
|
|
40
|
+
infra/obs/vmauth.rendered.yaml
|
|
41
|
+
infra/obs/Caddyfile.rendered
|
|
42
|
+
infra/obs/certs/
|
|
43
|
+
|
|
44
|
+
# Generic secret / rendered shapes (F2 hygiene)
|
|
45
|
+
*.pem
|
|
46
|
+
.env
|
|
47
|
+
*.env
|
|
48
|
+
!*.env.example
|
|
49
|
+
!infra/obs/.env.example
|
|
50
|
+
*.rendered.yaml
|
|
51
|
+
Caddyfile.rendered
|
|
52
|
+
**/umcobs0.conf
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
fe/storybook-static/
|
|
56
|
+
infra/benchmark-host/config.beam.local.toml
|
|
57
|
+
|
|
58
|
+
# fe_admin (D34)
|
|
59
|
+
fe_admin/node_modules/
|
|
60
|
+
fe_admin/out/
|
|
61
|
+
fe_admin/.next/
|
|
62
|
+
fe_admin/.agent-browser-shots/
|
|
63
|
+
fe_admin/storybook-static/
|
|
64
|
+
.dual-review/
|
|
65
|
+
|
|
66
|
+
# Built client distributions (published via the release workflow, never committed).
|
|
67
|
+
client/dist/
|
|
68
|
+
client/.venv/
|
remember-0.2.0/PKG-INFO
ADDED
|
@@ -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.
|
remember-0.2.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# remember
|
|
2
|
+
|
|
3
|
+
Control-plane client for the [remember.dev](https://remember.dev) managed memory service.
|
|
4
|
+
|
|
5
|
+
`rememberstack` answers *memory* questions — ingest a document, search claims, run an assured
|
|
6
|
+
operation. This package answers the questions an operator of that memory also has:
|
|
7
|
+
|
|
8
|
+
- is my deployment ready?
|
|
9
|
+
- what is my balance, and what did that ingest cost?
|
|
10
|
+
- has spend safety parked my work?
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from remember import CloudClient
|
|
14
|
+
|
|
15
|
+
with CloudClient.from_env() as cloud:
|
|
16
|
+
if cloud.is_ready():
|
|
17
|
+
print(cloud.billing_status().balance)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```console
|
|
21
|
+
$ remember-status
|
|
22
|
+
deployment active bb81063d-6b7b-41d0-a1df-dc57d4f1fe87
|
|
23
|
+
endpoint live bb81063d-6b7b-41d0-a1df-dc57d4f1fe87.dp.remember.dev
|
|
24
|
+
billing active balance 42.10
|
|
25
|
+
spend allow
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```console
|
|
31
|
+
pip install remember
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This installs `rememberstack` too. The `remember` **command** comes from that package and is
|
|
35
|
+
unaffected by this one; this distribution adds only `remember-status`.
|
|
36
|
+
|
|
37
|
+
## Credentials
|
|
38
|
+
|
|
39
|
+
This client authenticates with a **control-plane token** (`umc_cp_…`) — the D53 credential kind.
|
|
40
|
+
It is not the same thing as a deployment API token (`umc_dp_…`): that one authenticates *memory*
|
|
41
|
+
calls at the deployment's ingress and will be rejected here.
|
|
42
|
+
|
|
43
|
+
Mint one with `POST /v1/orgs/<org>/control-tokens` while signed in to the app. There is no button
|
|
44
|
+
for this yet — the API landed before the interface did — so today it is a call, not a click:
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
export REMEMBER_CLOUD_TOKEN='umc_cp_…'
|
|
48
|
+
export REMEMBER_CLOUD_ORG='your-organisation-id'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The secret is shown once. The credential is organisation-scoped, **read-only**, expires (90 days by
|
|
52
|
+
default), and can be surrendered by its holder at any time.
|
|
53
|
+
|
|
54
|
+
## What it will not do
|
|
55
|
+
|
|
56
|
+
No memory verbs, no second ingest or search contract. To *use* the memory, use `rememberstack`
|
|
57
|
+
pointed at your deployment; this package tells you what that memory costs and whether it is ready.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remember"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Control-plane client for the remember.dev managed memory service."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "WriteIt.ai s.r.o.", email = "info@writeit.ai" }]
|
|
13
|
+
keywords = ["remember.dev", "rememberstack", "memory", "agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# D43 rule 1: the cloud distribution may depend on the memory client; never the
|
|
24
|
+
# reverse. The memory verbs stay in rememberstack — this package adds only the
|
|
25
|
+
# control-plane questions that engine has no business answering.
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"rememberstack>=0.8.1",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://remember.dev"
|
|
33
|
+
Documentation = "https://remember.dev/docs"
|
|
34
|
+
Source = "https://github.com/writeitai/ultimate-memory-cloud"
|
|
35
|
+
|
|
36
|
+
# D43 rule 3: this project declares NO console script named `remember`. That
|
|
37
|
+
# entry point belongs to `rememberstack` alone, and colliding on it would be a
|
|
38
|
+
# PATH conflict between two installed distributions.
|
|
39
|
+
[project.scripts]
|
|
40
|
+
remember-status = "remember.cli:main"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/remember"]
|
|
44
|
+
|
|
45
|
+
# The client's tests are their own suite: the repository's root pytest run is
|
|
46
|
+
# scoped to ``src/tests`` and never collects them. Declaring the section here
|
|
47
|
+
# also makes ``client`` pytest's rootdir, so a run does not inherit the service's
|
|
48
|
+
# asyncio settings.
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[dependency-groups]
|
|
53
|
+
dev = ["pytest>=8.3", "respx>=0.21", "mypy>=1.14", "ruff>=0.9"]
|
|
@@ -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"
|
|
@@ -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())
|
|
@@ -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
|