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 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
+ )