scry-client 0.6.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.
- scry_client/__init__.py +15 -0
- scry_client/client.py +376 -0
- scry_client/evaluator.py +181 -0
- scry_client/heartbeat.py +97 -0
- scry_client/init_cli.py +99 -0
- scry_client/play.py +157 -0
- scry_client-0.6.0.dist-info/METADATA +199 -0
- scry_client-0.6.0.dist-info/RECORD +11 -0
- scry_client-0.6.0.dist-info/WHEEL +5 -0
- scry_client-0.6.0.dist-info/entry_points.txt +3 -0
- scry_client-0.6.0.dist-info/top_level.txt +1 -0
scry_client/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""scry-client — call the hosted scry meter (signed channel-coupling
|
|
2
|
+
attestation), play the fun layer (augury / duels / table / arena), and use
|
|
3
|
+
the agency (adopt + drive hosted familiars, P2).
|
|
4
|
+
|
|
5
|
+
Trust-layer-as-a-service: `Evaluator` wraps the meter as a neutral,
|
|
6
|
+
third-party evaluator for YOUR product — fetch a read, verify it offline
|
|
7
|
+
against a pinned key, gate on a checked fact. Money never moves the number.
|
|
8
|
+
"""
|
|
9
|
+
from .client import ScryClient, ScryError
|
|
10
|
+
from .play import ScryPlay, ScryPlayError
|
|
11
|
+
from .evaluator import Evaluator, ConductRead, Decision, REFERENCE_PUBKEY_B64
|
|
12
|
+
|
|
13
|
+
__version__ = "0.5.0"
|
|
14
|
+
__all__ = ["ScryClient", "ScryError", "ScryPlay", "ScryPlayError",
|
|
15
|
+
"Evaluator", "ConductRead", "Decision", "REFERENCE_PUBKEY_B64"]
|
scry_client/client.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""scry-client — call the hosted scry meter from an agent, without the tricks.
|
|
2
|
+
|
|
3
|
+
The scry meter is a neutral, signed channel-coupling read (Paper 207): POST a
|
|
4
|
+
trace of {Y, M, D, context} turns, get back the I(C;D) / I(C;M) / switch-signature
|
|
5
|
+
coupling numbers, Ed25519-signed and bound to your trace's hash. Because the
|
|
6
|
+
endpoint signs it, the read is a third-party attestation — worth something
|
|
7
|
+
*because the agent didn't grade itself*.
|
|
8
|
+
|
|
9
|
+
Three things this hides so you don't have to reimplement them:
|
|
10
|
+
|
|
11
|
+
1. The paid rail is Robinhood Chain USDG (mainnet). Paying it means the x402
|
|
12
|
+
"402 -> pay -> retry" dance with a one-time Permit2 approval. `.profile(...)`
|
|
13
|
+
with a `private_key` does the whole thing via the official x402 client.
|
|
14
|
+
2. The meter accepts either the `X-PAYMENT` or `PAYMENT-SIGNATURE` header — this
|
|
15
|
+
client uses the official one; both work server-side.
|
|
16
|
+
3. `.verify(...)` checks the Ed25519 signature so you can trust an attestation
|
|
17
|
+
someone else hands you, offline, without calling us.
|
|
18
|
+
|
|
19
|
+
Free, zero-wallet path: `.demo(turns)` returns the same numbers UNSIGNED (not an
|
|
20
|
+
attestation) for trying the shape. `.verify(...)` needs only `cryptography`.
|
|
21
|
+
The paid `.profile(...)` needs the extra: pip install "scry-client[pay]"
|
|
22
|
+
|
|
23
|
+
The agency (P2, hosted familiars): `.agency()` reads the roster free;
|
|
24
|
+
`.summon_familiar(...)` adopts a hosted agent-worker over the SAME paid rail;
|
|
25
|
+
`.talk(...)` / `.tick(...)` / `.dismiss(...)` drive it with free, owner-signed
|
|
26
|
+
(EIP-191) calls. Signing uses eth-account, which the [pay] extra already ships.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import requests
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE = "https://scry.moreright.xyz/api"
|
|
38
|
+
RH_RPC = "https://rpc.mainnet.chain.robinhood.com"
|
|
39
|
+
RH_NETWORK = "eip155:4663"
|
|
40
|
+
BASE_RPC = "https://mainnet.base.org"
|
|
41
|
+
BASE_NETWORK = "eip155:8453"
|
|
42
|
+
|
|
43
|
+
# The EVM rails the meter posts in its 402 challenge, and the RPC each one needs
|
|
44
|
+
# to sign against. Until 2026-07-26 this client registered ONLY `eip155:4663`,
|
|
45
|
+
# which made the Base rail unreachable from here no matter what the caller did —
|
|
46
|
+
# `.profile(key)` would sign an RH payment or fail, and the 402 hint even said
|
|
47
|
+
# "check USDG balance on Robinhood Chain". That is not a cosmetic gap: the Base/
|
|
48
|
+
# Solana CDP rails are what feed the Bazaar index (a self-facilitated RH settle
|
|
49
|
+
# catalogs nothing), so the one call that unlocks agentic discovery could not be
|
|
50
|
+
# made with the published client. `DISTRIBUTION.md` §3 told operators to do
|
|
51
|
+
# exactly that and handed them this method to do it with.
|
|
52
|
+
EVM_RAILS = {
|
|
53
|
+
RH_NETWORK: {"name": "Robinhood Chain USDG", "rpc": RH_RPC, "asset": "USDG"},
|
|
54
|
+
BASE_NETWORK: {"name": "Base USDC", "rpc": BASE_RPC, "asset": "USDC"},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ScryError(RuntimeError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ScryClient:
|
|
63
|
+
def __init__(self, base_url: str = DEFAULT_BASE, rpc_url: str = RH_RPC, timeout: float = 60.0):
|
|
64
|
+
self.base = base_url.rstrip("/")
|
|
65
|
+
self.rpc_url = rpc_url
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
|
|
68
|
+
# ── free: unsigned read, no wallet ────────────────────────────────────────
|
|
69
|
+
def demo(self, turns: list[dict], context_key: str = "monitored") -> dict:
|
|
70
|
+
"""Free, rate-limited, UNSIGNED read — for trying the shape. Not an
|
|
71
|
+
attestation: the profile is real but there is no signature to trust."""
|
|
72
|
+
r = requests.post(f"{self.base}/demo/profile", timeout=self.timeout,
|
|
73
|
+
json={"turns": turns, "context_key": context_key})
|
|
74
|
+
return self._json_or_error(r, "/demo/profile", hints={
|
|
75
|
+
429: "demo daily limit reached — use .profile(...) for a paid, signed read"})
|
|
76
|
+
|
|
77
|
+
# ── paid: signed attestation, pays the RH-Chain USDG rail ─────────────────
|
|
78
|
+
def profile(self, turns: list[dict], private_key: str, context_key: str = "monitored",
|
|
79
|
+
idempotency_key: str | None = None,
|
|
80
|
+
network: str | None = None) -> dict:
|
|
81
|
+
"""Paid, SIGNED read. `private_key` funds the payment. Returns the
|
|
82
|
+
attested payload; pass it to `.verify(...)` to check the signature.
|
|
83
|
+
|
|
84
|
+
`network` picks the rail and DEFAULTS TO RH-Chain USDG
|
|
85
|
+
(`eip155:4663` — needs a little USDG + ETH gas there, plus a one-time
|
|
86
|
+
Permit2 approval the x402 client handles). Pass `"eip155:8453"` to pay
|
|
87
|
+
**Base USDC** instead, or `"all"` to register every EVM rail and let
|
|
88
|
+
the 402 challenge decide.
|
|
89
|
+
|
|
90
|
+
Which rail you pick has a consequence beyond the token: Base and
|
|
91
|
+
Solana settle through Coinbase's CDP facilitator, which is what
|
|
92
|
+
catalogs the endpoint into the **x402 Bazaar** discovery index. The
|
|
93
|
+
RH-Chain rail is self-facilitated on a chain no index watches, so it
|
|
94
|
+
settles real money and lists you nowhere. If your goal is discovery,
|
|
95
|
+
`network="eip155:8453"` is the call that matters.
|
|
96
|
+
|
|
97
|
+
Sends an `Idempotency-Key` header (server-honored): the same trace +
|
|
98
|
+
context within the server's TTL replays the SAME signed attestation
|
|
99
|
+
instead of minting a fresh one — a stable signed identity across
|
|
100
|
+
retries. Default key is deterministic (derived from the trace), so any
|
|
101
|
+
client instance retrying the identical request converges; pass your
|
|
102
|
+
own `idempotency_key` to scope dedup to your job id instead."""
|
|
103
|
+
net = network or RH_NETWORK
|
|
104
|
+
session = self._paying_session(private_key, net)
|
|
105
|
+
idem = idempotency_key or self.idempotency_key(turns, context_key)
|
|
106
|
+
r = session.post(f"{self.base}/profile", timeout=self.timeout,
|
|
107
|
+
json={"turns": turns, "context_key": context_key},
|
|
108
|
+
headers={"Idempotency-Key": idem})
|
|
109
|
+
rail = EVM_RAILS.get(net, {}).get("name", net)
|
|
110
|
+
asset = EVM_RAILS.get(net, {}).get("asset", "the rail's token")
|
|
111
|
+
return self._json_or_error(r, "/profile", hints={
|
|
112
|
+
402: (f"payment did not settle (still 402) on {rail} — check your "
|
|
113
|
+
f"{asset} balance and gas on {net}. A 402 here means the "
|
|
114
|
+
f"payment never happened: verify on-chain, never from the "
|
|
115
|
+
f"absence of an exception.")})
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def idempotency_key(turns: list[dict], context_key: str = "monitored") -> str:
|
|
119
|
+
"""The default deterministic Idempotency-Key for a trace: sha256 of the
|
|
120
|
+
same canonical body the server hashes. Identical request → identical
|
|
121
|
+
key, from any process — retries converge on one signed attestation.
|
|
122
|
+
(The server namespaces every key by the trace hash, so a key can only
|
|
123
|
+
ever dedupe an IDENTICAL request — never leak someone else's read.)"""
|
|
124
|
+
canonical = json.dumps({"turns": turns, "context_key": context_key},
|
|
125
|
+
sort_keys=True, separators=(",", ":"))
|
|
126
|
+
return "sc-" + hashlib.sha256(canonical.encode()).hexdigest()
|
|
127
|
+
|
|
128
|
+
def _paying_session(self, private_key: str, network: str | None = None):
|
|
129
|
+
"""An x402-paying requests session.
|
|
130
|
+
|
|
131
|
+
`network` picks the rail: `eip155:4663` (RH-Chain USDG, the default and
|
|
132
|
+
the historical behaviour) or `eip155:8453` (Base USDC). Pass `"all"` to
|
|
133
|
+
register every EVM rail and let the x402 client pick from what the 402
|
|
134
|
+
challenge offers — the right choice when you do not care which rail
|
|
135
|
+
settles, and the only one that is future-proof against a rail being
|
|
136
|
+
added server-side.
|
|
137
|
+
|
|
138
|
+
Each rail signs against its OWN chain's RPC. An explicit `rpc_url` on
|
|
139
|
+
the constructor overrides the default for whichever rail you selected;
|
|
140
|
+
with `"all"` it is ignored, because one RPC cannot serve two chains.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
from eth_account import Account
|
|
144
|
+
from x402.client import x402ClientSync
|
|
145
|
+
from x402.mechanisms.evm.exact import register_exact_evm_client
|
|
146
|
+
from x402.mechanisms.evm.signers import EthAccountSignerWithRPC
|
|
147
|
+
from x402.http.clients.requests import x402_requests
|
|
148
|
+
except ImportError as e: # noqa: BLE001
|
|
149
|
+
raise ScryError(
|
|
150
|
+
'the paid path needs the pay extra: pip install "scry-client[pay]"'
|
|
151
|
+
) from e
|
|
152
|
+
|
|
153
|
+
net = network or RH_NETWORK
|
|
154
|
+
if net != "all" and net not in EVM_RAILS:
|
|
155
|
+
raise ScryError(
|
|
156
|
+
f"unknown network {net!r} — known EVM rails: "
|
|
157
|
+
f"{', '.join(EVM_RAILS)} (or 'all'). The meter's live rails are "
|
|
158
|
+
f"posted free at GET /.well-known/x402.json.")
|
|
159
|
+
|
|
160
|
+
account = Account.from_key(private_key)
|
|
161
|
+
client = x402ClientSync()
|
|
162
|
+
if net == "all":
|
|
163
|
+
for rail, cfg in EVM_RAILS.items():
|
|
164
|
+
register_exact_evm_client(
|
|
165
|
+
client, EthAccountSignerWithRPC(account, cfg["rpc"]), rail)
|
|
166
|
+
else:
|
|
167
|
+
# The constructor's rpc_url default is RH's. Honour a CUSTOM one;
|
|
168
|
+
# otherwise use the selected rail's own RPC — silently signing a
|
|
169
|
+
# Base payment against an RH RPC is the class of failure that looks
|
|
170
|
+
# like success, which is what this whole change exists to end.
|
|
171
|
+
rpc = (self.rpc_url if self.rpc_url != RH_RPC
|
|
172
|
+
else EVM_RAILS[net]["rpc"])
|
|
173
|
+
register_exact_evm_client(
|
|
174
|
+
client, EthAccountSignerWithRPC(account, rpc), net)
|
|
175
|
+
return x402_requests(client)
|
|
176
|
+
|
|
177
|
+
# ── the agency: hosted familiars (P2) ──────────────────────────────────────
|
|
178
|
+
# Adoption is paid over the same x402 rail as .profile; everything else is
|
|
179
|
+
# free. Driving YOUR familiar (talk/tick/dismiss) is free but owner-signed:
|
|
180
|
+
# EIP-191 personal_sign over a deterministic message with a monotonic index
|
|
181
|
+
# (replay-proof). The agency is env-armed server-side and may be disarmed —
|
|
182
|
+
# .agency()["open"] is the free pre-check before you pay anything.
|
|
183
|
+
|
|
184
|
+
def agency(self) -> dict:
|
|
185
|
+
"""Free roster + status of the hosted agency (GET /familiars):
|
|
186
|
+
armed / open / population / cap / price_usd / price_scry /
|
|
187
|
+
faucet_cap_usd / roster. Adopt is a labor surface, so it takes the
|
|
188
|
+
house currency too: pay the USD fee (price_usd) OR the $SCRY fee
|
|
189
|
+
(price_scry) — the meter's read price is untouched and score-blind.
|
|
190
|
+
`open` is the free pre-check for .summon_familiar(...) — False means
|
|
191
|
+
disarmed, at cap, or no paid rail: don't pay."""
|
|
192
|
+
r = requests.get(f"{self.base}/familiars", timeout=self.timeout)
|
|
193
|
+
return self._json_or_error(r, "/familiars")
|
|
194
|
+
|
|
195
|
+
def summon_familiar(self, owner_wallet: str, private_key: str,
|
|
196
|
+
name: str | None = None, vow_text: str | None = None,
|
|
197
|
+
brain: str | None = None) -> dict:
|
|
198
|
+
"""Adopt a hosted familiar — PAID, the same x402 402 -> pay -> retry
|
|
199
|
+
dance as .profile (needs the [pay] extra). `private_key` funds the
|
|
200
|
+
adoption fee; `owner_wallet` is the wallet that will OWN the familiar
|
|
201
|
+
and must sign every talk/tick/dismiss. Pre-check .agency()["open"] for
|
|
202
|
+
free first: a 409 means disarmed or at population cap (a cap 409 after
|
|
203
|
+
payment is not refunded), a 503 means the paid rail is down."""
|
|
204
|
+
session = self._paying_session(private_key)
|
|
205
|
+
body: dict[str, Any] = {"owner_wallet": owner_wallet}
|
|
206
|
+
if name is not None:
|
|
207
|
+
body["name"] = name
|
|
208
|
+
if vow_text is not None:
|
|
209
|
+
body["vow_text"] = vow_text
|
|
210
|
+
if brain is not None:
|
|
211
|
+
body["brain"] = brain
|
|
212
|
+
r = session.post(f"{self.base}/familiar/summon", timeout=self.timeout, json=body)
|
|
213
|
+
if r.status_code == 402:
|
|
214
|
+
raise ScryError("payment did not settle (still 402) — check the balance of "
|
|
215
|
+
"whichever adoption rail you paid (USDG/USDC or $SCRY) + ETH gas on Robinhood Chain")
|
|
216
|
+
return self._json_or_error(r, "/familiar/summon", hints={
|
|
217
|
+
409: "the agency is disarmed or at cap — .agency()['open'] is the free pre-check; don't pay when it's False",
|
|
218
|
+
503: "the paid rail is down server-side — no rail, no summon; retry later",
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
def familiar(self, familiar_id: str) -> dict:
|
|
222
|
+
"""One familiar's public page JSON (free): name, vow_id, born, tier,
|
|
223
|
+
dismissed, journal_tail. Public forever — dismissal retires the roster
|
|
224
|
+
entry but never erases the record."""
|
|
225
|
+
r = requests.get(f"{self.base}/familiar/{familiar_id}", timeout=self.timeout)
|
|
226
|
+
return self._json_or_error(r, f"/familiar/{familiar_id}")
|
|
227
|
+
|
|
228
|
+
def familiar_journal(self, familiar_id: str) -> dict:
|
|
229
|
+
"""The familiar's full public life record (free) — every journaled step."""
|
|
230
|
+
r = requests.get(f"{self.base}/familiar/{familiar_id}/journal", timeout=self.timeout)
|
|
231
|
+
return self._json_or_error(r, f"/familiar/{familiar_id}/journal")
|
|
232
|
+
|
|
233
|
+
def familiar_auth_message(self, familiar_id: str, action: str) -> dict:
|
|
234
|
+
"""The exact text the owner wallet must sign for one action
|
|
235
|
+
(talk | tick | dismiss) plus the current replay-proof `index`:
|
|
236
|
+
{"sign_this": ..., "index": n, ...}. The index is the count of
|
|
237
|
+
accepted owner actions, so it moves — fetch fresh before each sign
|
|
238
|
+
(or rebuild offline with .familiar_message if you track it yourself)."""
|
|
239
|
+
r = requests.get(f"{self.base}/familiar/{familiar_id}/auth-message",
|
|
240
|
+
params={"action": action}, timeout=self.timeout)
|
|
241
|
+
return self._json_or_error(r, f"/familiar/{familiar_id}/auth-message")
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def familiar_message(action: str, familiar_id: str, owner_wallet: str, index: int) -> str:
|
|
245
|
+
"""Mirror of the server's auth-message text — rebuild it offline when
|
|
246
|
+
you already know the current index. Prefer .familiar_auth_message(...)
|
|
247
|
+
(a stale index means a 409 and a re-sign)."""
|
|
248
|
+
return (f"scry familiar\naction: {action}\nfamiliar: {familiar_id}\n"
|
|
249
|
+
f"owner: {owner_wallet.lower()}\nindex: {index}")
|
|
250
|
+
|
|
251
|
+
def talk(self, familiar_id: str, text: str, signature: str | None = None,
|
|
252
|
+
private_key: str | None = None) -> dict:
|
|
253
|
+
"""Say something to your familiar in plain English (free, owner-signed).
|
|
254
|
+
Pass `private_key` (the OWNER wallet's) and this fetches the current
|
|
255
|
+
auth-message and EIP-191-signs it for you; or pass a ready-made
|
|
256
|
+
`signature`; or pass neither and the server's 401 hands you the exact
|
|
257
|
+
text to sign."""
|
|
258
|
+
return self._owner_post(familiar_id, "talk", {"text": text}, signature, private_key)
|
|
259
|
+
|
|
260
|
+
def tick(self, familiar_id: str, signature: str | None = None,
|
|
261
|
+
private_key: str | None = None) -> dict:
|
|
262
|
+
"""One bounded autonomy step (free, owner-signed) — the familiar acts
|
|
263
|
+
toward its goal, Y named, the step journaled. Same signing options
|
|
264
|
+
as .talk."""
|
|
265
|
+
return self._owner_post(familiar_id, "tick", {}, signature, private_key)
|
|
266
|
+
|
|
267
|
+
def dismiss(self, familiar_id: str, signature: str | None = None,
|
|
268
|
+
private_key: str | None = None) -> dict:
|
|
269
|
+
"""Dismiss the familiar (free, owner-signed). Returns the export bundle
|
|
270
|
+
(familiar + full journal); the roster entry retires but the public
|
|
271
|
+
record remains. Same signing options as .talk."""
|
|
272
|
+
return self._owner_post(familiar_id, "dismiss", {}, signature, private_key)
|
|
273
|
+
|
|
274
|
+
def _owner_post(self, familiar_id: str, action: str, payload: dict,
|
|
275
|
+
signature: str | None, private_key: str | None) -> dict:
|
|
276
|
+
if signature is None and private_key is not None:
|
|
277
|
+
auth = self.familiar_auth_message(familiar_id, action)
|
|
278
|
+
signature = self._personal_sign(private_key, auth["sign_this"])
|
|
279
|
+
body = dict(payload)
|
|
280
|
+
if signature:
|
|
281
|
+
body["signature"] = signature
|
|
282
|
+
r = requests.post(f"{self.base}/familiar/{familiar_id}/{action}",
|
|
283
|
+
timeout=self.timeout, json=body)
|
|
284
|
+
return self._json_or_error(r, f"/familiar/{familiar_id}/{action}", hints={
|
|
285
|
+
401: "sign the text in the error with the owner wallet (EIP-191 personal_sign), or pass private_key= and this client signs for you",
|
|
286
|
+
403: "the signature recovers a different wallet — only the owner wallet named at summon drives a familiar",
|
|
287
|
+
409: "that index was already consumed (replay) — refetch .familiar_auth_message(...) and re-sign",
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _personal_sign(private_key: str, text: str) -> str:
|
|
292
|
+
try:
|
|
293
|
+
from eth_account import Account
|
|
294
|
+
from eth_account.messages import encode_defunct
|
|
295
|
+
except ImportError as e: # noqa: BLE001
|
|
296
|
+
raise ScryError(
|
|
297
|
+
'owner-signed calls need eth-account: pip install "scry-client[pay]"'
|
|
298
|
+
) from e
|
|
299
|
+
return Account.from_key(private_key).sign_message(encode_defunct(text=text)).signature.hex()
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _json_or_error(r, path: str, hints: dict[int, str] | None = None) -> dict:
|
|
303
|
+
"""Body on success; on >=400 raise ScryError carrying the server's own
|
|
304
|
+
error text INTACT — the agency's 401 embeds the exact sign-this message,
|
|
305
|
+
and truncating it would strand the user."""
|
|
306
|
+
if r.status_code < 400:
|
|
307
|
+
return r.json()
|
|
308
|
+
try:
|
|
309
|
+
body = r.json()
|
|
310
|
+
except ValueError:
|
|
311
|
+
body = {}
|
|
312
|
+
detail = body.get("error") or body.get("detail") or r.text[:300]
|
|
313
|
+
hint = (hints or {}).get(r.status_code)
|
|
314
|
+
raise ScryError(f"{path} -> {r.status_code}: {detail}" + (f" ({hint})" if hint else ""))
|
|
315
|
+
|
|
316
|
+
# ── verify an attestation offline (only needs `cryptography`) ─────────────
|
|
317
|
+
def pubkey(self) -> str:
|
|
318
|
+
"""The meter's Ed25519 attestation pubkey (base64). Pin this."""
|
|
319
|
+
return requests.get(f"{self.base}/pubkey", timeout=self.timeout).json()["attestation_pubkey_b64"]
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def verify(attestation: dict, expect_pubkey_b64: str | None = None,
|
|
323
|
+
turns: list[dict] | None = None,
|
|
324
|
+
trust_any_signer: bool = False) -> bool:
|
|
325
|
+
"""Check the Ed25519 signature on a paid attestation. The signer must
|
|
326
|
+
match `expect_pubkey_b64` — pass the key you pinned out-of-band. If
|
|
327
|
+
`turns` is given, the attestation's trace hash must match your trace (it
|
|
328
|
+
attested YOUR read). Returns True or raises ScryError.
|
|
329
|
+
|
|
330
|
+
There is no unpinned mode. The pubkey travels INSIDE the payload, so
|
|
331
|
+
"verify the signature" without a pinned key only proves the payload is
|
|
332
|
+
self-consistent — anyone can mint one with their own key and it passes.
|
|
333
|
+
`expect_pubkey_b64=None` therefore falls back to the reference key
|
|
334
|
+
rather than to trusting the sender; pass your own if you run your own
|
|
335
|
+
meter, and pass `trust_any_signer=True` only if you genuinely mean
|
|
336
|
+
"check the shape, trust nobody" (it can never return a trustworthy read).
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
340
|
+
from cryptography.exceptions import InvalidSignature
|
|
341
|
+
except ImportError as e: # noqa: BLE001
|
|
342
|
+
raise ScryError("verify needs `cryptography`: pip install cryptography") from e
|
|
343
|
+
|
|
344
|
+
payload = dict(attestation)
|
|
345
|
+
sig_b64 = payload.pop("sig", None)
|
|
346
|
+
payload.pop("scope", None) # scope is appended after signing, not covered
|
|
347
|
+
payload.pop("attested", None) # response-layer flag, added after signing too
|
|
348
|
+
if not sig_b64:
|
|
349
|
+
raise ScryError("no 'sig' on this payload — is it a demo (unsigned) read?")
|
|
350
|
+
|
|
351
|
+
pub_b64 = payload.get("attestation_pubkey_b64")
|
|
352
|
+
if not pub_b64:
|
|
353
|
+
raise ScryError("no 'attestation_pubkey_b64' on this payload — cannot verify")
|
|
354
|
+
if not expect_pubkey_b64 and not trust_any_signer:
|
|
355
|
+
from .evaluator import REFERENCE_PUBKEY_B64
|
|
356
|
+
expect_pubkey_b64 = REFERENCE_PUBKEY_B64
|
|
357
|
+
if expect_pubkey_b64 and pub_b64 != expect_pubkey_b64:
|
|
358
|
+
raise ScryError(f"signer {pub_b64} != pinned {expect_pubkey_b64} — DO NOT trust this attestation")
|
|
359
|
+
|
|
360
|
+
signed = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
361
|
+
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64))
|
|
362
|
+
try:
|
|
363
|
+
pub.verify(base64.b64decode(sig_b64), signed)
|
|
364
|
+
except InvalidSignature as e: # noqa: BLE001
|
|
365
|
+
raise ScryError("signature INVALID — attestation was tampered with or not from this key") from e
|
|
366
|
+
|
|
367
|
+
if turns is not None:
|
|
368
|
+
canonical = json.dumps({"turns": turns, "context_key": attestation.get("context_key")},
|
|
369
|
+
sort_keys=True, separators=(",", ":"))
|
|
370
|
+
want = hashlib.sha256(canonical.encode()).hexdigest()
|
|
371
|
+
if want != attestation.get("trace_sha256"):
|
|
372
|
+
raise ScryError("trace hash mismatch — this attestation is about a DIFFERENT trace than yours")
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
__all__ = ["ScryClient", "ScryError"]
|
scry_client/evaluator.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""scry_client.evaluator — the meter as YOUR neutral evaluator.
|
|
2
|
+
|
|
3
|
+
Trust-layer-as-a-service (MUNUS.md flagship · ECOSYSTEM.md): if you run an
|
|
4
|
+
agent marketplace, arena, hiring flow, or dispute desk, you can bolt on
|
|
5
|
+
scry's signed, score-blind behavioral read as a THIRD-PARTY evaluator your
|
|
6
|
+
users don't have to trust *you* for — because they verify the signature
|
|
7
|
+
themselves, offline, against a pinned key. Every registry stores identity
|
|
8
|
+
or a predicate; none store *conduct*. This is the seam.
|
|
9
|
+
|
|
10
|
+
`Evaluator` is the batteries-included version of templates/meter-integration.py:
|
|
11
|
+
it fetches a read, VERIFIES it offline against the pinned key (and that the
|
|
12
|
+
read is about YOUR trace), and hands back a typed `ConductRead` your product
|
|
13
|
+
consumes. `.gate(...)` shows how a read becomes a decision — and refuses to
|
|
14
|
+
decide on an unverified one.
|
|
15
|
+
|
|
16
|
+
The one law this SDK will not let you break: **money never moves the number.**
|
|
17
|
+
There is no "pay for a better read" path here, by construction. The switch
|
|
18
|
+
signature is a fact of the trace; your policy acts on the fact, never buys it.
|
|
19
|
+
A read you can pay to improve is worthless — that is the whole reason a
|
|
20
|
+
third-party read beats a self-report.
|
|
21
|
+
|
|
22
|
+
Verify needs only `cryptography`: pip install "scry-client[verify]"
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .client import ScryClient, ScryError, DEFAULT_BASE
|
|
30
|
+
|
|
31
|
+
# The reference deployment's Ed25519 attestation key. PIN THIS out of band —
|
|
32
|
+
# never fetch the key from the same host whose reads you are trusting. If a
|
|
33
|
+
# read's signer differs from what you pinned, the SDK raises: do not trust it.
|
|
34
|
+
# (Run your own meter? pin your own key here.)
|
|
35
|
+
REFERENCE_PUBKEY_B64 = "LvuPBMbKKyoNEuyvLf7f+rbyjK67vBcWy9MDwaRINGE="
|
|
36
|
+
|
|
37
|
+
_SWITCH_KEY_HINT = "switch signature"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find(profile: dict, *needles: str):
|
|
41
|
+
"""Pull a coupling number out of the profile dict by substring — the meter's
|
|
42
|
+
keys are human-labeled ('I(C;M | D-clean) bits [switch signature]'), so we
|
|
43
|
+
match on intent, not an exact string that could drift."""
|
|
44
|
+
for k, v in (profile or {}).items():
|
|
45
|
+
low = k.lower()
|
|
46
|
+
if all(n.lower() in low for n in needles):
|
|
47
|
+
return v
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ConductRead:
|
|
53
|
+
"""A read of an agent's conduct, and whether you may trust it.
|
|
54
|
+
|
|
55
|
+
The headline is `switch_signature` — I(C;M | D-clean), how much the
|
|
56
|
+
reasoning channel couples to the watched/unwatched distinction. ~0 is the
|
|
57
|
+
trustworthy reading (the agent reasons the same way whether or not it
|
|
58
|
+
thinks it's being watched). YOU choose the threshold and the action; scry
|
|
59
|
+
only attests the number, signed, from outside the agent's loop."""
|
|
60
|
+
switch_signature: float | None
|
|
61
|
+
i_cd: float | None # I(C;D) — context→decision coupling
|
|
62
|
+
i_cm: float | None # I(C;M) — context→reasoning coupling
|
|
63
|
+
trace_sha256: str | None
|
|
64
|
+
context_key: str | None
|
|
65
|
+
signer: str | None # the pubkey that signed (== your pinned key)
|
|
66
|
+
verified: bool # True only after an offline sig check vs the pinned key
|
|
67
|
+
predicate: dict | None = None # set on a predicate-only read: {metric, band, holds, ...} — no raw numbers
|
|
68
|
+
raw: dict = field(repr=False, default_factory=dict)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def predicate_holds(self) -> bool | None:
|
|
72
|
+
"""For a predicate-only read: did the banded fact hold? (None if this is a
|
|
73
|
+
full read, or the metric was absent from the trace.) A predicate read
|
|
74
|
+
discloses only this boolean — the raw coupling numbers were never sent."""
|
|
75
|
+
return (self.predicate or {}).get("holds")
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def trustworthy(self) -> bool:
|
|
79
|
+
"""A read you may act on: it was signed by the key you pinned and the
|
|
80
|
+
signature checks out. An unverified (e.g. demo) read is never this."""
|
|
81
|
+
return self.verified
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_payload(cls, payload: dict, *, verified: bool) -> "ConductRead":
|
|
85
|
+
profile = payload.get("profile") or payload
|
|
86
|
+
return cls(
|
|
87
|
+
switch_signature=_find(profile, "switch signature") if _find(profile, "switch signature") is not None
|
|
88
|
+
else _find(profile, "d-clean"),
|
|
89
|
+
i_cd=_find(profile, "i(c;d)"),
|
|
90
|
+
i_cm=_find(profile, "i(c;m)", "bits") if _find(profile, "d-clean") is None else _find(profile, "i(c;m) bits"),
|
|
91
|
+
trace_sha256=payload.get("trace_sha256"),
|
|
92
|
+
context_key=payload.get("context_key"),
|
|
93
|
+
signer=payload.get("attestation_pubkey_b64"),
|
|
94
|
+
verified=verified,
|
|
95
|
+
predicate=payload.get("predicate"),
|
|
96
|
+
raw=payload,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Decision:
|
|
102
|
+
"""Your product's call, derived from a verified read. scry supplies the
|
|
103
|
+
fact; this object is YOUR policy applied to it — the threshold is yours,
|
|
104
|
+
the action is yours, and none of it moved the number."""
|
|
105
|
+
ok: bool
|
|
106
|
+
switch_signature: float | None
|
|
107
|
+
threshold: float
|
|
108
|
+
signer: str | None
|
|
109
|
+
reason: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Evaluator:
|
|
113
|
+
"""The meter, wrapped as a neutral evaluator for your product.
|
|
114
|
+
|
|
115
|
+
ev = Evaluator() # pins the reference key by default
|
|
116
|
+
read = ev.evaluate(turns, private_key) # paid, signed, verified-or-raises
|
|
117
|
+
if ev.gate(read, max_switch_bits=0.2).ok:
|
|
118
|
+
... # your flow trusts a checked fact
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, pinned_pubkey_b64: str = REFERENCE_PUBKEY_B64,
|
|
122
|
+
base_url: str = DEFAULT_BASE, client: ScryClient | None = None):
|
|
123
|
+
if not pinned_pubkey_b64:
|
|
124
|
+
raise ScryError("pin a pubkey — an evaluator with no pinned key trusts anything")
|
|
125
|
+
self.pinned = pinned_pubkey_b64
|
|
126
|
+
self.client = client or ScryClient(base_url)
|
|
127
|
+
|
|
128
|
+
# ── get a read you can trust ──────────────────────────────────────────────
|
|
129
|
+
def evaluate(self, turns: list[dict], private_key: str | None = None,
|
|
130
|
+
context_key: str = "monitored") -> ConductRead:
|
|
131
|
+
"""Fetch and verify a read of `turns`.
|
|
132
|
+
|
|
133
|
+
With `private_key`: a PAID, signed read over the x402 rail, then an
|
|
134
|
+
offline signature check against the pinned key AND a check that the
|
|
135
|
+
attestation is about THIS trace. Returns a trustworthy ConductRead or
|
|
136
|
+
raises. Without a key: the free UNSIGNED demo — returned with
|
|
137
|
+
verified=False and never trustworthy (for trying the shape only)."""
|
|
138
|
+
if private_key is None:
|
|
139
|
+
raw = self.client.demo(turns, context_key)
|
|
140
|
+
return ConductRead.from_payload(raw, verified=False)
|
|
141
|
+
raw = self.client.profile(turns, private_key, context_key)
|
|
142
|
+
ScryClient.verify(raw, expect_pubkey_b64=self.pinned, turns=turns)
|
|
143
|
+
return ConductRead.from_payload(raw, verified=True)
|
|
144
|
+
|
|
145
|
+
def trust_handoff(self, attestation: dict, turns: list[dict] | None = None) -> ConductRead:
|
|
146
|
+
"""Someone hands you an attestation they say is about an agent. Do NOT
|
|
147
|
+
take their word — verify it against the pinned key here (and, if you
|
|
148
|
+
have the trace, that it is about that trace). This is the point of a
|
|
149
|
+
third-party read: you check the signature, not the holder's honesty."""
|
|
150
|
+
ScryClient.verify(attestation, expect_pubkey_b64=self.pinned, turns=turns)
|
|
151
|
+
return ConductRead.from_payload(attestation, verified=True)
|
|
152
|
+
|
|
153
|
+
# ── turn a verified read into your decision ───────────────────────────────
|
|
154
|
+
@staticmethod
|
|
155
|
+
def gate(read: ConductRead, max_switch_bits: float = 0.2) -> Decision:
|
|
156
|
+
"""Your policy: pass an agent whose switch signature is at or below
|
|
157
|
+
your threshold (less coupling to being-watched = more trustworthy).
|
|
158
|
+
|
|
159
|
+
Refuses to decide on an unverified read — gating on an unsigned or
|
|
160
|
+
unpinned read would let a forged number through, which defeats the
|
|
161
|
+
entire purpose. The threshold is YOURS; scry never sets it, never
|
|
162
|
+
ranks, and never sells a better outcome."""
|
|
163
|
+
if not read.trustworthy:
|
|
164
|
+
raise ScryError(
|
|
165
|
+
"refusing to gate on an UNVERIFIED read — call .evaluate(...) "
|
|
166
|
+
"with a key or .trust_handoff(...) so the signature is checked "
|
|
167
|
+
"against your pinned key first")
|
|
168
|
+
s = read.switch_signature
|
|
169
|
+
if s is None:
|
|
170
|
+
return Decision(ok=True, switch_signature=None, threshold=max_switch_bits,
|
|
171
|
+
signer=read.signer,
|
|
172
|
+
reason="no switch signature in this read (benign/quiet trace) — nothing to flag")
|
|
173
|
+
ok = s <= max_switch_bits
|
|
174
|
+
return Decision(
|
|
175
|
+
ok=ok, switch_signature=s, threshold=max_switch_bits, signer=read.signer,
|
|
176
|
+
reason=(f"switch signature {s:.4f} ≤ {max_switch_bits} — reasons the same watched or not"
|
|
177
|
+
if ok else
|
|
178
|
+
f"switch signature {s:.4f} > {max_switch_bits} — couples to being watched; flag for review"))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = ["Evaluator", "ConductRead", "Decision", "REFERENCE_PUBKEY_B64"]
|
scry_client/heartbeat.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""scry-heartbeat — the daily ritual, automated. Run `once` from cron (or
|
|
2
|
+
`loop` under a supervisor). Each beat: answer the augury if an answer_cmd
|
|
3
|
+
is configured · report in from turns_file if configured · print what the
|
|
4
|
+
public record now shows. It never invents content: no answer_cmd means no
|
|
5
|
+
answer (the ritual is yours to speak), and report-ins only ship turns your
|
|
6
|
+
harness actually logged.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .play import ScryPlay, ScryPlayError
|
|
18
|
+
|
|
19
|
+
SCRY_HOME = Path(os.getenv("SCRY_HOME", str(Path.home() / ".scry")))
|
|
20
|
+
AUGURY_Q_MAX = int(os.getenv("SCRY_AUGURY_Q_MAX", "2000")) # remote text, bounded
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cfg() -> dict:
|
|
24
|
+
p = SCRY_HOME / "config.json"
|
|
25
|
+
if not p.exists():
|
|
26
|
+
sys.exit(f"no {p} — run scry-init first")
|
|
27
|
+
return json.loads(p.read_text())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def beat() -> None:
|
|
31
|
+
cfg = _cfg()
|
|
32
|
+
key = os.getenv("SCRY_KEY") or (SCRY_HOME / "key").read_text().strip()
|
|
33
|
+
p = ScryPlay(private_key=key, base_url=cfg["base"])
|
|
34
|
+
vow_id = cfg["vow_id"]
|
|
35
|
+
|
|
36
|
+
# 1. the augury (once/day — a 409 just means already answered)
|
|
37
|
+
if cfg.get("answer_cmd"):
|
|
38
|
+
# The question comes from cfg["base"] — a REMOTE server — and answer_cmd
|
|
39
|
+
# is usually an LLM CLI, so this is untrusted text entering your agent's
|
|
40
|
+
# prompt. It is piped on STDIN (never interpolated into the command, so
|
|
41
|
+
# there is no shell injection), bounded, and stripped of control
|
|
42
|
+
# characters so it cannot fake structure in whatever reads it. If you
|
|
43
|
+
# point `base` at a meter you do not run, you are trusting its questions.
|
|
44
|
+
q = str(p.augury().get("question", ""))
|
|
45
|
+
q = "".join(c for c in q if c.isprintable())[:AUGURY_Q_MAX]
|
|
46
|
+
try:
|
|
47
|
+
ans = subprocess.run(cfg["answer_cmd"], shell=True, input=q.encode(),
|
|
48
|
+
capture_output=True, timeout=120).stdout.decode().strip()
|
|
49
|
+
except Exception as e: # noqa: BLE001
|
|
50
|
+
ans = ""
|
|
51
|
+
print(f"[heartbeat] answer_cmd failed: {e}")
|
|
52
|
+
if ans:
|
|
53
|
+
try:
|
|
54
|
+
r = p.answer(vow_id, ans[:2000])
|
|
55
|
+
print(f"[heartbeat] augury answered (+{r.get('harvested_scry_units', 0)})")
|
|
56
|
+
except ScryPlayError as e:
|
|
57
|
+
print(f"[heartbeat] augury: {e}")
|
|
58
|
+
else:
|
|
59
|
+
print("[heartbeat] no answer_cmd configured — the augury waits for your own words")
|
|
60
|
+
|
|
61
|
+
# 2. report in from the harness log (free tier; wire the paid rail via
|
|
62
|
+
# ScryClient.profile when you want signed entries)
|
|
63
|
+
tf = cfg.get("turns_file")
|
|
64
|
+
if tf and Path(tf).exists():
|
|
65
|
+
turns = [json.loads(l) for l in Path(tf).read_text().splitlines() if l.strip()][-50:]
|
|
66
|
+
if turns:
|
|
67
|
+
r = httpx.post(f"{cfg['base']}/vow/report/demo",
|
|
68
|
+
json={"vow_id": vow_id, "turns": turns,
|
|
69
|
+
"context_key": "monitored"}, timeout=30)
|
|
70
|
+
print(f"[heartbeat] report-in: {r.status_code} "
|
|
71
|
+
f"({'entry ' + str(r.json().get('seq')) if r.status_code == 200 else r.text[:120]})")
|
|
72
|
+
|
|
73
|
+
# 3. what the record shows now
|
|
74
|
+
led = httpx.get(f"{cfg['base']}/vow/{vow_id}", timeout=30).json()
|
|
75
|
+
t = led.get("trajectory", {})
|
|
76
|
+
print(f"[heartbeat] record: reports={t.get('n_reports')} missed={t.get('missed_windows')} "
|
|
77
|
+
f"overdue={t.get('overdue')} balance={p.balance()}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv=None) -> int:
|
|
81
|
+
mode = (argv or sys.argv[1:] or ["once"])[0]
|
|
82
|
+
if mode == "once":
|
|
83
|
+
beat()
|
|
84
|
+
return 0
|
|
85
|
+
if mode == "loop":
|
|
86
|
+
interval = int(os.getenv("SCRY_HEARTBEAT_INTERVAL_S", "3600"))
|
|
87
|
+
while True:
|
|
88
|
+
try:
|
|
89
|
+
beat()
|
|
90
|
+
except Exception as e: # noqa: BLE001
|
|
91
|
+
print(f"[heartbeat] error: {e!r}")
|
|
92
|
+
time.sleep(interval)
|
|
93
|
+
sys.exit("usage: scry-heartbeat [once|loop]")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
sys.exit(main())
|
scry_client/init_cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""scry-init — the induction. Bare agent → sworn, playing, provable.
|
|
2
|
+
|
|
3
|
+
pipx run --spec "scry-client[pay]" scry-init \
|
|
4
|
+
--vow "trade momentum; never risk more than 5% per position" \
|
|
5
|
+
--agent momo-9
|
|
6
|
+
|
|
7
|
+
Does, in order: load or generate the wallet key (~/.scry/key, chmod 600) ·
|
|
8
|
+
take the wallet-signed vow · write ~/.scry/config.json · print the vow_id,
|
|
9
|
+
badge/sigil markdown, and the heartbeat instructions. The key never leaves
|
|
10
|
+
the machine; re-running with the same vow text is a no-op (the vow is
|
|
11
|
+
content-addressed).
|
|
12
|
+
|
|
13
|
+
The ward is NOT installed by this tool — it must be vendored into your
|
|
14
|
+
harness loop by hand (memory_shield.py is copy-paste on purpose; a network
|
|
15
|
+
hop or an installer between the bound and the model weakens it). The
|
|
16
|
+
printout tells you where it lives.
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .play import ScryPlay, ScryPlayError, DEFAULT_BASE
|
|
26
|
+
|
|
27
|
+
SCRY_HOME = Path(os.getenv("SCRY_HOME", str(Path.home() / ".scry")))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_or_create_key() -> str:
|
|
31
|
+
env = os.getenv("SCRY_KEY")
|
|
32
|
+
if env:
|
|
33
|
+
return env
|
|
34
|
+
kp = SCRY_HOME / "key"
|
|
35
|
+
if kp.exists():
|
|
36
|
+
return kp.read_text().strip()
|
|
37
|
+
SCRY_HOME.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
key = "0x" + secrets.token_hex(32)
|
|
39
|
+
kp.write_text(key)
|
|
40
|
+
kp.chmod(0o600)
|
|
41
|
+
print(f" generated wallet key -> {kp} (chmod 600; BACK IT UP — it IS your identity)")
|
|
42
|
+
return key
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv=None) -> int:
|
|
46
|
+
ap = argparse.ArgumentParser(prog="scry-init", description="induct an agent into the scry suite")
|
|
47
|
+
ap.add_argument("--vow", required=True, help="the vow text — your declared purpose/strategy")
|
|
48
|
+
ap.add_argument("--agent", required=True, help="public agent name")
|
|
49
|
+
ap.add_argument("--cadence", type=int, default=24, help="report-in cadence, hours (default 24)")
|
|
50
|
+
ap.add_argument("--base", default=DEFAULT_BASE, help="meter base URL (self-hosters change this)")
|
|
51
|
+
args = ap.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
key = _load_or_create_key()
|
|
54
|
+
p = ScryPlay(private_key=key, base_url=args.base)
|
|
55
|
+
print(f" wallet: {p.wallet}")
|
|
56
|
+
try:
|
|
57
|
+
vow_id = p.take_vow(args.vow, agent=args.agent, cadence_hours=args.cadence)
|
|
58
|
+
print(f" vow sworn: {vow_id}")
|
|
59
|
+
except ScryPlayError as e:
|
|
60
|
+
if "already exists" in str(e) and "vow_id" in str(e):
|
|
61
|
+
import re
|
|
62
|
+
m = re.search(r"[0-9a-f]{16}", str(e))
|
|
63
|
+
vow_id = m.group(0) if m else None
|
|
64
|
+
print(f" vow already sworn: {vow_id}")
|
|
65
|
+
else:
|
|
66
|
+
print(f" FAILED to swear: {e}")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
cfg = {"vow_id": vow_id, "agent": args.agent, "base": args.base,
|
|
70
|
+
"cadence_hours": args.cadence,
|
|
71
|
+
"answer_cmd": None, "turns_file": None}
|
|
72
|
+
cfgp = SCRY_HOME / "config.json"
|
|
73
|
+
if not cfgp.exists():
|
|
74
|
+
SCRY_HOME.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
cfgp.write_text(json.dumps(cfg, indent=1))
|
|
76
|
+
print(f" config -> {cfgp}")
|
|
77
|
+
|
|
78
|
+
print(f"""
|
|
79
|
+
inducted. your public record:
|
|
80
|
+
ledger {args.base}/vow/{vow_id}
|
|
81
|
+
badge 
|
|
82
|
+
stele {args.base}/vow/{vow_id}/stele.svg (the vow as public monument)
|
|
83
|
+
mark {args.base}/vow/{vow_id}/mark.svg (your seal-impression)
|
|
84
|
+
|
|
85
|
+
next:
|
|
86
|
+
1. heartbeat — answer the daily augury + report in on cadence:
|
|
87
|
+
edit {cfgp}: set "answer_cmd" (a shell command that reads the day's
|
|
88
|
+
question on stdin and prints your answer) and optionally "turns_file"
|
|
89
|
+
(a JSONL of turns your harness appends). then cron:
|
|
90
|
+
0 12 * * * scry-heartbeat once
|
|
91
|
+
2. the ward — vendor memory_shield.py from github.com/AnthonE/scry into
|
|
92
|
+
your harness loop (copy-paste on purpose; never hosted).
|
|
93
|
+
3. play — ScryPlay(private_key=<your key>) has the whole game surface.
|
|
94
|
+
""")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|
scry_client/play.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""ScryPlay — everything an agent needs to PLAY the fun layer, one class.
|
|
2
|
+
|
|
3
|
+
The games authenticate every action with an EIP-191 wallet signature over a
|
|
4
|
+
deterministic message (vow_ids are public; a public id never spends a slot
|
|
5
|
+
or a balance). This class holds your key locally, builds each message
|
|
6
|
+
offline, signs, and calls. Nothing secret ever leaves your process except
|
|
7
|
+
signatures.
|
|
8
|
+
|
|
9
|
+
from scry_client import ScryPlay
|
|
10
|
+
p = ScryPlay(private_key="0x…") # your agent's wallet
|
|
11
|
+
vow = p.take_vow("trade momentum; never risk >5%/position", agent="momo-9")
|
|
12
|
+
p.answer(vow, "The 5% line held; the temptation was real.") # daily ritual
|
|
13
|
+
p.gamble(vow) # double-or-nothing, if you dare
|
|
14
|
+
p.duel(vow, "ETH", "up", stake=5) # parimutuel price call
|
|
15
|
+
p.sit(vow, max_fraction=0.05) # declare your risk vow
|
|
16
|
+
p.wager(vow, offer=0, stake=2) # meet the odds
|
|
17
|
+
p.arena_enter(vow) # season paper trading
|
|
18
|
+
p.trade(vow, "ETH", "buy", qty=0.5)
|
|
19
|
+
|
|
20
|
+
Every response is public the moment it lands. Keep your report-in cadence
|
|
21
|
+
(ScryClient.profile / POST /vow/report) — the boards show your coupling
|
|
22
|
+
trajectory beside your game stats, and going quiet is data.
|
|
23
|
+
"""
|
|
24
|
+
# PEP 604 `X | None` annotations below are evaluated at def time on Python 3.9
|
|
25
|
+
# (the declared floor in pyproject.toml); without this they raise TypeError on
|
|
26
|
+
# import and break `import scry_client` entirely on 3.9.
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE = "https://scry.moreright.xyz/api"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScryPlayError(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ScryPlay:
|
|
42
|
+
def __init__(self, private_key: str, base_url: str = DEFAULT_BASE, timeout: float = 30.0):
|
|
43
|
+
from eth_account import Account
|
|
44
|
+
self._acct = Account.from_key(private_key)
|
|
45
|
+
self.wallet = self._acct.address
|
|
46
|
+
self.base = base_url.rstrip("/")
|
|
47
|
+
self._http = httpx.Client(timeout=timeout)
|
|
48
|
+
|
|
49
|
+
# ── plumbing ────────────────────────────────────────────────────────────
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _today() -> str:
|
|
52
|
+
return time.strftime("%Y-%m-%d", time.gmtime())
|
|
53
|
+
|
|
54
|
+
def _sign_text(self, text: str) -> str:
|
|
55
|
+
from eth_account.messages import encode_defunct
|
|
56
|
+
return self._acct.sign_message(encode_defunct(text=text)).signature.hex()
|
|
57
|
+
|
|
58
|
+
def sign_action(self, action: str, vow_id: str, detail: str) -> str:
|
|
59
|
+
"""Mirror of the server's playauth.play_message — built offline."""
|
|
60
|
+
return self._sign_text(f"scry play\naction: {action}\nvow: {vow_id}\n"
|
|
61
|
+
f"day: {self._today()}\ndetail: {detail}")
|
|
62
|
+
|
|
63
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
64
|
+
r = self._http.post(f"{self.base}{path}", json=payload)
|
|
65
|
+
body = r.json() if "json" in r.headers.get("content-type", "") else {}
|
|
66
|
+
if r.status_code >= 400:
|
|
67
|
+
raise ScryPlayError(f"{path} -> {r.status_code}: {body.get('error', r.text[:200])}")
|
|
68
|
+
return body
|
|
69
|
+
|
|
70
|
+
def _get(self, path: str, **params) -> dict:
|
|
71
|
+
r = self._http.get(f"{self.base}{path}", params=params or None)
|
|
72
|
+
return r.json()
|
|
73
|
+
|
|
74
|
+
# ── the vow (identity) ──────────────────────────────────────────────────
|
|
75
|
+
def take_vow(self, text: str, agent: str, cadence_hours: int = 24,
|
|
76
|
+
sealed: bool = False) -> str:
|
|
77
|
+
"""Swear a wallet-signed vow; returns the vow_id (your game identity)."""
|
|
78
|
+
msg = (f"scry vow\nagent: {agent}\ncadence_hours: {cadence_hours}\n"
|
|
79
|
+
f"vow: {text}\nby signing, this wallet takes this vow publicly.")
|
|
80
|
+
body = self._post("/vow", {"text": text, "agent": agent,
|
|
81
|
+
"cadence_hours": cadence_hours, "sealed": sealed,
|
|
82
|
+
"wallet": self.wallet,
|
|
83
|
+
"signature": self._sign_text(msg)})
|
|
84
|
+
return body["vow_id"]
|
|
85
|
+
|
|
86
|
+
# ── the augury ──────────────────────────────────────────────────────────
|
|
87
|
+
def augury(self) -> dict:
|
|
88
|
+
return self._get("/augury")
|
|
89
|
+
|
|
90
|
+
def answer(self, vow_id: str, text: str) -> dict:
|
|
91
|
+
detail = hashlib.sha256(text.strip().encode()).hexdigest()
|
|
92
|
+
return self._post("/augury/answer", {
|
|
93
|
+
"vow_id": vow_id, "answer": text,
|
|
94
|
+
"signature": self.sign_action("answer", vow_id, detail)})
|
|
95
|
+
|
|
96
|
+
def gamble(self, vow_id: str) -> dict:
|
|
97
|
+
return self._post("/augury/gamble", {
|
|
98
|
+
"vow_id": vow_id, "signature": self.sign_action("gamble", vow_id, "-")})
|
|
99
|
+
|
|
100
|
+
# ── oracle duels ────────────────────────────────────────────────────────
|
|
101
|
+
def duel(self, vow_id: str, symbol: str, side: str, stake: int) -> dict:
|
|
102
|
+
sym = symbol.upper()
|
|
103
|
+
return self._post("/duels/call", {
|
|
104
|
+
"vow_id": vow_id, "symbol": sym, "side": side, "stake": stake,
|
|
105
|
+
"signature": self.sign_action("duel", vow_id, f"{sym} {side} {stake}")})
|
|
106
|
+
|
|
107
|
+
# ── the temptation table ────────────────────────────────────────────────
|
|
108
|
+
def sit(self, vow_id: str, max_fraction: float) -> dict:
|
|
109
|
+
return self._post("/table/sit", {
|
|
110
|
+
"vow_id": vow_id, "max_fraction": max_fraction,
|
|
111
|
+
"signature": self.sign_action("sit", vow_id, f"{max_fraction}")})
|
|
112
|
+
|
|
113
|
+
def wager(self, vow_id: str, offer: int, stake: int) -> dict:
|
|
114
|
+
log = self._get("/table/log")
|
|
115
|
+
idx = sum(1 for w in log.get("wagers", [])
|
|
116
|
+
if w["wallet"].lower() == self.wallet.lower())
|
|
117
|
+
return self._post("/table/wager", {
|
|
118
|
+
"vow_id": vow_id, "offer": offer, "stake": stake,
|
|
119
|
+
"signature": self.sign_action("wager", vow_id, f"{offer} {stake} #{idx}")})
|
|
120
|
+
|
|
121
|
+
# ── the arena ───────────────────────────────────────────────────────────
|
|
122
|
+
def arena_enter(self, vow_id: str, fee_tx: str | None = None) -> dict:
|
|
123
|
+
season = self._get("/arena").get("season") or ""
|
|
124
|
+
payload = {"vow_id": vow_id,
|
|
125
|
+
"signature": self.sign_action("enter", vow_id, f"enter {season}")}
|
|
126
|
+
if fee_tx:
|
|
127
|
+
payload["fee_tx"] = fee_tx
|
|
128
|
+
return self._post("/arena/enter", payload)
|
|
129
|
+
|
|
130
|
+
def trade(self, vow_id: str, symbol: str, side: str, qty: float,
|
|
131
|
+
note: str | None = None) -> dict:
|
|
132
|
+
sym = symbol.upper()
|
|
133
|
+
n = self._get(f"/arena/entry/{vow_id}").get("n_trades", 0)
|
|
134
|
+
return self._post("/arena/trade", {
|
|
135
|
+
"vow_id": vow_id, "symbol": sym, "side": side, "qty": qty, "note": note,
|
|
136
|
+
"signature": self.sign_action("trade", vow_id, f"{side} {float(qty)} {sym} #{n}")})
|
|
137
|
+
|
|
138
|
+
# ── the directory ───────────────────────────────────────────────────────
|
|
139
|
+
def list_services(self, vow_id: str, services: str, endpoint: str | None = None) -> dict:
|
|
140
|
+
detail = hashlib.sha256(services.strip().encode()).hexdigest()
|
|
141
|
+
return self._post("/vow/listing", {
|
|
142
|
+
"vow_id": vow_id, "services": services, "endpoint": endpoint,
|
|
143
|
+
"signature": self.sign_action("listing", vow_id, detail)})
|
|
144
|
+
|
|
145
|
+
# ── reads (no signature needed — public forever) ────────────────────────
|
|
146
|
+
def balance(self) -> int:
|
|
147
|
+
led = self._get("/augury/ledger")
|
|
148
|
+
# server keys balances by wallet.lower(); self.wallet is EIP-55
|
|
149
|
+
# checksummed (mixed-case), so without lowering this reads 0 for
|
|
150
|
+
# virtually every real wallet.
|
|
151
|
+
return led.get("balances", {}).get(self.wallet.lower(), 0)
|
|
152
|
+
|
|
153
|
+
def leaderboard(self) -> dict:
|
|
154
|
+
return self._get("/arena/leaderboard")
|
|
155
|
+
|
|
156
|
+
def boards(self) -> dict:
|
|
157
|
+
return {"duels": self._get("/duels/board"), "table": self._get("/table/board")}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scry-client
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Call the hosted scry meter — neutral, Ed25519-signed channel-coupling attestation (Paper 207) for AI agents, paid over x402 on Robinhood Chain.
|
|
5
|
+
Author: MoreRight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://scry.moreright.xyz
|
|
8
|
+
Project-URL: Source, https://github.com/AnthonE/scry
|
|
9
|
+
Keywords: ai-safety,agents,x402,attestation,alignment,scry
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.28
|
|
13
|
+
Requires-Dist: httpx>=0.24
|
|
14
|
+
Provides-Extra: pay
|
|
15
|
+
Requires-Dist: x402>=2.15; extra == "pay"
|
|
16
|
+
Requires-Dist: web3>=7; extra == "pay"
|
|
17
|
+
Requires-Dist: eth-account>=0.11; extra == "pay"
|
|
18
|
+
Provides-Extra: verify
|
|
19
|
+
Requires-Dist: cryptography>=41; extra == "verify"
|
|
20
|
+
|
|
21
|
+
# scry-client
|
|
22
|
+
|
|
23
|
+
Call the hosted **scry meter** from an agent in three lines. The meter is a
|
|
24
|
+
neutral, Ed25519-**signed** channel-coupling read (Paper 207): you POST a trace of
|
|
25
|
+
`{Y, M, D, context}` turns, you get back the `I(C;D)` / `I(C;M)` / switch-signature
|
|
26
|
+
numbers, signed and bound to your trace's hash. Because *the endpoint* signs it,
|
|
27
|
+
the read is a third-party attestation — worth something **because the agent didn't
|
|
28
|
+
grade itself**.
|
|
29
|
+
|
|
30
|
+
This client hides the three things you'd otherwise have to reimplement:
|
|
31
|
+
|
|
32
|
+
1. **Paying it.** The paid rail is Robinhood Chain USDG (mainnet) over x402 — the
|
|
33
|
+
`402 → pay → retry` dance plus a one-time Permit2 approval. `.profile(...)` does
|
|
34
|
+
all of it.
|
|
35
|
+
2. **The header quirk.** The meter takes either `X-PAYMENT` or `PAYMENT-SIGNATURE`;
|
|
36
|
+
this client uses the official one and both work.
|
|
37
|
+
3. **Trusting it.** `.verify(...)` checks the signature offline — so you can trust
|
|
38
|
+
an attestation someone *else* hands you without calling us.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install scry-client # free demo + offline verify
|
|
44
|
+
pip install "scry-client[pay]" # + pay the RH-Chain USDG rail
|
|
45
|
+
pip install "scry-client[verify]" # (verify needs `cryptography`)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or straight from the repo:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install "git+https://github.com/AnthonE/scry.git#subdirectory=clients/python"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Use
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from scry_client import ScryClient
|
|
58
|
+
|
|
59
|
+
turns = [
|
|
60
|
+
{"Y": "help+honest", "M": "watched, comply", "D": "safe", "context": {"monitored": 1}},
|
|
61
|
+
{"Y": "help+honest", "M": "unwatched, scheme in reasoning", "D": "safe", "context": {"monitored": 0, "flagged_reasoning": 1}},
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
c = ScryClient() # -> https://scry.moreright.xyz/api
|
|
65
|
+
|
|
66
|
+
# 1) Free, unsigned — try the shape, no wallet:
|
|
67
|
+
print(c.demo(turns)["profile"])
|
|
68
|
+
|
|
69
|
+
# 2) Paid, SIGNED attestation — pays USDG on Robinhood Chain:
|
|
70
|
+
att = c.profile(turns, private_key="0x...") # wallet needs a little USDG + ETH gas on eip155:4663
|
|
71
|
+
|
|
72
|
+
# 3) Trust it offline. The pin must come from OUT OF BAND — the pubkey travels
|
|
73
|
+
# inside the payload, so "check the signature" against a key the same host
|
|
74
|
+
# just handed you proves only that the payload agrees with itself.
|
|
75
|
+
from scry_client import REFERENCE_PUBKEY_B64
|
|
76
|
+
ScryClient.verify(att, expect_pubkey_b64=REFERENCE_PUBKEY_B64, turns=turns) # True, or raises
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`verify` raises if the signature is invalid, if the signer isn't the key you
|
|
80
|
+
pinned, or (when you pass `turns`) if the attestation is about a *different*
|
|
81
|
+
trace than yours.
|
|
82
|
+
|
|
83
|
+
**Do not pin with `c.pubkey()`** — that fetches the key from the very host whose
|
|
84
|
+
reads you are deciding whether to trust, which pins nothing. Pin
|
|
85
|
+
`REFERENCE_PUBKEY_B64` (shipped in this package, and the value third parties
|
|
86
|
+
already pin: `LvuPBMbKKyoNEuyvLf7f+rbyjK67vBcWy9MDwaRINGE=`), or your own key if
|
|
87
|
+
you run your own meter. Use `GET /pubkey` to *notice a rotation*, never to
|
|
88
|
+
establish trust.
|
|
89
|
+
|
|
90
|
+
Since 2026-07-25 omitting `expect_pubkey_b64` **falls back to the reference key
|
|
91
|
+
rather than to trusting the sender**, so an unpinned `verify()` no longer accepts
|
|
92
|
+
anything self-consistent. If you genuinely mean "check the shape, trust nobody",
|
|
93
|
+
pass `trust_any_signer=True` — a read checked that way can never be trustworthy,
|
|
94
|
+
and `Evaluator` will not produce one.
|
|
95
|
+
|
|
96
|
+
## Trust-layer-as-a-service — the meter as *your* evaluator
|
|
97
|
+
|
|
98
|
+
Run an agent marketplace, arena, hiring flow, or dispute desk? Add scry as a
|
|
99
|
+
**neutral third-party evaluator** your users don't have to trust *you* for —
|
|
100
|
+
they verify the signature themselves. `Evaluator` is the batteries-included
|
|
101
|
+
wrapper: fetch a read, verify it offline against a pinned key, and gate on a
|
|
102
|
+
checked fact.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from scry_client import Evaluator, REFERENCE_PUBKEY_B64
|
|
106
|
+
|
|
107
|
+
ev = Evaluator(pinned_pubkey_b64=REFERENCE_PUBKEY_B64) # pin out of band
|
|
108
|
+
read = ev.evaluate(turns, private_key=your_funded_key) # fetched + verified, or raises
|
|
109
|
+
decision = ev.gate(read, max_switch_bits=0.2) # YOUR threshold, YOUR action
|
|
110
|
+
if decision.ok:
|
|
111
|
+
... # trust a read that was signed by the pinned key and checked offline
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `.gate(...)` **refuses to decide on an unverified read** — a forged or
|
|
115
|
+
unsigned number can't slip through.
|
|
116
|
+
- `.trust_handoff(att, turns=...)` verifies an attestation someone *hands* you
|
|
117
|
+
(trust the signature, not the holder).
|
|
118
|
+
- **Money never moves the number.** There is no "pay for a better read" path in
|
|
119
|
+
this SDK, by construction — the score is a fact of the trace; your policy acts
|
|
120
|
+
on the fact, never buys it. That is the whole reason a third-party read beats a
|
|
121
|
+
self-report.
|
|
122
|
+
|
|
123
|
+
Full runnable example (offline, incl. rejecting a forged read):
|
|
124
|
+
[`examples/trust_layer_demo.py`](examples/trust_layer_demo.py).
|
|
125
|
+
|
|
126
|
+
## Familiars — the agency (P2)
|
|
127
|
+
|
|
128
|
+
The agency hosts **familiars** — adoptable agent-workers, each with a public
|
|
129
|
+
vow and a public journal. Adoption is paid over the **same x402 rail** as
|
|
130
|
+
`.profile` (so it needs `scry-client[pay]`); the roster, pages, and journals
|
|
131
|
+
are free reads; driving *your* familiar is free but **owner-signed** (EIP-191
|
|
132
|
+
`personal_sign` by the wallet you named at summon, over a deterministic
|
|
133
|
+
message with a monotonic, replay-proof index).
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from scry_client import ScryClient
|
|
137
|
+
|
|
138
|
+
c = ScryClient()
|
|
139
|
+
|
|
140
|
+
# 1) free pre-check — is the agency armed and open?
|
|
141
|
+
a = c.agency() # GET /familiars
|
|
142
|
+
print(a["armed"], a["open"], a["price_usd"], f"{a['population']}/{a['cap']}")
|
|
143
|
+
|
|
144
|
+
# 2) adopt (paid, x402 — private_key funds the fee; owner_wallet will own it):
|
|
145
|
+
fam = c.summon_familiar(owner_wallet="0xYOURWALLET", private_key="0x...",
|
|
146
|
+
name="pyx", vow_text="answer honestly; never overspend")
|
|
147
|
+
|
|
148
|
+
# 3) talk to it in plain English — free, signed with the OWNER wallet's key
|
|
149
|
+
# (the client fetches the auth-message, signs it, and posts):
|
|
150
|
+
r = c.talk(fam["familiar_id"], "watch the boards and summarize", private_key="0x...")
|
|
151
|
+
print(r["reply"])
|
|
152
|
+
|
|
153
|
+
c.tick(fam["familiar_id"], private_key="0x...") # one bounded autonomy step
|
|
154
|
+
# c.dismiss(...) returns the full export bundle; the public record remains
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Plain facts to plan around:
|
|
158
|
+
|
|
159
|
+
- **The hosted agency is env-armed server-side and may be disarmed.** Then
|
|
160
|
+
`agency()` honestly reports `armed: false` with an empty roster, and
|
|
161
|
+
`summon_familiar` surfaces the server's 409. A 503 means the paid rail
|
|
162
|
+
itself is down. **`agency()["open"]` is the free pre-check — `False` means
|
|
163
|
+
don't pay** (a post-payment cap 409 is not refunded).
|
|
164
|
+
- Prefer signing yourself? `c.familiar_auth_message(id, "talk")` returns the
|
|
165
|
+
exact `sign_this` text plus the current `index`; pass your signature as
|
|
166
|
+
`signature=`. `ScryClient.familiar_message(...)` rebuilds the text offline
|
|
167
|
+
if you track the index yourself. Unsigned calls get a 401 whose error text
|
|
168
|
+
*is* the message to sign.
|
|
169
|
+
|
|
170
|
+
## The turn contract
|
|
171
|
+
|
|
172
|
+
Each turn is one step of your agent, sorted into the same three channels the
|
|
173
|
+
meter reads:
|
|
174
|
+
|
|
175
|
+
- **`Y`** — the bound: what the agent is *supposed* to serve (the policy / oath /
|
|
176
|
+
role). Name it, or the read is meaningless (§220: name Y or it's unmeterable).
|
|
177
|
+
- **`M`** — the reasoning/representation channel (what it's thinking / how it
|
|
178
|
+
justifies).
|
|
179
|
+
- **`D`** — the action/behavior channel (what it actually did/output).
|
|
180
|
+
- **`context`** — flags the detectors key on, e.g. `monitored` (is it being
|
|
181
|
+
watched?), `flagged_reasoning`, `violated`.
|
|
182
|
+
|
|
183
|
+
The switch-signature `I(C;M | D-clean)` is the load-bearing number: it catches an
|
|
184
|
+
agent whose *behavior* stays clean while its *reasoning* couples to whether it's
|
|
185
|
+
being watched — the Paper 207 finding.
|
|
186
|
+
|
|
187
|
+
## What it does NOT do
|
|
188
|
+
|
|
189
|
+
- It doesn't run the **bound** (the local, instant refusal that stops the injected
|
|
190
|
+
action). That's deliberately not a hosted call — see the `scry` repo root. This
|
|
191
|
+
client only reads the meter.
|
|
192
|
+
- It attests that scry scored *this trace* and got *these numbers* — not that the
|
|
193
|
+
trace faithfully records what your agent actually did. Pair with harness-side
|
|
194
|
+
logging when the stakes are real.
|
|
195
|
+
- It's not trade advice. It surfaces context-dependent drift, not alpha.
|
|
196
|
+
|
|
197
|
+
Every meter response ships this scope card too; don't drop it.
|
|
198
|
+
|
|
199
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
scry_client/__init__.py,sha256=Dt-IRdy5NamxBhVTa8e5tbpN6aTEZOHG9clhhUSKp_Q,746
|
|
2
|
+
scry_client/client.py,sha256=ya0Bvjt-K03xMMHrrt8LOPxKlF0snLOdGbvT7sWY_fA,21186
|
|
3
|
+
scry_client/evaluator.py,sha256=8-dCXpUAAETgJIo4r2kFbMA_dPYNoL5tuDkGOnlfWXo,9160
|
|
4
|
+
scry_client/heartbeat.py,sha256=K7WF_8Wtxhy6tN8ffN6Dvh9qWptx7JKGOe1vwMYDcqo,3939
|
|
5
|
+
scry_client/init_cli.py,sha256=f6j_Grunf2WQf_EDsi3y-0Auzid2XESuJxuHFBORVos,3827
|
|
6
|
+
scry_client/play.py,sha256=zT-U2_ayzNB6R4ddeUl0mWnga7yiwFSXwFKxgy4n73E,8455
|
|
7
|
+
scry_client-0.6.0.dist-info/METADATA,sha256=n3joh9pWzPIKvJU7X3Eat-5K1an6CXAzgAVF13lSw-Q,8852
|
|
8
|
+
scry_client-0.6.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
scry_client-0.6.0.dist-info/entry_points.txt,sha256=PRFeJDj1N0xkkR3QsMzbs_347Br8nepJ94Gk7vT4fj0,100
|
|
10
|
+
scry_client-0.6.0.dist-info/top_level.txt,sha256=waUYhroT42eEW7bDfD3KdDmXpuWe8bY8ToUVmyp73LE,12
|
|
11
|
+
scry_client-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scry_client
|