agent-apprenticeship 0.1.6 → 0.1.7

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.
Files changed (33) hide show
  1. package/bin/agent-apprenticeship.js +85 -37
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/agent_apprenticeship_trace/__init__.py +1 -1
  5. package/src/agent_apprenticeship_trace/actual_outputs_normalizer.py +104 -28
  6. package/src/agent_apprenticeship_trace/apprentice_adapters.py +3 -1
  7. package/src/agent_apprenticeship_trace/artifact_resolver.py +6 -0
  8. package/src/agent_apprenticeship_trace/bundle_exporter.py +132 -2
  9. package/src/agent_apprenticeship_trace/cli.py +1171 -262
  10. package/src/agent_apprenticeship_trace/codex_runner.py +89 -6
  11. package/src/agent_apprenticeship_trace/config.py +251 -32
  12. package/src/agent_apprenticeship_trace/ecosystem_r2.py +515 -0
  13. package/src/agent_apprenticeship_trace/env.py +5 -5
  14. package/src/agent_apprenticeship_trace/evaluator.py +127 -11
  15. package/src/agent_apprenticeship_trace/grader.py +12 -11
  16. package/src/agent_apprenticeship_trace/learning.py +270 -1
  17. package/src/agent_apprenticeship_trace/llm_output_normalizer.py +8 -7
  18. package/src/agent_apprenticeship_trace/loop.py +103 -4
  19. package/src/agent_apprenticeship_trace/loop_review.py +686 -0
  20. package/src/agent_apprenticeship_trace/mentor_checkpoints.py +49 -21
  21. package/src/agent_apprenticeship_trace/openai_structured.py +84 -14
  22. package/src/agent_apprenticeship_trace/package_exporter.py +1 -1
  23. package/src/agent_apprenticeship_trace/progress.py +2 -1
  24. package/src/agent_apprenticeship_trace/public_run.py +369 -57
  25. package/src/agent_apprenticeship_trace/public_sanitizer.py +37 -1
  26. package/src/agent_apprenticeship_trace/rubric_generation.py +11 -10
  27. package/src/agent_apprenticeship_trace/schemas.py +378 -3
  28. package/src/agent_apprenticeship_trace/task_intake.py +43 -9
  29. package/src/agent_apprenticeship_trace/tdo.py +2304 -0
  30. package/src/agent_apprenticeship_trace/trace_normalizer.py +351 -17
  31. package/src/agent_apprenticeship_trace/trace_prompt.py +38 -11
  32. package/src/agent_apprenticeship_trace/training_signals.py +1 -1
  33. package/src/agent_apprenticeship_trace/verifier.py +12 -6
