agenthand 0.1.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.
- agenthand/__init__.py +8 -0
- agenthand/assets.py +402 -0
- agenthand/composite.py +930 -0
- agenthand/config.py +142 -0
- agenthand/ingest.py +142 -0
- agenthand/ledger.py +167 -0
- agenthand/measure.py +1165 -0
- agenthand/meta_client.py +1152 -0
- agenthand/pricing.py +109 -0
- agenthand/sam_masks.py +155 -0
- agenthand/segmentation.py +339 -0
- agenthand/server.py +728 -0
- agenthand/verify.py +160 -0
- agenthand-0.1.0.dist-info/METADATA +193 -0
- agenthand-0.1.0.dist-info/RECORD +17 -0
- agenthand-0.1.0.dist-info/WHEEL +4 -0
- agenthand-0.1.0.dist-info/entry_points.txt +3 -0
agenthand/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""agenthand: an MCP server giving AI sales agents identify / cut_out / draw.
|
|
2
|
+
|
|
3
|
+
The seller never opens agenthand; their agent calls it. Tools exchange
|
|
4
|
+
asset handles (asset://<id>) instead of image bytes, every call writes a
|
|
5
|
+
cost receipt, and billable spend is fail-closed behind a hard cap.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
agenthand/assets.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""The asset store: images live on disk, tools pass handles.
|
|
2
|
+
|
|
3
|
+
Every image agenthand touches is stored as a file under the data dir and
|
|
4
|
+
referenced everywhere else as ``asset://<id>``. Each asset has a sidecar
|
|
5
|
+
JSON record (``<id>.json``, written via temp file + os.replace so a crash
|
|
6
|
+
mid-write cannot corrupt the store) next to its data file. A legacy
|
|
7
|
+
``assets.json`` index from the first scaffold is still READ as a fallback
|
|
8
|
+
so old data dirs keep working; new writes are sidecars only.
|
|
9
|
+
|
|
10
|
+
Two hashes are recorded per asset:
|
|
11
|
+
|
|
12
|
+
- ``sha256``: the hash of the stored file bytes.
|
|
13
|
+
- ``pixel_sha256``: the hash of the decoded RGBA pixel bytes, recorded
|
|
14
|
+
when the asset decodes as an image. File bytes can change without the
|
|
15
|
+
pixels changing (re-encoding) and pixels can change under the same file
|
|
16
|
+
hash's blind spots (metadata-driven decode differences), so the
|
|
17
|
+
Fidelity Lock checks both.
|
|
18
|
+
|
|
19
|
+
A third identity is recorded when the image carries one:
|
|
20
|
+
``color_profile_sha256``, the hash of the embedded ICC colour profile.
|
|
21
|
+
Pixel values alone do not determine displayed colour — a Display P3
|
|
22
|
+
photo whose profile is dropped renders differently in every viewer
|
|
23
|
+
while every pixel hash still matches (the J2 Gauntlet's strongest
|
|
24
|
+
attack). The pipeline therefore preserves the source's profile end to
|
|
25
|
+
end and records its identity on every asset, so a profile change is
|
|
26
|
+
detectable and counts as a fidelity failure in the draw receipt.
|
|
27
|
+
|
|
28
|
+
Lineage: each record names the asset it was derived from
|
|
29
|
+
(``source_asset_id``), forming a chain back to a source photo. The draw
|
|
30
|
+
tool refuses a foreground whose chain is anything other than
|
|
31
|
+
foreground -> source_photo or foreground -> mask -> source_photo: a
|
|
32
|
+
generated asset anywhere in the ancestry would put model pixels
|
|
33
|
+
inside the layer the Fidelity Lock protects.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import hashlib
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import uuid
|
|
42
|
+
from dataclasses import asdict, dataclass
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
from io import BytesIO
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
HANDLE_PREFIX = "asset://"
|
|
48
|
+
|
|
49
|
+
# Asset kinds. draw only accepts KIND_FOREGROUND, chained to KIND_SOURCE.
|
|
50
|
+
KIND_SOURCE = "source_photo"
|
|
51
|
+
KIND_FOREGROUND = "foreground_png"
|
|
52
|
+
KIND_MASK = "mask_png"
|
|
53
|
+
KIND_PLATE = "background_plate"
|
|
54
|
+
KIND_COMPOSITE = "composite"
|
|
55
|
+
KIND_OVERLAY = "mask_overlay"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _register_image_openers() -> None:
|
|
59
|
+
"""Teach Pillow the formats phones actually produce.
|
|
60
|
+
|
|
61
|
+
iPhones save HEIC by default; without pillow-heif registered,
|
|
62
|
+
Pillow cannot decode the format at all and ingest would refuse the
|
|
63
|
+
most common seller photo there is (J2 Gauntlet, Fable lane). The
|
|
64
|
+
dependency is declared in pyproject; the guard keeps a stripped
|
|
65
|
+
environment honest (HEIC input then fails as undecodable, like any
|
|
66
|
+
unsupported format) instead of crashing every import.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
from pillow_heif import register_heif_opener
|
|
70
|
+
|
|
71
|
+
register_heif_opener()
|
|
72
|
+
except ImportError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_register_image_openers()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AssetIntegrityError(RuntimeError):
|
|
80
|
+
"""Raised when stored bytes no longer match their recorded hashes."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class AssetRecord:
|
|
85
|
+
asset_id: str
|
|
86
|
+
sha256: str
|
|
87
|
+
kind: str # one of the KIND_* constants
|
|
88
|
+
filename: str
|
|
89
|
+
byte_size: int
|
|
90
|
+
created_at: str
|
|
91
|
+
source_asset_id: str | None = None # the asset this one was derived from
|
|
92
|
+
pixel_sha256: str | None = None # hash of decoded RGBA bytes, if an image
|
|
93
|
+
width: int | None = None
|
|
94
|
+
height: int | None = None
|
|
95
|
+
color_profile_sha256: str | None = None # hash of the embedded ICC profile
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def pixel_hash_of_image(image) -> str:
|
|
99
|
+
"""Hash of an image's decoded RGBA pixel bytes (PIL Image input)."""
|
|
100
|
+
return hashlib.sha256(image.convert("RGBA").tobytes()).hexdigest()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def icc_profile_of_image(image) -> bytes | None:
|
|
104
|
+
"""The image's embedded ICC profile bytes, or None if it has none."""
|
|
105
|
+
info = getattr(image, "info", None) or {}
|
|
106
|
+
icc = info.get("icc_profile")
|
|
107
|
+
return bytes(icc) if icc else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def color_profile_sha256_of_image(image) -> str | None:
|
|
111
|
+
"""Hash of an image's embedded ICC profile, or None if it has none."""
|
|
112
|
+
icc = icc_profile_of_image(image)
|
|
113
|
+
return hashlib.sha256(icc).hexdigest() if icc is not None else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def describe_color_profile(icc: bytes | None) -> str:
|
|
117
|
+
"""Human-readable name for a colour profile, for receipts.
|
|
118
|
+
|
|
119
|
+
Best effort: a parseable ICC profile reports its own description;
|
|
120
|
+
anything else falls back to a generic label (the profile's hash,
|
|
121
|
+
recorded alongside, is the identity that actually gets checked).
|
|
122
|
+
"""
|
|
123
|
+
if not icc:
|
|
124
|
+
return "sRGB (no embedded ICC profile)"
|
|
125
|
+
try:
|
|
126
|
+
from PIL import ImageCms
|
|
127
|
+
|
|
128
|
+
profile = ImageCms.ImageCmsProfile(BytesIO(icc))
|
|
129
|
+
description = profile.profile.profile_description
|
|
130
|
+
if description:
|
|
131
|
+
return str(description).strip()
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return "embedded ICC profile (description unreadable)"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def carry_color_profile(source_image, target_image):
|
|
138
|
+
"""Copy the source image's ICC profile onto the target image.
|
|
139
|
+
|
|
140
|
+
Several Pillow operations (alpha_composite, Image.composite) build
|
|
141
|
+
a fresh image and drop ``info``; every pipeline step that derives
|
|
142
|
+
pixels from a profiled image must re-attach the profile explicitly
|
|
143
|
+
or the product silently changes colour space mid-chain.
|
|
144
|
+
"""
|
|
145
|
+
icc = icc_profile_of_image(source_image)
|
|
146
|
+
if icc is not None:
|
|
147
|
+
target_image.info["icc_profile"] = icc
|
|
148
|
+
return target_image
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _image_facts(
|
|
152
|
+
data: bytes,
|
|
153
|
+
) -> tuple[str | None, int | None, int | None, str | None]:
|
|
154
|
+
"""(pixel_sha256, width, height, color_profile_sha256) for image bytes.
|
|
155
|
+
|
|
156
|
+
All None when the data does not decode as an image.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
from PIL import Image
|
|
160
|
+
|
|
161
|
+
with Image.open(BytesIO(data)) as img:
|
|
162
|
+
img.load()
|
|
163
|
+
return (
|
|
164
|
+
pixel_hash_of_image(img),
|
|
165
|
+
img.width,
|
|
166
|
+
img.height,
|
|
167
|
+
color_profile_sha256_of_image(img),
|
|
168
|
+
)
|
|
169
|
+
except Exception:
|
|
170
|
+
return None, None, None, None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AssetStore:
|
|
174
|
+
def __init__(self, data_dir: Path):
|
|
175
|
+
self.data_dir = Path(data_dir)
|
|
176
|
+
self.assets_dir = self.data_dir / "assets"
|
|
177
|
+
self.index_path = self.data_dir / "assets.json" # legacy, read-only
|
|
178
|
+
|
|
179
|
+
# -- internal record handling -------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _sidecar_path(self, asset_id: str) -> Path:
|
|
182
|
+
return self.assets_dir / f"{asset_id}.json"
|
|
183
|
+
|
|
184
|
+
def _load_legacy_index(self) -> dict[str, dict]:
|
|
185
|
+
if not self.index_path.exists():
|
|
186
|
+
return {}
|
|
187
|
+
try:
|
|
188
|
+
return json.loads(self.index_path.read_text(encoding="utf-8"))
|
|
189
|
+
except (json.JSONDecodeError, OSError):
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
def _load_record(self, asset_id: str) -> AssetRecord:
|
|
193
|
+
sidecar = self._sidecar_path(asset_id)
|
|
194
|
+
if sidecar.exists():
|
|
195
|
+
return AssetRecord(**json.loads(sidecar.read_text(encoding="utf-8")))
|
|
196
|
+
legacy = self._load_legacy_index()
|
|
197
|
+
if asset_id in legacy:
|
|
198
|
+
return AssetRecord(**legacy[asset_id])
|
|
199
|
+
raise KeyError(f"Unknown asset id: {asset_id!r}")
|
|
200
|
+
|
|
201
|
+
def _save_record(self, record: AssetRecord) -> None:
|
|
202
|
+
self.assets_dir.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
sidecar = self._sidecar_path(record.asset_id)
|
|
204
|
+
tmp = sidecar.with_suffix(f".json.tmp-{uuid.uuid4().hex}")
|
|
205
|
+
tmp.write_text(
|
|
206
|
+
json.dumps(asdict(record), indent=2, sort_keys=True), encoding="utf-8"
|
|
207
|
+
)
|
|
208
|
+
os.replace(tmp, sidecar)
|
|
209
|
+
|
|
210
|
+
def _receipt_path(self, asset_id: str) -> Path:
|
|
211
|
+
return self.assets_dir / f"{asset_id}.receipt.json"
|
|
212
|
+
|
|
213
|
+
def receipt_path(self, handle: str) -> Path:
|
|
214
|
+
"""Where an asset's persisted fidelity receipt lives, if any.
|
|
215
|
+
|
|
216
|
+
The receipt sits beside the asset's data file and its record
|
|
217
|
+
sidecar, named ``<asset_id>.receipt.json`` so each composite
|
|
218
|
+
keeps its own (a single shared receipt.json would be
|
|
219
|
+
overwritten by the next draw).
|
|
220
|
+
"""
|
|
221
|
+
return self._receipt_path(self.parse_handle(handle))
|
|
222
|
+
|
|
223
|
+
def save_receipt(self, handle: str, receipt: dict) -> Path:
|
|
224
|
+
"""Persist a fidelity receipt beside its asset; returns its path.
|
|
225
|
+
|
|
226
|
+
Written via temp file + os.replace, the same convention as the
|
|
227
|
+
record sidecars, so a crash mid-write cannot leave a torn
|
|
228
|
+
receipt behind: readers see the old file or the new one, never
|
|
229
|
+
half of either.
|
|
230
|
+
"""
|
|
231
|
+
self.assets_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
path = self.receipt_path(handle)
|
|
233
|
+
tmp = path.with_suffix(f".json.tmp-{uuid.uuid4().hex}")
|
|
234
|
+
tmp.write_text(
|
|
235
|
+
json.dumps(receipt, indent=2, sort_keys=True), encoding="utf-8"
|
|
236
|
+
)
|
|
237
|
+
os.replace(tmp, path)
|
|
238
|
+
return path
|
|
239
|
+
|
|
240
|
+
def load_receipt(self, handle: str) -> dict | None:
|
|
241
|
+
"""The asset's persisted fidelity receipt, or None if none exists."""
|
|
242
|
+
path = self.receipt_path(handle)
|
|
243
|
+
if not path.exists():
|
|
244
|
+
return None
|
|
245
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
246
|
+
|
|
247
|
+
# -- public API ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def parse_handle(handle: str) -> str:
|
|
251
|
+
if not isinstance(handle, str) or not handle.startswith(HANDLE_PREFIX):
|
|
252
|
+
raise ValueError(f"Not an asset handle: {handle!r}")
|
|
253
|
+
asset_id = handle[len(HANDLE_PREFIX):].strip()
|
|
254
|
+
if not asset_id:
|
|
255
|
+
raise ValueError(f"Empty asset id in handle: {handle!r}")
|
|
256
|
+
return asset_id
|
|
257
|
+
|
|
258
|
+
def put_bytes(
|
|
259
|
+
self,
|
|
260
|
+
data: bytes,
|
|
261
|
+
*,
|
|
262
|
+
kind: str,
|
|
263
|
+
suffix: str = ".bin",
|
|
264
|
+
source_asset_id: str | None = None,
|
|
265
|
+
) -> str:
|
|
266
|
+
"""Store bytes as a new asset; returns its asset:// handle."""
|
|
267
|
+
self.assets_dir.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
asset_id = uuid.uuid4().hex
|
|
269
|
+
filename = f"{asset_id}{suffix}"
|
|
270
|
+
tmp = self.assets_dir / f"{filename}.tmp-{uuid.uuid4().hex}"
|
|
271
|
+
tmp.write_bytes(data)
|
|
272
|
+
os.replace(tmp, self.assets_dir / filename)
|
|
273
|
+
|
|
274
|
+
pixel_sha256, width, height, color_profile_sha256 = _image_facts(data)
|
|
275
|
+
record = AssetRecord(
|
|
276
|
+
asset_id=asset_id,
|
|
277
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
278
|
+
kind=kind,
|
|
279
|
+
filename=filename,
|
|
280
|
+
byte_size=len(data),
|
|
281
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
282
|
+
source_asset_id=source_asset_id,
|
|
283
|
+
pixel_sha256=pixel_sha256,
|
|
284
|
+
width=width,
|
|
285
|
+
height=height,
|
|
286
|
+
color_profile_sha256=color_profile_sha256,
|
|
287
|
+
)
|
|
288
|
+
self._save_record(record)
|
|
289
|
+
return HANDLE_PREFIX + asset_id
|
|
290
|
+
|
|
291
|
+
def put_image(
|
|
292
|
+
self,
|
|
293
|
+
image,
|
|
294
|
+
*,
|
|
295
|
+
kind: str,
|
|
296
|
+
source_asset_id: str | None = None,
|
|
297
|
+
) -> str:
|
|
298
|
+
"""Store a PIL Image as a PNG asset; returns its asset:// handle.
|
|
299
|
+
|
|
300
|
+
The image's embedded ICC colour profile, when it has one, is
|
|
301
|
+
written into the PNG so the stored asset carries the same
|
|
302
|
+
profile as the in-memory image it was stored from.
|
|
303
|
+
"""
|
|
304
|
+
icc = icc_profile_of_image(image)
|
|
305
|
+
converted = image.convert("RGBA")
|
|
306
|
+
buffer = BytesIO()
|
|
307
|
+
if icc is not None:
|
|
308
|
+
converted.info["icc_profile"] = icc
|
|
309
|
+
converted.save(buffer, format="PNG", icc_profile=icc)
|
|
310
|
+
else:
|
|
311
|
+
converted.save(buffer, format="PNG")
|
|
312
|
+
return self.put_bytes(
|
|
313
|
+
buffer.getvalue(), kind=kind, suffix=".png", source_asset_id=source_asset_id
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def get_record(self, handle: str) -> AssetRecord:
|
|
317
|
+
return self._load_record(self.parse_handle(handle))
|
|
318
|
+
|
|
319
|
+
def resolve(self, handle: str) -> Path:
|
|
320
|
+
record = self.get_record(handle)
|
|
321
|
+
return self.assets_dir / record.filename
|
|
322
|
+
|
|
323
|
+
def read_bytes(self, handle: str) -> bytes:
|
|
324
|
+
return self.resolve(handle).read_bytes()
|
|
325
|
+
|
|
326
|
+
def read_image(self, handle: str):
|
|
327
|
+
"""Decode the asset as a PIL Image in RGBA mode.
|
|
328
|
+
|
|
329
|
+
The stored ICC colour profile rides along on the returned
|
|
330
|
+
image so downstream steps can carry it forward explicitly.
|
|
331
|
+
"""
|
|
332
|
+
from PIL import Image
|
|
333
|
+
|
|
334
|
+
with Image.open(BytesIO(self.read_bytes(handle))) as img:
|
|
335
|
+
img.load()
|
|
336
|
+
converted = img.convert("RGBA")
|
|
337
|
+
return carry_color_profile(img, converted)
|
|
338
|
+
|
|
339
|
+
def sha256_of(self, handle: str) -> str:
|
|
340
|
+
return self.get_record(handle).sha256
|
|
341
|
+
|
|
342
|
+
def lineage(self, handle: str) -> list[AssetRecord]:
|
|
343
|
+
"""The derivation chain for a handle: [record, parent, ..., root].
|
|
344
|
+
|
|
345
|
+
Raises KeyError if any link in the chain is missing; a chain that
|
|
346
|
+
cannot be walked to its root must not be trusted by draw.
|
|
347
|
+
"""
|
|
348
|
+
chain: list[AssetRecord] = []
|
|
349
|
+
seen: set[str] = set()
|
|
350
|
+
record = self.get_record(handle)
|
|
351
|
+
while True:
|
|
352
|
+
if record.asset_id in seen:
|
|
353
|
+
raise AssetIntegrityError(
|
|
354
|
+
f"Cycle in asset lineage at {record.asset_id!r}."
|
|
355
|
+
)
|
|
356
|
+
seen.add(record.asset_id)
|
|
357
|
+
chain.append(record)
|
|
358
|
+
if record.source_asset_id is None:
|
|
359
|
+
return chain
|
|
360
|
+
record = self._load_record(record.source_asset_id)
|
|
361
|
+
|
|
362
|
+
def verify_integrity(self, handle: str) -> None:
|
|
363
|
+
"""Re-hash the stored asset and RAISE on any mismatch.
|
|
364
|
+
|
|
365
|
+
Checks the file-byte hash and, when one was recorded, the pixel
|
|
366
|
+
hash. This is the check the Fidelity Lock receipt relies on: if
|
|
367
|
+
the stored product layer no longer matches the hashes recorded
|
|
368
|
+
when segmentation produced it, it must never be presented as the
|
|
369
|
+
untouched product. Returns None on success; raises
|
|
370
|
+
AssetIntegrityError (or KeyError for an unknown handle).
|
|
371
|
+
"""
|
|
372
|
+
record = self.get_record(handle)
|
|
373
|
+
data = self.read_bytes(handle)
|
|
374
|
+
actual_file = hashlib.sha256(data).hexdigest()
|
|
375
|
+
if actual_file != record.sha256:
|
|
376
|
+
raise AssetIntegrityError(
|
|
377
|
+
f"Asset {handle!r} file hash mismatch: recorded "
|
|
378
|
+
f"{record.sha256}, actual {actual_file}. The stored bytes "
|
|
379
|
+
"were modified after ingest."
|
|
380
|
+
)
|
|
381
|
+
if record.pixel_sha256 is not None or record.color_profile_sha256 is not None:
|
|
382
|
+
pixel_sha256, _, _, color_profile_sha256 = _image_facts(data)
|
|
383
|
+
if (
|
|
384
|
+
record.pixel_sha256 is not None
|
|
385
|
+
and pixel_sha256 != record.pixel_sha256
|
|
386
|
+
):
|
|
387
|
+
raise AssetIntegrityError(
|
|
388
|
+
f"Asset {handle!r} pixel hash mismatch: recorded "
|
|
389
|
+
f"{record.pixel_sha256}, actual {pixel_sha256}. The "
|
|
390
|
+
"decoded pixels differ from what was stored."
|
|
391
|
+
)
|
|
392
|
+
if (
|
|
393
|
+
record.color_profile_sha256 is not None
|
|
394
|
+
and color_profile_sha256 != record.color_profile_sha256
|
|
395
|
+
):
|
|
396
|
+
raise AssetIntegrityError(
|
|
397
|
+
f"Asset {handle!r} colour profile mismatch: recorded "
|
|
398
|
+
f"{record.color_profile_sha256}, actual "
|
|
399
|
+
f"{color_profile_sha256}. The stored image's colour "
|
|
400
|
+
"profile was changed after ingest; identical pixel "
|
|
401
|
+
"values would display as different colours."
|
|
402
|
+
)
|