@@ -0,0 +1,515 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ import shutil
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import zipfile
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .config import (
17
+ DEFAULT_R2_BUCKET,
18
+ DEFAULT_R2_CONTRIBUTION_PREFIX,
19
+ DEFAULT_R2_INDEX_PREFIX,
20
+ DEFAULT_R2_SEED_PREFIX,
21
+ Settings,
22
+ get_settings,
23
+ )
24
+ from .env import redact_secrets
25
+ from .io import append_jsonl, read_json, read_jsonl, write_json
26
+ from .public_sanitizer import sanitize_public_obj
27
+
28
+
29
+ def utc_now() -> str:
30
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def safe_object_id(value: str) -> str:
34
+ raw = str(value or "").strip()
35
+ if "/" in raw or "\\" in raw or ".." in raw:
36
+ raise ValueError("Unsafe ecosystem package id for object storage.")
37
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip(".-")
38
+ if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned:
39
+ raise ValueError("Unsafe ecosystem package id for object storage.")
40
+ return cleaned[:160]
41
+
42
+
43
+ def identity_path(settings: Settings | None = None) -> Path:
44
+ s = settings or get_settings()
45
+ return s.app_home / "ecosystem_identity.json"
46
+
47
+
48
+ def shared_history_path(settings: Settings | None = None) -> Path:
49
+ s = settings or get_settings()
50
+ return s.app_home / "shared_contributions.jsonl"
51
+
52
+
53
+ def get_or_create_ecosystem_identity(settings: Settings | None = None) -> dict[str, Any]:
54
+ s = settings or get_settings()
55
+ path = identity_path(s)
56
+ if path.exists():
57
+ data = read_json(path)
58
+ if isinstance(data, dict) and data.get("ecosystem_user_id"):
59
+ return data
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ data = {
62
+ "ecosystem_user_id": f"aa-user-{secrets.token_hex(12)}",
63
+ "installation_id": f"aa-install-{secrets.token_hex(12)}",
64
+ "created_at": utc_now(),
65
+ "display_name": None,
66
+ "metadata_json": {},
67
+ }
68
+ write_json(path, data)
69
+ return data
70
+
71
+
72
+ def shared_history(settings: Settings | None = None) -> list[dict[str, Any]]:
73
+ path = shared_history_path(settings)
74
+ if not path.exists():
75
+ return []
76
+ return [row for row in read_jsonl(path) if isinstance(row, dict)]
77
+
78
+
79
+ def append_shared_history(record: dict[str, Any], settings: Settings | None = None) -> None:
80
+ path = shared_history_path(settings)
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ append_jsonl(path, record)
83
+
84
+
85
+ def shared_count(settings: Settings | None = None) -> int:
86
+ return len(shared_history(settings))
87
+
88
+
89
+ def r2_api_url(settings: Settings | None = None) -> str | None:
90
+ s = settings or get_settings()
91
+ value = (s.r2_api_url or "").strip().rstrip("/")
92
+ return value or None
93
+
94
+
95
+ def r2_status(settings: Settings | None = None) -> dict[str, Any]:
96
+ s = settings or get_settings()
97
+ identity = get_or_create_ecosystem_identity(s)
98
+ return {
99
+ "storage_backend": s.ecosystem_storage_backend,
100
+ "r2_api_url": r2_api_url(s),
101
+ "r2_bucket": s.r2_bucket or DEFAULT_R2_BUCKET,
102
+ "r2_index_prefix": s.r2_index_prefix or DEFAULT_R2_INDEX_PREFIX,
103
+ "r2_contribution_prefix": s.r2_contribution_prefix or DEFAULT_R2_CONTRIBUTION_PREFIX,
104
+ "r2_seed_prefix": s.r2_seed_prefix or DEFAULT_R2_SEED_PREFIX,
105
+ "ecosystem_user_id": identity.get("ecosystem_user_id"),
106
+ "shared_count": shared_count(s),
107
+ }
108
+
109
+
110
+ def item_id_for_package(package_id: str) -> str:
111
+ return f"aa-item-{safe_object_id(package_id)}"
112
+
113
+
114
+ def _bundle_manifest(bundle: Path) -> dict[str, Any]:
115
+ manifest = bundle / "contribution_manifest.json"
116
+ if not manifest.exists():
117
+ raise FileNotFoundError(f"Experience Compilation manifest not found: {manifest}")
118
+ return read_json(manifest)
119
+
120
+
121
+ def _first_text(*values: Any) -> str | None:
122
+ for value in values:
123
+ if isinstance(value, list):
124
+ for item in value:
125
+ if item not in (None, ""):
126
+ return str(item)
127
+ elif value not in (None, ""):
128
+ return str(value)
129
+ return None
130
+
131
+
132
+ def _as_list(value: Any) -> list[Any]:
133
+ if value is None:
134
+ return []
135
+ if isinstance(value, list):
136
+ return value
137
+ if isinstance(value, tuple):
138
+ return list(value)
139
+ if value == "":
140
+ return []
141
+ return [value]
142
+
143
+
144
+ def _first_list(*values: Any) -> list[str]:
145
+ for value in values:
146
+ items = [str(item) for item in _as_list(value) if item not in (None, "")]
147
+ if items:
148
+ return items
149
+ return []
150
+
151
+
152
+ def _read_optional_json(path: Path) -> Any:
153
+ if not path.exists():
154
+ return None
155
+ try:
156
+ return read_json(path)
157
+ except Exception:
158
+ return None
159
+
160
+
161
+ def _economic_value_category(value: Any, rows_by_type: dict[str, Any], artifacts: int, traces: int) -> str:
162
+ raw = str(value or "").strip().lower()
163
+ if raw in {"low", "medium", "high", "unknown"}:
164
+ return raw
165
+ if raw and raw not in {"none", "null"}:
166
+ if any(token in raw for token in ["high", "strategic", "critical", "enterprise"]):
167
+ return "high"
168
+ if any(token in raw for token in ["low", "small", "minor"]):
169
+ return "low"
170
+ return "medium"
171
+ accepted = 0
172
+ for count in (rows_by_type or {}).values():
173
+ try:
174
+ accepted += int(count or 0)
175
+ except Exception:
176
+ pass
177
+ if accepted >= 12 or artifacts >= 4 or traces >= 3:
178
+ return "high"
179
+ if accepted >= 4 or artifacts >= 1 or traces >= 1:
180
+ return "medium"
181
+ return "unknown"
182
+
183
+
184
+ def ecosystem_item_from_bundle(
185
+ bundle: Path,
186
+ metadata: dict[str, Any],
187
+ *,
188
+ settings: Settings | None = None,
189
+ identity: dict[str, Any] | None = None,
190
+ source: str = "contribution",
191
+ ) -> dict[str, Any]:
192
+ s = settings or get_settings()
193
+ identity = identity or get_or_create_ecosystem_identity(s)
194
+ manifest = _bundle_manifest(bundle)
195
+ package_id = safe_object_id(str(metadata.get("bundle_id") or manifest.get("bundle_id") or bundle.name))
196
+ item_id = item_id_for_package(package_id)
197
+ api_url = r2_api_url(s)
198
+ root_prefix = (
199
+ (os.environ.get("AA_R2_QA_PREFIX") or "qa").strip("/")
200
+ if source == "qa"
201
+ else (s.r2_contribution_prefix or DEFAULT_R2_CONTRIBUTION_PREFIX).strip("/")
202
+ )
203
+ prefix = f"{root_prefix}/{package_id}"
204
+ download_url = f"{api_url}/v1/ecosystem/packages/{item_id}" if api_url else None
205
+ compiler_dir = bundle / "experience_compiler"
206
+ skill_title = manifest.get("title") or metadata.get("title") or package_id
207
+ skill_summary = ""
208
+ skill_path = compiler_dir / "skill_pack" / "skill.md"
209
+ if skill_path.exists():
210
+ for line in skill_path.read_text(errors="ignore").splitlines():
211
+ clean = line.strip("# ").strip()
212
+ if clean:
213
+ skill_summary = clean[:240]
214
+ break
215
+ rows_by_type = manifest.get("experience_compiler_rows_by_type") or manifest.get("tdo_rows_by_type") or {}
216
+ env_spec = _read_optional_json(compiler_dir / "environment" / "environment_spec.json") or {}
217
+ skill_card = _read_optional_json(compiler_dir / "skill_pack" / "skill_card.json") or {}
218
+ retrieval_card = _read_optional_json(compiler_dir / "skill_pack" / "retrieval_card.json") or {}
219
+ loop_manifest = _read_optional_json(bundle / "loops" / "loop_manifest.json") or {}
220
+ artifact_count = int(metadata.get("artifact_count") or manifest.get("artifact_count") or 0)
221
+ trace_count = int(metadata.get("trace_count") or metadata.get("attempts") or manifest.get("trace_count") or manifest.get("attempts") or 0)
222
+ domains = _first_list(metadata.get("domains"), manifest.get("domains"), [env_spec.get("environment_domain")])
223
+ subdomains = _first_list(metadata.get("subdomains"), manifest.get("subdomains"))
224
+ domain = _first_text(domains) or "unknown"
225
+ subdomain = _first_text(subdomains) or "unknown"
226
+ tags = sorted(set(_first_list(metadata.get("tags"), manifest.get("tags"), skill_card.get("tags"), retrieval_card.get("tags"))))
227
+ surfaces = sorted(set(_first_list(env_spec.get("interaction_surfaces"), metadata.get("surfaces"), manifest.get("surfaces"))))
228
+ tools = sorted(set(_first_list(env_spec.get("required_tools"), metadata.get("tools"), manifest.get("tools"))))
229
+ expected_value = _economic_value_category(
230
+ metadata.get("expected_economic_value") or manifest.get("expected_economic_value"),
231
+ rows_by_type,
232
+ artifact_count,
233
+ trace_count,
234
+ )
235
+ economic_summary = (
236
+ str(metadata.get("economic_value_summary") or manifest.get("economic_value_summary") or "").strip()
237
+ or f"{expected_value.title()} reuse value based on trace evidence, artifact coverage, and generated training rows."
238
+ )
239
+ package_version = str(manifest.get("package_version") or manifest.get("agent_apprenticeship_version") or "0.1.7")
240
+ loop_count = int(loop_manifest.get("loop_iteration_count") or manifest.get("loop_iteration_count") or 0)
241
+ has_skill = (compiler_dir / "skill_pack").exists()
242
+ has_training = (compiler_dir / "training_time_package").exists()
243
+ has_eval = (compiler_dir / "eval_package").exists() or (compiler_dir / "evals").exists()
244
+ has_environment = (compiler_dir / "environment").exists()
245
+ return sanitize_public_obj(
246
+ {
247
+ "id": item_id,
248
+ "item_id": item_id,
249
+ "kind": "agent_experience_package",
250
+ "experience_compilation_id": package_id,
251
+ "title": metadata.get("title") or manifest.get("title") or package_id,
252
+ "task_title": manifest.get("title") or metadata.get("title") or package_id,
253
+ "summary": metadata.get("summary") or metadata.get("title") or manifest.get("title") or "",
254
+ "domain": domain,
255
+ "domains": domains or [domain],
256
+ "subdomain": subdomain,
257
+ "subdomains": subdomains or ([subdomain] if subdomain != "unknown" else []),
258
+ "expected_economic_value": expected_value,
259
+ "economic_value_summary": economic_summary,
260
+ "tags": tags,
261
+ "surfaces": surfaces,
262
+ "tools": tools,
263
+ "created_at": metadata.get("created_at") or utc_now(),
264
+ "updated_at": utc_now(),
265
+ "source": source,
266
+ "package_id": package_id,
267
+ "package_version": package_version,
268
+ "task_id": manifest.get("task_id"),
269
+ "apprenticeship_mode": manifest.get("apprenticeship_mode") or metadata.get("apprenticeship_mode"),
270
+ "mentor_mode": manifest.get("mentor_mode"),
271
+ "apprentice_agent": metadata.get("apprentice_agent"),
272
+ "mentor_model_provider": metadata.get("mentor_model_provider"),
273
+ "task_status": metadata.get("task_status") or manifest.get("task_status"),
274
+ "run_status": metadata.get("run_status") or manifest.get("run_status"),
275
+ "attempts": metadata.get("attempts") or manifest.get("attempts") or 0,
276
+ "traced_steps": metadata.get("traced_steps") or manifest.get("traced_steps") or 0,
277
+ "tdo_status": manifest.get("tdo_status"),
278
+ "trace_count": trace_count,
279
+ "artifact_count": artifact_count,
280
+ "loop_iteration_count": loop_count,
281
+ "tdo_rows_by_type": rows_by_type,
282
+ "training_row_counts": rows_by_type,
283
+ "has_full_experience_compilation": True,
284
+ "has_runtime_training": bool((compiler_dir / "skill_pack" / "skill.md").exists()),
285
+ "has_skill_pack": has_skill,
286
+ "has_training_time_package": has_training,
287
+ "has_eval_package": has_eval,
288
+ "has_environment_package": has_environment,
289
+ "available_outputs": {
290
+ "full_experience_compilation": True,
291
+ "full_training_package": True,
292
+ "runtime_training": bool((compiler_dir / "skill_pack" / "skill.md").exists()),
293
+ "skill_pack": has_skill,
294
+ "training_time_package": has_training,
295
+ "eval_package": has_eval,
296
+ "environment_package": has_environment,
297
+ },
298
+ "experience_compiler": {
299
+ "enabled": bool(manifest.get("experience_compiler_enabled") or compiler_dir.exists()),
300
+ "status": manifest.get("experience_compiler_status") or manifest.get("tdo_status"),
301
+ "skill_title": skill_title,
302
+ "skill_summary": skill_summary,
303
+ "runtime_training_summary": skill_summary or f"Runtime training compiled from {skill_title}.",
304
+ "training_data_counts": rows_by_type,
305
+ "environment_domain": metadata.get("environment_domain") or manifest.get("environment_domain") or _first_text(metadata.get("domains"), manifest.get("domains")),
306
+ },
307
+ "contributor": {
308
+ "ecosystem_user_id": identity.get("ecosystem_user_id"),
309
+ "display_name": identity.get("display_name"),
310
+ },
311
+ "storage": {
312
+ "backend": "cloudflare_r2",
313
+ "bucket": s.r2_bucket or DEFAULT_R2_BUCKET,
314
+ "path_prefix": prefix,
315
+ "manifest_path": f"{prefix}/manifest.json",
316
+ "package_path": f"{prefix}/contribution_package.zip",
317
+ "download_url": download_url,
318
+ },
319
+ }
320
+ )
321
+
322
+
323
+ def storage_ref_for_item(
324
+ item: dict[str, Any],
325
+ *,
326
+ settings: Settings | None = None,
327
+ dry_run: bool,
328
+ uploaded_at: str | None = None,
329
+ url: str | None = None,
330
+ ) -> dict[str, Any]:
331
+ s = settings or get_settings()
332
+ storage = item.get("storage") or {}
333
+ return sanitize_public_obj(
334
+ {
335
+ "storage_backend": "forsy-r2",
336
+ "bucket_backend": "cloudflare_r2",
337
+ "ingestion_api_url": r2_api_url(s),
338
+ "bucket": s.r2_bucket or DEFAULT_R2_BUCKET,
339
+ "path_prefix": storage.get("path_prefix"),
340
+ "manifest_path": storage.get("manifest_path"),
341
+ "package_path": storage.get("package_path"),
342
+ "index_item_path": f"{(s.r2_index_prefix or DEFAULT_R2_INDEX_PREFIX).strip('/')}/items/{item.get('id')}.json",
343
+ "item_id": item.get("id"),
344
+ "package_id": item.get("package_id"),
345
+ "ecosystem_user_id": (item.get("contributor") or {}).get("ecosystem_user_id"),
346
+ "url": url or (r2_api_url(s) + f"/v1/ecosystem/items/{item.get('id')}" if r2_api_url(s) else None),
347
+ "uploaded_at": uploaded_at,
348
+ "dry_run": dry_run,
349
+ "upload_mode": "dry_run" if dry_run else "r2_worker_ingestion",
350
+ }
351
+ )
352
+
353
+
354
+ def write_storage_ref(bundle: Path, submission_dir: Path, storage_ref: dict[str, Any]) -> Path:
355
+ path = submission_dir / "storage_ref.json"
356
+ write_json(path, storage_ref)
357
+ write_json(bundle / "storage_ref.json", storage_ref)
358
+ copied_bundle = submission_dir / "contribution_bundle"
359
+ if copied_bundle.exists():
360
+ write_json(copied_bundle / "storage_ref.json", storage_ref)
361
+ return path
362
+
363
+
364
+ def _multipart_body(fields: dict[str, tuple[str, bytes, str] | str]) -> tuple[bytes, str]:
365
+ boundary = f"----aa-r2-{secrets.token_hex(12)}"
366
+ parts: list[bytes] = []
367
+ for name, value in fields.items():
368
+ parts.append(f"--{boundary}\r\n".encode())
369
+ if isinstance(value, tuple):
370
+ filename, content, content_type = value
371
+ parts.append(
372
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
373
+ f"Content-Type: {content_type}\r\n\r\n".encode()
374
+ )
375
+ parts.append(content)
376
+ parts.append(b"\r\n")
377
+ else:
378
+ parts.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
379
+ parts.append(f"--{boundary}--\r\n".encode())
380
+ return b"".join(parts), boundary
381
+
382
+
383
+ def upload_contribution_to_worker(
384
+ *,
385
+ api_url: str,
386
+ package_zip: Path,
387
+ ecosystem_item: dict[str, Any],
388
+ manifest: dict[str, Any],
389
+ storage_ref: dict[str, Any],
390
+ qa: bool = False,
391
+ timeout: int = 60,
392
+ ) -> dict[str, Any]:
393
+ api_url = api_url.rstrip("/")
394
+ body, boundary = _multipart_body(
395
+ {
396
+ "package_zip": (package_zip.name, package_zip.read_bytes(), "application/zip"),
397
+ "ecosystem_item_json": json.dumps(ecosystem_item, sort_keys=True),
398
+ "manifest_json": json.dumps(manifest, sort_keys=True),
399
+ "storage_ref_json": json.dumps(storage_ref, sort_keys=True),
400
+ "qa": "true" if qa else "false",
401
+ }
402
+ )
403
+ req = urllib.request.Request(
404
+ f"{api_url}/v1/ecosystem/contributions",
405
+ data=body,
406
+ method="POST",
407
+ headers={
408
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
409
+ "User-Agent": "agent-apprenticeship-cli",
410
+ },
411
+ )
412
+ try:
413
+ with urllib.request.urlopen(req, timeout=timeout) as response:
414
+ payload = response.read().decode("utf-8")
415
+ return json.loads(payload or "{}")
416
+ except urllib.error.HTTPError as exc:
417
+ detail = exc.read().decode("utf-8", errors="replace")
418
+ raise RuntimeError(redact_secrets(f"R2 ingestion failed: HTTP {exc.code} {detail}")) from exc
419
+ except urllib.error.URLError as exc:
420
+ raise RuntimeError(redact_secrets(f"R2 ingestion API unavailable: {exc}")) from exc
421
+
422
+
423
+ def _request_json(url: str, *, timeout: int = 30) -> dict[str, Any] | list[Any]:
424
+ req = urllib.request.Request(url, headers={"User-Agent": "agent-apprenticeship-cli"})
425
+ with urllib.request.urlopen(req, timeout=timeout) as response:
426
+ return json.loads(response.read().decode("utf-8") or "{}")
427
+
428
+
429
+ def _request_bytes(url: str, *, timeout: int = 60) -> bytes:
430
+ req = urllib.request.Request(url, headers={"User-Agent": "agent-apprenticeship-cli"})
431
+ with urllib.request.urlopen(req, timeout=timeout) as response:
432
+ return response.read()
433
+
434
+
435
+ def r2_index_cache_path(settings: Settings | None = None) -> Path:
436
+ s = settings or get_settings()
437
+ return s.app_home / "ecosystem" / "r2_index_cache.json"
438
+
439
+
440
+ def fetch_r2_index(settings: Settings | None = None, *, use_cache: bool = True) -> list[dict[str, Any]]:
441
+ s = settings or get_settings()
442
+ api_url = r2_api_url(s)
443
+ if not api_url:
444
+ if use_cache and r2_index_cache_path(s).exists():
445
+ data = read_json(r2_index_cache_path(s))
446
+ return [row for row in data.get("items", []) if isinstance(row, dict)]
447
+ raise FileNotFoundError("R2 ingestion API is not configured. Run `apprentice ecosystem configure --r2-api-url <url>` or use `--storage github`.")
448
+ data = _request_json(f"{api_url}/v1/ecosystem/index")
449
+ rows = data.get("items") if isinstance(data, dict) else data
450
+ rows = [row for row in rows or [] if isinstance(row, dict)]
451
+ cache = r2_index_cache_path(s)
452
+ cache.parent.mkdir(parents=True, exist_ok=True)
453
+ write_json(cache, {"cached_at": utc_now(), "api_url": api_url, "items": rows})
454
+ return rows
455
+
456
+
457
+ def fetch_r2_item(item_id: str, settings: Settings | None = None) -> dict[str, Any]:
458
+ s = settings or get_settings()
459
+ api_url = r2_api_url(s)
460
+ if not api_url:
461
+ for row in fetch_r2_index(s, use_cache=True):
462
+ if row.get("id") == item_id or row.get("package_id") == item_id or row.get("bundle_id") == item_id:
463
+ return row
464
+ raise FileNotFoundError("R2 ingestion API is not configured and item is not in cache.")
465
+ data = _request_json(f"{api_url}/v1/ecosystem/items/{urllib.parse.quote(item_id)}")
466
+ return data if isinstance(data, dict) else {}
467
+
468
+
469
+ def download_r2_package(item_id: str, dest: Path, settings: Settings | None = None) -> Path:
470
+ s = settings or get_settings()
471
+ api_url = r2_api_url(s)
472
+ if not api_url:
473
+ raise FileNotFoundError("R2 ingestion API is not configured. Use `--storage github` for legacy fallback.")
474
+ data = _request_bytes(f"{api_url}/v1/ecosystem/packages/{urllib.parse.quote(item_id)}")
475
+ zip_path = dest.parent / f"{safe_object_id(item_id)}.zip"
476
+ zip_path.parent.mkdir(parents=True, exist_ok=True)
477
+ zip_path.write_bytes(data)
478
+ if dest.exists():
479
+ shutil.rmtree(dest)
480
+ dest.mkdir(parents=True, exist_ok=True)
481
+ with zipfile.ZipFile(zip_path) as zf:
482
+ for info in zf.infolist():
483
+ target = dest / info.filename
484
+ if not target.resolve().is_relative_to(dest.resolve()):
485
+ raise RuntimeError("R2 package contains an unsafe path.")
486
+ zf.extractall(dest)
487
+ return dest
488
+
489
+
490
+ def append_successful_share(
491
+ *,
492
+ item: dict[str, Any],
493
+ bundle: Path,
494
+ storage_backend: str,
495
+ url: str | None,
496
+ sharing_mode: str,
497
+ settings: Settings | None = None,
498
+ ) -> dict[str, Any]:
499
+ s = settings or get_settings()
500
+ record = {
501
+ "item_id": item.get("id"),
502
+ "package_id": item.get("package_id"),
503
+ "ecosystem_user_id": (item.get("contributor") or {}).get("ecosystem_user_id"),
504
+ "uploaded_at": utc_now(),
505
+ "storage_backend": storage_backend,
506
+ "url": url,
507
+ "local_bundle_path": str(bundle),
508
+ "title": item.get("title"),
509
+ "domain": item.get("domain"),
510
+ "tdo_status": item.get("tdo_status"),
511
+ "apprenticeship_mode": item.get("apprenticeship_mode"),
512
+ "sharing_mode": sharing_mode,
513
+ }
514
+ append_shared_history(record, s)
515
+ return record
@@ -23,13 +23,13 @@ def load_local_env(root: Path | None=None) -> dict[str,str]:
23
23
  return loaded
24
24
 
25
25
  SECRET_PATTERNS=[
26
- re.compile(r"sk-proj-[A-Za-z0-9_-]{20,}"),
27
- re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"),
28
- re.compile(r"sk-or-v1-[A-Za-z0-9_-]{20,}"),
29
- re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
26
+ re.compile(r"(?<![A-Za-z0-9])sk-proj-[A-Za-z0-9_-]{20,}"),
27
+ re.compile(r"(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}"),
28
+ re.compile(r"(?<![A-Za-z0-9])sk-or-v1-[A-Za-z0-9_-]{20,}"),
29
+ re.compile(r"(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}"),
30
30
  re.compile(r"AIza[A-Za-z0-9_-]{20,}"),
31
31
  re.compile(r"dsk-[A-Za-z0-9_-]{20,}"),
32
- re.compile(r"user_[A-Za-z0-9_-]{12,}"),
32
+ re.compile(r"\buser_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{16,}\b"),
33
33
  re.compile(r"(['\"]user_id['\"]\s*:\s*)['\"][^'\"]+['\"]"),
34
34
  ]
35
35
  def contains_secret(text: str) -> bool: