merge: sync feedback schema with main and assign revision 0030

This commit is contained in:
SToneX
2026-09-10 15:14:29 +08:00
155 changed files with 13148 additions and 395 deletions
+55 -9
View File
@@ -36,6 +36,7 @@ Regenerate via `scripts/build-map.py`.
| `scripts/contactout_overflow_verify.py` | architecture/contactout.md |
| `scripts/dev-local.sh` | ops/deploy.md |
| `scripts/dump_surface.py` | architecture/composition.md |
| `scripts/import_arena_verification.py` | interface/enrich-arena.md |
| `scripts/indexnow_submit.py` | interface/seo.md |
| `scripts/minimax_plugin.py` | interface/skill.md |
| `scripts/provider_balances.py` | ops/capacity.md |
@@ -66,10 +67,17 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/alembic/versions/0024_membership_calls_today_counter.py` | architecture/data-model.md |
| `src/treg/alembic/versions/0025_feedback.py` | architecture/feedback.md |
| `src/treg/alembic/versions/0026_callreview.py` | architecture/feedback.md |
| `src/treg/alembic/versions/0027_feedback_handling.py` | architecture/feedback.md |
| `src/treg/alembic/versions/0027_enrich_arena.py` | interface/enrich-arena.md |
| `src/treg/alembic/versions/0028_arena_insights.py` | architecture/data-model.md, interface/enrich-arena.md |
| `src/treg/alembic/versions/0029_arena_verification_snapshot.py` | architecture/data-model.md, interface/enrich-arena.md |
| `src/treg/alembic/versions/0030_enrich_arena.py` | architecture/data-model.md |
| `src/treg/alembic/versions/0030_feedback_handling.py` | architecture/feedback.md |
| `src/treg/analytics.py` | architecture/data-model.md |
| `src/treg/api.py` | architecture/archive.md, architecture/money.md, architecture/multi-tenancy.md, architecture/proxy-model.md, architecture/super-admin.md, interface/api.md, interface/dashboard.md, interface/landing-sandbox.md, interface/seo.md |
| `src/treg/application/__init__.py` | architecture/import-boundaries.md |
| `src/treg/application/arena.py` | interface/enrich-arena.md |
| `src/treg/application/arena_insights.py` | interface/enrich-arena.md |
| `src/treg/application/arena_verification_insights.py` | interface/enrich-arena.md |
| `src/treg/application/asynctasks.py` | architecture/archive.md, architecture/money.md, architecture/multi-tenancy.md, architecture/proxy-model.md |
| `src/treg/application/auth.py` | architecture/data-model.md, architecture/mcp-oauth.md, architecture/multi-tenancy.md, interface/api.md, interface/onboarding.md |
| `src/treg/application/billing.py` | architecture/money.md |
@@ -80,12 +88,14 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/application/call/evidence.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/idempotency.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/intake.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/invite.py` | architecture/feedback.md |
| `src/treg/application/call/overflow.py` | architecture/import-boundaries.md, ops/capacity.md |
| `src/treg/application/call/reserve.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/resolve.py` | architecture/contactout.md, architecture/import-boundaries.md, architecture/instagram-oauth.md, architecture/money.md, architecture/multi-tenancy.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/route.py` | architecture/catalog.md, architecture/import-boundaries.md |
| `src/treg/application/call/service.py` | architecture/archive.md, architecture/import-boundaries.md, architecture/instagram-oauth.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/settle.py` | architecture/import-boundaries.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/call/sumble.py` | architecture/sumble.md |
| `src/treg/application/call/types.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/application/connect.py` | architecture/auth-secrets.md, architecture/composition.md, guides/expanding-a-category.md, interface/api.md |
| `src/treg/application/feedback.py` | architecture/feedback.md |
@@ -98,7 +108,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/application/signup.py` | architecture/ads-conversions.md, architecture/money.md, architecture/multi-tenancy.md, interface/api.md |
| `src/treg/archive.py` | architecture/archive.md |
| `src/treg/audit.py` | architecture/data-model.md, ops/deploy.md |
| `src/treg/bootstrap.py` | architecture/archive.md, architecture/composition.md |
| `src/treg/bootstrap.py` | architecture/archive.md, architecture/composition.md, interface/enrich-arena.md |
| `src/treg/bootstrap_handlers.py` | architecture/composition.md, architecture/data-model.md, interface/api.md |
| `src/treg/bootstrap_http.py` | architecture/composition.md, interface/api.md |
| `src/treg/call_surface.py` | architecture/composition.md, architecture/proxy-model.md, interface/api.md |
@@ -166,6 +176,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/catalog/examples/quickenrich.x.industries.json` | architecture/catalog.md |
| `src/treg/catalog/examples/quickenrich.x.revenue-ranges.json` | architecture/catalog.md |
| `src/treg/catalog/examples/replicate.image-gen.flux-schnell.json` | architecture/catalog.md |
| `src/treg/catalog/examples/sumble.organizations.json` | architecture/sumble.md |
| `src/treg/catalog/examples/tomba.people.email.verify.json` | architecture/catalog.md |
| `src/treg/catalog/examples/trykitt.people.email.find.json` | architecture/catalog.md |
| `src/treg/catalog/examples/trykitt.people.email.verify.json` | architecture/catalog.md |
@@ -186,6 +197,8 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/catalog/quickenrich.yaml` | architecture/catalog.md |
| `src/treg/catalog/replicate.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog/replicate.yaml` | architecture/catalog.md |
| `src/treg/catalog/sumble.extended.yaml` | architecture/sumble.md |
| `src/treg/catalog/sumble.yaml` | architecture/sumble.md |
| `src/treg/catalog/tikhub.extended.yaml` | architecture/catalog.md |
| `src/treg/catalog/tomba.yaml` | architecture/catalog.md, architecture/money.md |
| `src/treg/catalog/trykitt.yaml` | architecture/catalog.md |
@@ -196,6 +209,8 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/convert.py` | interface/cli.md |
| `src/treg/crypto.py` | architecture/auth-secrets.md |
| `src/treg/domain/__init__.py` | architecture/import-boundaries.md |
| `src/treg/domain/arena.py` | interface/enrich-arena.md |
| `src/treg/domain/arena_insights.py` | interface/enrich-arena.md |
| `src/treg/domain/asynctasks/__init__.py` | architecture/import-boundaries.md, architecture/money.md |
| `src/treg/domain/capacity/__init__.py` | architecture/import-boundaries.md, ops/capacity.md |
| `src/treg/domain/capacity/collectors.py` | ops/capacity.md |
@@ -229,7 +244,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/domain/governance/budgets.py` | architecture/import-boundaries.md, architecture/money.md, architecture/multi-tenancy.md, interface/api.md |
| `src/treg/domain/governance/publicdemo.py` | architecture/import-boundaries.md, architecture/multi-tenancy.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/domain/governance/sandbox.py` | interface/landing-sandbox.md |
| `src/treg/domain/governance/teams.py` | architecture/import-boundaries.md, architecture/multi-tenancy.md, interface/api.md |
| `src/treg/domain/governance/teams.py` | architecture/import-boundaries.md, architecture/multi-tenancy.md, interface/api.md, interface/enrich-arena.md |
| `src/treg/domain/governance/usage.py` | architecture/import-boundaries.md, architecture/multi-tenancy.md, architecture/proxy-model.md, interface/api.md |
| `src/treg/domain/identity/__init__.py` | architecture/import-boundaries.md |
| `src/treg/domain/identity/access.py` | architecture/multi-tenancy.md, architecture/super-admin.md, interface/api.md |
@@ -251,6 +266,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/infra/__init__.py` | architecture/money.md |
| `src/treg/infra/catalog_observations.py` | architecture/catalog.md |
| `src/treg/infra/db.py` | architecture/data-model.md, architecture/multi-tenancy.md, ops/deploy.md |
| `src/treg/infra/kv.py` | architecture/feedback.md |
| `src/treg/infra/oauth_exchange.py` | architecture/auth-secrets.md, architecture/instagram-oauth.md, guides/expanding-a-category.md |
| `src/treg/infra/oauth_refresh.py` | architecture/auth-secrets.md |
| `src/treg/infra/stripe.py` | architecture/money.md |
@@ -268,7 +284,7 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/maintenance.py` | architecture/data-model.md, ops/deploy.md |
| `src/treg/mcp.py` | architecture/instagram-oauth.md, architecture/mcp-oauth.md |
| `src/treg/mcp_install.py` | interface/skill.md |
| `src/treg/models.py` | architecture/data-model.md, architecture/money.md, architecture/multi-tenancy.md |
| `src/treg/models.py` | architecture/data-model.md, architecture/money.md, architecture/multi-tenancy.md, interface/enrich-arena.md |
| `src/treg/oauth.py` | architecture/auth-secrets.md |
| `src/treg/oauth_providers.py` | architecture/auth-secrets.md, guides/expanding-a-category.md |
| `src/treg/providers.py` | interface/env-import.md |
@@ -276,7 +292,8 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/reconcile.py` | architecture/money.md |
| `src/treg/routers/__init__.py` | interface/api.md |
| `src/treg/routers/admin.py` | architecture/archive.md, architecture/composition.md, architecture/money.md, architecture/super-admin.md, interface/api.md |
| `src/treg/routers/auth.py` | architecture/composition.md, architecture/mcp-oauth.md, architecture/multi-tenancy.md, interface/api.md, interface/onboarding.md |
| `src/treg/routers/arena.py` | interface/enrich-arena.md |
| `src/treg/routers/auth.py` | architecture/composition.md, architecture/mcp-oauth.md, architecture/multi-tenancy.md, interface/api.md, interface/enrich-arena.md, interface/onboarding.md |
| `src/treg/routers/auth_helpers.py` | interface/api.md |
| `src/treg/routers/billing.py` | architecture/composition.md, architecture/money.md, interface/api.md |
| `src/treg/routers/call.py` | architecture/composition.md, architecture/feedback.md, architecture/instagram-oauth.md, architecture/money.md, architecture/proxy-model.md, interface/api.md |
@@ -296,10 +313,15 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/skills.py` | interface/env-import.md |
| `src/treg/timeutil.py` | architecture/data-model.md, interface/api.md |
| `src/treg/web/adtrack.js` | architecture/ads-conversions.md |
| `src/treg/web/agent-setup.js` | interface/dashboard.md, interface/enrich-arena.md |
| `src/treg/web/astra.html` | interface/seo.md |
| `src/treg/web/catalog.css` | interface/seo.md |
| `src/treg/web/claude-connector.html` | architecture/mcp-oauth.md |
| `src/treg/web/connect-demo.html` | architecture/mcp-oauth.md |
| `src/treg/web/enrich-arena.html` | interface/enrich-arena.md |
| `src/treg/web/enrich-arena/arena.css` | interface/enrich-arena.md |
| `src/treg/web/enrich-arena/arena.js` | interface/enrich-arena.md |
| `src/treg/web/enrich-arena/bench.js` | interface/enrich-arena.md |
| `src/treg/web/fable-gtm.html` | interface/seo.md |
| `src/treg/web/feedback.md` | architecture/feedback.md |
| `src/treg/web/grokbot.html` | interface/seo.md |
@@ -308,13 +330,27 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/web/install.sh` | interface/landing-sandbox.md |
| `src/treg/web/landing.html` | interface/seo.md |
| `src/treg/web/llms.txt` | interface/seo.md |
| `src/treg/web/logos/apollo.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/branddev.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/companyenrich.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/contactout.svg` | architecture/contactout.md |
| `src/treg/web/logos/findymail.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/hunter.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/icypeas.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/leadmagic.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/leadsforge.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/lusha.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/pdl.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/predictleads.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/sumble.svg` | architecture/sumble.md |
| `src/treg/web/logos/thecompaniesapi.svg` | interface/enrich-arena.md |
| `src/treg/web/logos/tomba.svg` | interface/enrich-arena.md |
| `src/treg/web/media/astra/page.css` | interface/seo.md |
| `src/treg/web/media/astra/page.js` | interface/seo.md |
| `src/treg/web/people-search.html` | interface/seo.md |
| `src/treg/web/robots.txt` | interface/seo.md |
| `src/treg/web/selfhost.sh` | ops/deploy.md |
| `src/treg/web/sitetrack.js` | architecture/data-model.md, interface/api.md, interface/dashboard.md |
| `src/treg/web/sitetrack.js` | architecture/data-model.md, interface/api.md, interface/dashboard.md, interface/enrich-arena.md |
| `src/treg/web/skill.md` | interface/skill.md |
| `src/treg/web/support.html` | interface/seo.md |
| `src/treg/web/tour/index.html` | interface/dashboard.md |
@@ -326,9 +362,14 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/web/vendor/vue-3.5.41.global.prod.js` | interface/dashboard.md |
| `src/treg/worker.py` | ops/capacity.md, ops/deploy.md |
| `tests/fixtures/aggregators/verification/contactout.json` | architecture/contactout.md |
| `tests/js/arena-bench.test.cjs` | interface/enrich-arena.md |
| `tests/js/arena-template.test.cjs` | interface/enrich-arena.md |
| `tests/js/enrich-arena.test.cjs` | interface/enrich-arena.md |
| `tests/test_aigc_pr_b.py` | architecture/catalog.md |
| `tests/test_alembic_expand_safety.py` | architecture/data-model.md |
| `tests/test_app_roles.py` | architecture/composition.md |
| `tests/test_arena_insights.py` | interface/enrich-arena.md |
| `tests/test_arena_verification_insights.py` | interface/enrich-arena.md |
| `tests/test_asynctasks.py` | architecture/money.md, architecture/multi-tenancy.md, architecture/proxy-model.md |
| `tests/test_auth.py` | architecture/multi-tenancy.md |
| `tests/test_call_application_contract.py` | architecture/proxy-model.md |
@@ -343,6 +384,7 @@ Regenerate via `scripts/build-map.py`.
| `tests/test_capacity_smoothing.py` | ops/capacity.md |
| `tests/test_catalog_api.py` | architecture/catalog.md |
| `tests/test_catalog_validate.py` | architecture/catalog.md, architecture/contactout.md |
| `tests/test_enrich_arena.py` | interface/enrich-arena.md |
| `tests/test_error_capture.py` | architecture/proxy-model.md |
| `tests/test_feedback.py` | architecture/feedback.md |
| `tests/test_feedback_handling_schema.py` | architecture/feedback.md |
@@ -351,6 +393,7 @@ Regenerate via `scripts/build-map.py`.
| `tests/test_influencersclub_overflow.py` | ops/capacity.md |
| `tests/test_instagram_oauth_architecture.py` | architecture/instagram-oauth.md |
| `tests/test_key_providers.py` | architecture/contactout.md |
| `tests/test_kv.py` | architecture/feedback.md |
| `tests/test_marketplace_call.py` | architecture/contactout.md, architecture/mcp-oauth.md, architecture/proxy-model.md |
| `tests/test_mcp.py` | architecture/mcp-oauth.md |
| `tests/test_mcp_directory.py` | architecture/mcp-oauth.md |
@@ -363,6 +406,7 @@ Regenerate via `scripts/build-map.py`.
| `tests/test_route_cost_ceiling.py` | architecture/catalog.md |
| `tests/test_router_dependencies.py` | architecture/multi-tenancy.md |
| `tests/test_routing.py` | architecture/catalog.md, architecture/contactout.md |
| `tests/test_sumble.py` | architecture/sumble.md |
| `tests/test_tag_billing.py` | architecture/proxy-model.md |
| `tests/test_tag_billing_adversarial.py` | architecture/proxy-model.md |
| `tests/test_token_revocation.py` | architecture/multi-tenancy.md |
@@ -377,8 +421,8 @@ Regenerate via `scripts/build-map.py`.
| `architecture/catalog.md` | `quickenrich.yaml`, `quickenrich.extended.yaml`, `quickenrich.companies.search.json`, `quickenrich.people.email.find.json`, `quickenrich.people.enrich.json`, `quickenrich.people.phone.find.json`, `quickenrich.people.search.domain.json`, `quickenrich.people.search.json`, `quickenrich.x.company-services.json`, `quickenrich.x.country-codes.json`, `quickenrich.x.employee-ranges.json`, `quickenrich.x.industries.json`, `quickenrich.x.revenue-ranges.json`, `trykitt.yaml`, `trykitt.people.email.find.json`, `trykitt.people.email.verify.json`, `contracts.yaml`, `millionverifier.yaml`, `millionverifier.people.email.verify.json`, `millionverifier.account.usage.json`, `adapters.yaml`, `test_route_cost_ceiling.py`, `tomba.yaml`, `tomba.people.email.verify.json`, `findymail.search.business-profile.json`, `__init__.py`, `contracts.py`, `paths.py`, `plan.py`, `synthetic.py`, `route.py`, `test_routing.py`, `catalog-drift.yml`, `catalog_drift.py`, `catalog_ingest.py`, `catalog_validate.py`, `aliases.yaml`, `fx.yaml`, `aviato.yaml`, `crustdata.yaml`, `aviato.companies.acquisitions.json`, `aviato.companies.employees.json`, `aviato.companies.enrich.bulk.json`, `aviato.companies.enrich.json`, `aviato.companies.founders.json`, `aviato.companies.funding_rounds.json`, `aviato.companies.investments.json`, `aviato.companies.outbound_investments.json`, `aviato.companies.search.json`, `aviato.linkedin.company.posts.json`, `aviato.linkedin.post.comments.json`, `aviato.linkedin.post.reactions.json`, `aviato.linkedin.post.reposts.json`, `aviato.linkedin.user.posts.json`, `aviato.people.contact.get.json`, `aviato.people.email.find.json`, `aviato.people.enrich.bulk.json`, `aviato.people.enrich.json`, `aviato.people.phone.find.json`, `aviato.people.search.json`, `aviato.people.search.simple.json`, `crustdata.companies.autocomplete.json`, `crustdata.companies.enrich.json`, `crustdata.companies.identify.json`, `crustdata.companies.jobs.search.json`, `crustdata.companies.search.json`, `crustdata.people.autocomplete.json`, `crustdata.people.enrich.json`, `crustdata.people.search.json`, `google-search-console.yaml`, `google-search-console.extended.yaml`, `google-tag-manager.yaml`, `google-tag-manager.extended.yaml`, `instagram.yaml`, `instagram.extended.yaml`, `justoneapi.extended.yaml`, `minimax.yaml`, `apify.yaml`, `brightdata.yaml`, `companyenrich.yaml`, `oceanio.yaml`, `akta.extended.yaml`, `dataforseo.extended.yaml`, `tikhub.extended.yaml`, `minimax.video-gen.result.retrieve.json`, `minimax.video-gen.from_image.json`, `minimax.video-gen.task.status.json`, `openrouter.yaml`, `openrouter.extended.yaml`, `openrouter.x.alibaba-wan-3-0.json`, `replicate.yaml`, `replicate.extended.yaml`, `replicate.image-gen.flux-schnell.json`, `__init__.py`, `store.py`, `settlement.py`, `stats.py`, `catalog_observations.py`, `catalog.py`, `test_aigc_pr_b.py`, `test_catalog_api.py`, `test_catalog_validate.py` |
| `architecture/composition.md` | `bootstrap.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `connect.py`, `mcp_oauth.py`, `session.py`, `admin.py`, `auth.py`, `billing.py`, `call.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `web.py`, `dump_surface.py`, `test_app_roles.py` |
| `architecture/contactout.md` | `contactout.yaml`, `adapters.yaml`, `contactout.people.email.verify.json`, `test_routing.py`, `contactout.companies.search.json`, `contactout.companies.enrich.json`, `resolve.py`, `contactout.py`, `contactout.svg`, `test_marketplace_call.py`, `test_key_providers.py`, `test_capacity_collectors.py`, `test_catalog_validate.py`, `test_capacity_overflow.py`, `contactout_overflow_verify.py`, `contactout.json` |
| `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0005_capacity_policy_snapshot.py`, `0006_overflow_route.py`, `0007_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `0009_callrecord_hit.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `0019_async_poll_failures.py`, `0020_callrecord_created_at_indexes.py`, `0021_ledgerentry_org_created_at_index.py`, `0022_org_spent_today_counter.py`, `0023_callrecord_org_user_created_at_index.py`, `0024_membership_calls_today_counter.py`, `0011_callrecord_archive_link.py`, `0015_idempotentcall_membership_cascade.py`, `maintenance.py`, `sitetrack.js`, `models.py`, `timeutil.py`, `db.py`, `referrals.py`, `audit.py`, `analytics.py`, `bootstrap_handlers.py`, `ratestore.py`, `auth.py`, `test_postgres_reset.py`, `test_alembic_expand_safety.py` |
| `architecture/feedback.md` | `feedback_contract.py`, `__init__.py`, `reports.py`, `reviews.py`, `hints.py`, `config.py`, `call.py`, `feedback.py`, `feedback.py`, `0025_feedback.py`, `0026_callreview.py`, `0027_feedback_handling.py`, `test_feedback_handling_schema.py`, `feedback.md`, `test_feedback.py`, `test_reviews.py`, `test_hints.py` |
| `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0005_capacity_policy_snapshot.py`, `0006_overflow_route.py`, `0007_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `0009_callrecord_hit.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `0019_async_poll_failures.py`, `0020_callrecord_created_at_indexes.py`, `0021_ledgerentry_org_created_at_index.py`, `0022_org_spent_today_counter.py`, `0023_callrecord_org_user_created_at_index.py`, `0024_membership_calls_today_counter.py`, `0030_enrich_arena.py`, `0028_arena_insights.py`, `0029_arena_verification_snapshot.py`, `0011_callrecord_archive_link.py`, `0015_idempotentcall_membership_cascade.py`, `maintenance.py`, `sitetrack.js`, `models.py`, `timeutil.py`, `db.py`, `referrals.py`, `audit.py`, `analytics.py`, `bootstrap_handlers.py`, `ratestore.py`, `auth.py`, `test_postgres_reset.py`, `test_alembic_expand_safety.py` |
| `architecture/feedback.md` | `feedback_contract.py`, `__init__.py`, `reports.py`, `reviews.py`, `hints.py`, `config.py`, `call.py`, `invite.py`, `kv.py`, `feedback.py`, `feedback.py`, `0025_feedback.py`, `0026_callreview.py`, `0030_feedback_handling.py`, `test_feedback_handling_schema.py`, `feedback.md`, `test_feedback.py`, `test_reviews.py`, `test_hints.py`, `test_kv.py` |
| `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `access.py`, `authorize.py`, `idempotency.py`, `overflow.py`, `route.py`, `__init__.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `client_identity.py`, `__init__.py`, `__init__.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `__init__.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `injectors.py`, `relay.py`, `__init__.py`, `limiter.py`, `test_call_architecture.py`, `test_import_lightness.py` |
| `architecture/instagram-oauth.md` | `catalog_ingest.py`, `access.py`, `resolve.py`, `service.py`, `instagram.yaml`, `instagram.extended.yaml`, `cli.py`, `store.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `mcp.py`, `call.py`, `index.html`, `0010_oauth_authorization_method.py`, `test_instagram_oauth_architecture.py` |
| `architecture/local-proxy.md` | `localproxy.py`, `server.js` |
@@ -387,13 +431,15 @@ Regenerate via `scripts/build-map.py`.
| `architecture/money.md` | `__init__.py`, `settlement.py`, `__init__.py`, `models.py`, `billing.py`, `idempotency.py`, `intake.py`, `resolve.py`, `service.py`, `reserve.py`, `settle.py`, `tomba.yaml`, `asynctasks.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `0019_async_poll_failures.py`, `referrals.py`, `budgets.py`, `__init__.py`, `stripe.py`, `reconcile.py`, `referrals.py`, `api.py`, `signup.py`, `admin.py`, `billing.py`, `call.py`, `orgs.py`, `referrals.py`, `test_call_architecture.py`, `test_asynctasks.py` |
| `architecture/multi-tenancy.md` | `models.py`, `api.py`, `caller_metadata.py`, `auth.py`, `asynctasks.py`, `resolve.py`, `signup.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `access.py`, `session.py`, `test_auth.py`, `test_token_revocation.py`, `auth.py`, `orgs.py`, `resources.py`, `bundles.py`, `db.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `test_router_dependencies.py`, `test_asynctasks.py` |
| `architecture/proxy-model.md` | `relay.py`, `ssrf.py`, `api.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `asynctasks.py`, `client_identity.py`, `call_surface.py`, `sandbox_identity.py`, `access.py`, `publicdemo.py`, `usage.py`, `call.py`, `test_call_application_contract.py`, `test_call_cancellation.py`, `test_error_capture.py`, `test_marketplace_call.py`, `test_oauth_billed.py`, `test_passthrough.py`, `test_tag_billing.py`, `test_tag_billing_adversarial.py`, `test_call_architecture.py`, `test_asynctasks.py` |
| `architecture/sumble.md` | `sumble.yaml`, `sumble.extended.yaml`, `sumble.organizations.json`, `sumble.py`, `test_sumble.py`, `sumble.svg` |
| `architecture/super-admin.md` | `api.py`, `admin.py`, `access.py`, `config.py` |
| `foundation/charter.md` | `2026-06-30-jason-tools-registry.md`, `README.md` |
| `guides/expanding-a-category.md` | `oauth_providers.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `connect.py`, `connections.py`, `config.py` |
| `interface/api.md` | `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
| `interface/catalog-review-proposal.md` | `store.py`, `capabilities.yaml` |
| `interface/cli.md` | `cli.py`, `cli_analytics.py`, `convert.py`, `agents.py` |
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py` |
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `agent-setup.js`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py` |
| `interface/enrich-arena.md` | `arena.py`, `arena.py`, `arena.py`, `models.py`, `0027_enrich_arena.py`, `teams.py`, `auth.py`, `bootstrap.py`, `enrich-arena.html`, `arena.js`, `bench.js`, `arena-bench.test.cjs`, `arena-template.test.cjs`, `arena.css`, `agent-setup.js`, `arena_verification_insights.py`, `0029_arena_verification_snapshot.py`, `import_arena_verification.py`, `test_arena_verification_insights.py`, `arena_insights.py`, `arena_insights.py`, `0028_arena_insights.py`, `test_arena_insights.py`, `apollo.svg`, `branddev.svg`, `companyenrich.svg`, `findymail.svg`, `hunter.svg`, `icypeas.svg`, `leadmagic.svg`, `leadsforge.svg`, `lusha.svg`, `pdl.svg`, `predictleads.svg`, `thecompaniesapi.svg`, `tomba.svg`, `sitetrack.js`, `test_enrich_arena.py`, `enrich-arena.test.cjs` |
| `interface/env-import.md` | `providers.py`, `skills.py` |
| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `index.html`, `install.sh` |
| `interface/onboarding.md` | `auth.py`, `__init__.py`, `demo.py`, `cli.py`, `auth.py`, `onboard.py`, `index.html` |
+3
View File
@@ -100,6 +100,9 @@ jobs:
tests/test_ledger_claim_review.py
tests/test_asynctasks.py
tests/test_alembic_baseline.py
tests/test_enrich_arena.py
tests/test_arena_insights.py
tests/test_arena_verification_insights.py
tests/test_feedback.py
tests/test_archive.py
tests/test_call_cancellation.py
+9
View File
@@ -2,6 +2,12 @@
__pycache__/
*.pyc
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
*.sqlite*-wal
*.sqlite*-shm
*.bak
.env
.env.*
@@ -79,3 +85,6 @@ body.json
# Test screenshots — regenerated on demand, not versioned.
tests/screenshots/
# Arena production statistics belong in the database, never a bundled snapshot.
/src/treg/web/enrich-arena/insights.json
+1 -1
View File
@@ -57,7 +57,7 @@ agents then built against a constitution that was wrong.
| `routers/` | HTTP and MCP translation in, response shape out | business rules, query orchestration, money |
| `application/` | use-case sequencing, transaction boundaries, compensation, cross-domain composition | empty wrappers around one-domain CRUD |
| `domain/` | rules explainable and testable alone: `identity`, `governance`, `connections`, `tools`, `catalog`, `capacity`, `money`, `asynctasks`, `feedback` | routers, application, concrete SDKs |
| `infra/` | DB engine and sessions, crypto, upstream relay and SSRF, ratestore, email, Stripe | decisions |
| `infra/` | DB engine and sessions, crypto, upstream relay and SSRF, ratestore, the shared key-value store, email, Stripe | decisions |
- Domains do not import each other, with three sanctioned edges: `governance -> identity`,
`tools -> connections`, `capacity -> catalog` (read-only). `identity` and `money` are leaves.
+7 -1
View File
@@ -3,7 +3,7 @@
![treg — the tool catalog for your agent](docs/assets/treg-hero.png)
**OpenRouter, but for agent tools instead of models.** Point an agent at one base URL with one token
and it can do the job: **2,896 catalogued endpoints across 60 providers** — SEO and backlinks,
and it can do the job: **3,000+ catalogued endpoints across 60+ providers** — SEO and backlinks,
social and trends, people and company enrichment, ads, scraping — **priced per call, from a cent**,
with no provider signup. Plus your own team's keys, skills and CLIs, callable by every teammate's
agent without the credential ever leaving the server.
@@ -127,6 +127,11 @@ treg topup # add funds, or set up automatic top-ups
Out of balance is an HTTP **402** carrying `balance_micro`, `estimated_cost_micro` and a `topup_url`,
so an agent can act on it without reading prose.
**Enrich Arena** lives at `/enrich-arena`, outside the dashboard. Compare enrichment answers with each vendor’s cost and speed,
vote for the best answer in one click, or watch a sequential waterfall. Browsing is
public; submitting requires login, and billable attempts use your team's credits. See the
[Arena guide](docs/context/interface/enrich-arena.md).
## Share & use your own tools
The zero-thought path — point treg at a project and it figures out what's shareable:
@@ -310,6 +315,7 @@ Environment variables (prefix `TREG_`, read from `.env`):
| `TREG_BLOCKED_EMAIL_DOMAINS` | *(empty)* | comma-separated email domains refused at every sign-up/sign-in door and at team creation (subdomains included, case-insensitive). Empty blocks nothing — no list ships in the code |
| `TREG_ADMIN_TOKEN` | *(empty)* | cross-tenant **super-admin** bearer; authorizes every `/admin/*` endpoint. Empty disables the env path (only `is_superadmin` users reach `/admin`). Keep it long + secret. |
| `TREG_EMAIL_DEV_MODE` | `false` | when true, `/auth/email/start` returns the OTP in its response (no mail sender needed) — **dev/local only**, never in prod. |
| `TREG_KV_URL` | *(empty)* | shared key-value store (Redis protocol) for counters every worker must agree on, today the per-team review-invitation budget. Empty = an in-process fallback, fine for one worker |
No `.env` is needed for local dev — every setting has a working default (ephemeral key, sqlite).
+2 -2
View File
@@ -71,8 +71,8 @@ published too. `tests/test_plugin.py` asserts none appears.
**Never edit `skills/treg/SKILL.md`.** Change `src/treg/web/skill.md` and regenerate:
```bash
python3 scripts/build_plugin.py # regenerate BOTH plugins
python3 scripts/build_plugin.py --check # fail if either is stale (also a test)
uv run python scripts/build_plugin.py # regenerate BOTH plugins
uv run python scripts/build_plugin.py --check # fail if either is stale (also a test)
```
## Listing runbook
+2 -2
View File
@@ -28,8 +28,8 @@ exposed at runtime as `treg:treg`.
**Never edit `plugins/minimax/skills/treg/SKILL.md`.** Change `src/treg/web/skill.md` and regenerate:
```bash
python3 scripts/build_plugin.py # regenerate ALL plugins
python3 scripts/build_plugin.py --check # fail if any is stale (also a test)
uv run python scripts/build_plugin.py # regenerate ALL plugins
uv run python scripts/build_plugin.py --check # fail if any is stale (also a test)
python3 scripts/minimax_plugin.py --check # MiniMax package rules (also a test)
```
+1 -1
View File
@@ -66,7 +66,7 @@ whatever balance it can reach, and the token goes into a form.
Upload the bundle at `plugin/`. Regenerate first so the skill matches what is served:
```bash
python3 scripts/build_plugin.py --check # must print OK
uv run python scripts/build_plugin.py --check # must print OK
```
OpenAI scans the bundle for policy compliance, secrets, unnecessary access and conflicting
+3 -1
View File
@@ -32,6 +32,7 @@ covers (frontmatter `sources:`). Regenerate this index with
| [Money — prepaid balance, the ledger, Stripe, and the reports that check it](architecture/money.md) | shipped | __init__.py, settlement.py, __init__.py, models.py, … |
| [Multi-tenancy — orgs, memberships, invites, per-org scoping](architecture/multi-tenancy.md) | shipped | models.py, api.py, caller_metadata.py, auth.py, … |
| [The proxy — faithful credential-injecting relay + tool resolution](architecture/proxy-model.md) | shipped | relay.py, ssrf.py, api.py, authorize.py, … |
| [Sumble — account intelligence, subscription credits and BYOK](architecture/sumble.md) | shipped | sumble.yaml, sumble.extended.yaml, sumble.organizations.json, sumble.py, … |
| [Super-admin — cross-tenant read + control](architecture/super-admin.md) | shipped | api.py, admin.py, access.py, config.py |
## Interfaces (API · CLI · skill)
@@ -41,7 +42,8 @@ covers (frontmatter `sources:`). Regenerate this index with
| [The API — the only brain (FastAPI)](interface/api.md) | shipped | sitetrack.js, api.py, bootstrap_handlers.py, bootstrap_http.py, … |
| [Catalog browse review — categories, platform placement, and domain sections](interface/catalog-review-proposal.md) | reference | store.py, capabilities.yaml |
| [The CLI (treg) + skill scaffolding](interface/cli.md) | shipped | cli.py, cli_analytics.py, convert.py, agents.py |
| [The web dashboard (Ledger, served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, README.md, vue-3.5.41.global.prod.js, … |
| [The web dashboard (Ledger, served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, agent-setup.js, README.md, … |
| [Enrich Arena — paid comparisons, one-click feedback, and visible waterfalls](interface/enrich-arena.md) | shipped | arena.py, arena.py, arena.py, models.py, … |
| [Import — scan a .env AND/OR a skills dir, auto-register as tools + bundles](interface/env-import.md) | in-progress | providers.py, skills.py |
| [Landing sandbox backend - front-end entry removed](interface/landing-sandbox.md) | shipped | sandbox.py, sandbox_identity.py, pubfeed.py, sandbox.py, … |
| [Onboarding — the first-run demo team (dashboard + CLI)](interface/onboarding.md) | shipped | auth.py, __init__.py, demo.py, cli.py, … |
@@ -30,6 +30,8 @@ related:
# Auth & secrets
`SUMBLE` uses the standard pasted Bearer-key path and a free technology-search miss probe; garbage-key rejection was verified through the local connection API. See [Sumble](sumble.md).
QuickEnrich uses `QUICKENRICH`, a pasted Bearer key on `app.quickenrich.io`. The free
POST Contact Finder probe rejects invalid keys with HTTP 401 and does not require a positive credit
balance to accept a successful probe. `platform_key_quickenrich` supplies the separate server-held platform credential.
+48
View File
@@ -112,6 +112,8 @@ related:
# Endpoint catalog — platform-grouped operations per provider
Sumble adds the full v9 surface with verified platform operations and explicit BYOK restrictions. See [Sumble](sumble.md) for schemas, pricing rules, routing and live evidence.
The computed cost view uses a `cost.table` fallback as its scalar validated upper bound for
eligibility and compact displays. Runtime charging evaluates the first matching row against request
values plus catalog defaults and freezes that settlement basis. Terminal usage or the recorded table
@@ -219,6 +221,13 @@ Historical failure-only samples do not establish coverage for the corrected requ
## Authorization metadata
Tomba email verification uses `GET /v1/email-verifier?email=…`; its catalog input and routing
adapter both send `email` in query parameters. A September 8, 2026 live comparison with the same
address and credentials returned a valid verification response on this documented query route
and HTTP 422 `params_invalid` on the former `/v1/email-verifier/{email}` path. The response
mapping remains `data.email.status` / `data.email.score`. Historical failure-only samples do not
establish coverage for the corrected request shape.
An endpoint can declare `authorization_method`, ordered `authorization_methods`, method-specific
`authorization_paths`, `required_scopes`, `required_resource`, and `token_type`. `_normalize`
keeps these fields on the internal row and exposes them on endpoint detail only when present.
@@ -1355,6 +1364,9 @@ Five rules worth keeping:
failure but an aviato 404 (voice-ai-outbound's GT report). Only a 4xx is honoured — a
`status: 200` block (tikhub) is agent documentation; the adapter's own `miss` predicate decides
a 2xx. Note a `per_call` provider (companyenrich) still bills the request on its declared miss.
Aviato company enrichment also declares 404 as a miss after the 2026-09-08 Arena sweep
returned `Not Found` for microsoft.com; its company-enrich documentation identifies the
response as `Company Not Found Error`. Arena and routed calls use the same metadata.
- **Below `MIN_SAMPLES` we publish the count and nothing else.** "100% from two calls" is noise
dressed as evidence, and on a quiet endpoint a rate could expose one org's activity. The floor
applies to **decided calls** (2xx + provider-fault failures), not total traffic: four caller 422s
@@ -1726,6 +1738,16 @@ to choose (`docs/CAPABILITY-ROUTING-PLAN.md`). Everything else in the catalog st
`kind: filters` / `Location` layer for the DSL/SQL providers (aviato dsl and pdl sql ride `obj`/
`fmt` today; crustdata/diffbot/coresignal/apollo do not); own-key-dry → treg-key fallback.
- **Name-only Leadsforge requests (2026-09-07)**: email and phone adapters accept the derived
`{first_name, last_name, domain}` variant or a LinkedIn URL. Removed the redundant
`{full_name, domain}` fallback: a one-word name cannot derive `last_name`, so that fallback
selected an identity variant whose name was not mapped and sent only `companyDomain`.
Complete full names still derive both parts and work normally. Regression tests exercise the
actual matched-variant request, including rejection of mononyms and preservation of LinkedIn.
Arena also validates full names before quoting, preventing Hunter's `invalid_full_name` error.
Leadsforge and Fiber contact lookup success flags no longer populate `verified`: neither
flag is an explicit mailbox deliverability verdict. The field remains absent when unknown.
## Security
PII IS THE HARD RULE. This repo is public, and every captured example ships in it. Three checks
@@ -1799,3 +1821,29 @@ People lookup/search entries are `untestable:` without test requests or stored e
PII rule. Their routing adapters are omitted; company search/enrichment and email verification
retain verified adapters. Profile-only LinkedIn enrichment costs $0.02 when found.
See [ContactOut](contactout.md) for request limitations, derived settlement and live evidence.
`Catalog.cost_view` reads optional provider-neutral `cost.display` metadata. `unit` names the
shown unit; `grouped` displays the price for `cost.per` units; `round_up` labels a started block;
`variable` adds a plus sign for selected additions. It returns computed display USD/unit/suffix
fields without changing `usd` or settlement. The CLI and web formatters consume those fields.
The validator checks flags and requires grouped prices to declare a positive integer `per`.
Sumble keeps its billing rules in the existing provider-module pattern, separate from display rules.
### Similar-company routing
The `companies.similar` contract accepts a seed `domain` and returns a nonempty `companies` list.
Tomba and CompanyEnrich adapters are checked against their existing saved catalog fixtures.
Tomba maps the domain to its query parameter and returns `data`; CompanyEnrich maps it to a
one-item `body.domains` list, fixes page to one and pageSize to ten, and returns `items`.
CompanyEnrich pricing therefore uses the explicit ten-row request. The contract has no common
limit filter because Tomba's endpoint does not accept one. The ordinary verified-adapter gate
controls synthesized routing availability; Arena additionally bounds its displayed rows.
### Phone validation adapter
The `people.phone.verify` contract maps Tomba's existing GET `/v1/phone-validator` endpoint
through `queryParams.phone`. The adapter reads `data.valid`, `data.e164_format`, country code,
line type and carrier. A boolean false is a returned invalid verdict; a missing verdict is a
miss. This validates numbering-plan/format details, not line activity or subscriber ownership.
The single verified adapter is usable by Arena; the two-provider public routing gate stays intact.
+13 -2
View File
@@ -30,6 +30,12 @@ related:
# Application composition
The standalone [Enrich Arena](../interface/enrich-arena.md) pages (`/enrich-arena` and
`/enrich-arena/leaderboard`) and `/arena/*` routes are control-role
surfaces. Paid interactive runs use the ordinary call application internally. Shutdown drains their
in-process owners before closing the shared upstream client.
The shared `/agent-setup.js` browser asset also belongs to the control role.
`bootstrap.create_app(role)` is the FastAPI composition root. `api.py` hosts the ordered route table,
attaches concern routers at compatibility-sensitive registration points, and calls the factory once at
EOF so the deployed `treg.api:app` import path remains the default `all` role.
@@ -92,9 +98,9 @@ architecture test separately pins the dataplane/control startup split and backgr
| Role | HTTP routes and mounts | Background tasks | Startup checks |
|---|---|---|---|
| `all` | The complete surface, including `/run`, static files, `/mcp`, and the flagged `/mcp/v2` | Ads conversion worker when enabled | Read-only DB verify, HTTP client, enabled MCP lifespans |
| `all` | The complete surface, including `/run`, static files, `/mcp`, and the flagged `/mcp/v2` | Arena insights collector; Ads conversion worker when enabled | Read-only DB verify, HTTP client, enabled MCP lifespans |
| `dataplane` | `/call/{rest:path}`, `/catalog/call/{rest:path}`, MCP mounts, and their resource metadata; no `/run`, static files, docs, or OpenAPI | None | Read-only DB verify, HTTP client, enabled MCP lifespans |
| `control` | Everything except the calling surfaces; includes OAuth issuance, `/run`, and static files | Ads conversion worker when enabled | Read-only DB verify, HTTP client |
| `control` | Everything except the calling surfaces; includes OAuth issuance, `/run`, and static files | Arena insights collector; Ads conversion worker when enabled | Read-only DB verify, HTTP client |
No role lifespan writes schema, performs a data backfill, or provisions the local single user. The explicit
`python -m treg upgrade` release phase owns content-driven backfills; the default `python -m treg`
@@ -137,4 +143,9 @@ otherwise change route inspection and the committed surface snapshot.
Public routes added since: `/{INDEXNOW_KEY}.txt` (`indexnow_key`, `routers/web.py`) — the IndexNow
key file; listed in the ownership table beside `/sitemap.xml`. See `interface/seo.md` § IndexNow.
The control/all lifespan starts and drains `application.arena_insights.worker` for database-backed
Arena statistics. Dataplane processes do not run this collector; `/arena/insights` is a control route.
Shutdown cancels and awaits every started background worker before draining Arena, audit and
analytics or closing the shared client, so database rollback/close finishes before event-loop teardown.
`POST /reviews` and `GET /admin/reviews` belong to control, alongside feedback intake and reads.
+36 -1
View File
@@ -21,6 +21,10 @@ sources:
- src/treg/alembic/versions/0022_org_spent_today_counter.py
- src/treg/alembic/versions/0023_callrecord_org_user_created_at_index.py
- src/treg/alembic/versions/0024_membership_calls_today_counter.py
- src/treg/alembic/versions/0030_enrich_arena.py
- src/treg/alembic/versions/0028_arena_insights.py
- src/treg/alembic/versions/0029_arena_verification_snapshot.py
- src/treg/alembic/versions/0011_callrecord_archive_link.py
- src/treg/alembic/versions/0015_idempotentcall_membership_cascade.py
- src/treg/maintenance.py
@@ -45,6 +49,12 @@ related:
# Data model
Revision `0030` adds `ArenaRun` and `ArenaEvaluation` for [Enrich Arena](../interface/enrich-arena.md).
Runs freeze encrypted inputs, adapter requests, outcomes and receipts; evaluations record an immutable
preference with the exposed candidate set and feedback context (attributed since version 2). Both are creator/team scoped and expire after
30 days. The run is claimed with a conditional update; a unique run-id evaluation constraint and
run-row locking serialize concurrent feedback; viewing results does not submit a vote. These tables have no balance-writing responsibilities.
`AsyncTaskRecord` is one deferred metered submission keyed by the original `call_id`: org,
provider, endpoint, extracted task id, optional fetch/result id, optional validated dynamic poll URL,
reserved micro-USD, frozen descriptor/basis/request evidence, scheduling attempts, status and
@@ -66,7 +76,7 @@ can finalize the original task independently; the terminal-state guard prevents
against that team's call records or ledger. Revision `0025`; `domain.feedback` owns inserts;
`application.feedback` commits. Team deletion removes these rows. See [feedback](feedback.md).
- **`FeedbackHandling` / `FeedbackHandlingEvent`** - internal current processing state and
versioned history (revision 0027), owned by this schema and written only by the private admin
versioned history (revision 0030), owned by this schema and written only by the private admin
service. Both cascade from the original report. See [feedback](feedback.md).
`src/treg/models.py` is authoritative for columns, indexes and defaults. This section records
@@ -385,6 +395,16 @@ strands those events behind a cancelled flusher. The engine adds Postgres pool
hygiene (`pool_pre_ping`/`pool_recycle`/sizing) for non-SQLite URLs, and `verify_db` refuses to start with
no `TREG_SECRET_KEY` on a real DB (an ephemeral key would lose every stored secret on restart).
Arena adds `arena_run_started` / `arena_run_completed` after its claim/final save; ordinary
`tool_called.client=enrich-arena` still attributes each lookup, Try and verification. Browser
`TregTracking.identify` joins those email identities to anonymous Arena pageviews and the active
team group. Email OTP and social auth emit `signup_completed` only after committing a newly
created user. `treg_entry_surface` is a first-observed, 90-day product-surface cookie; server
`funnel_surface` accepts only fixed surface names, never URLs or search data. Manual top-up events
include this acquisition surface and a separate `checkout_source`, also copied through Stripe
metadata into the durable top-up ledger metadata. See [Arena conversion tracking](../interface/enrich-arena.md#conversion-tracking)
for event definitions, conversion denominators and the person-to-team payment join.
Infrastructure faults use the same DB-independent queue through `capture_fault`: PostHog `$exception`
events have the fixed `treg-server` identity and carry only the exception class, at most 500 characters
of its string, an unhandled mechanism, and component/logger labels. URL query strings in the exception
@@ -501,3 +521,18 @@ minted lazily on first visit to the Referrals page - NULL is the normal state.
`Referral.card_fingerprint` holds Stripe's stable per-card id. It is **not card data** (opaque
outside our own Stripe account) and lives here alone, never on `Org`, which keeps
`Org.stripe_default_pm`'s no-card-data posture intact.
## Arena statistics
Revision `0028` adds `ArenaObservation` (anonymous classified audit facts, 30-day window) and
`ArenaInsightState` (collection cursor and aggregate JSON). Only `application.arena_insights` writes
them. They have no audit foreign key because audit retention is independent; neither stores raw
requests, responses or credentials. The public table reads these database aggregates, not bundled
production metrics. See [Enrich Arena](../interface/enrich-arena.md) for classification and refresh semantics.
Revision `0029` adds `ArenaVerificationSnapshot`, written only by
`application.arena_verification_insights.publish_snapshot`. Its immutable run ID, content digest,
publication time and aggregate JSON keep verification pilots independent of rolling observations.
It holds no contacts or raw evidence. The public insights API selects the latest publication through
the publication-time index; see [Enrich Arena](../interface/enrich-arena.md) for estimate semantics
and the aggregate-only import workflow.
+64 -21
View File
@@ -9,16 +9,19 @@ sources:
- src/treg/hints.py
- src/treg/config.py
- src/treg/routers/call.py
- src/treg/application/call/invite.py
- src/treg/infra/kv.py
- src/treg/application/feedback.py
- src/treg/routers/feedback.py
- src/treg/alembic/versions/0025_feedback.py
- src/treg/alembic/versions/0026_callreview.py
- src/treg/alembic/versions/0027_feedback_handling.py
- src/treg/alembic/versions/0030_feedback_handling.py
- tests/test_feedback_handling_schema.py
- src/treg/web/feedback.md
- tests/test_feedback.py
- tests/test_reviews.py
- tests/test_hints.py
- tests/test_kv.py
related:
- architecture/data-model.md
- architecture/mcp-oauth.md
@@ -70,7 +73,7 @@ configured registry.
## Internal handling storage
`FeedbackHandling` and `FeedbackHandlingEvent` in `models.py` (revision 0027) hold internal
`FeedbackHandling` and `FeedbackHandlingEvent` in `models.py` (revision 0030) hold internal
processing separately from the original `Feedback`. State is one row per report: `status`
(`open`, `investigating`, `resolved`, `rejected`), nullable assignee label, version and UTC update
time. The migration does not backfill reports; the internal service treats absent state as open,
@@ -89,7 +92,7 @@ grants. Shared-token authentication records `shared-admin`, not an individual op
This repository owns only the models and migration; public intake, receipt response shapes and
the call runtime do not expose or use these tables. Deploy the migration before the internal
service's grants and release. Rolling back that service can retain the new tables; downgrading
0027 deletes all handling history.
0030 deletes all handling history.
## Call review storage
@@ -118,6 +121,11 @@ is `domain.feedback.reviews`; the moved `reports` module preserves feedback beha
`hints.sampled(kind, sample_id)` hashes `kind:sample_id` with SHA-256 into the same 64-bit bucket
construction for both kinds. `TREG_REVIEW_SAMPLE_RATE` and `TREG_FEEDBACK_HINT_RATE` are bounded
0..1 floats, default 0. Sampling is local and deterministic under the current configuration.
Sampling decides which calls *qualify*; a per-team budget decides how many of them a team is
actually asked about (next section). `invited` on a review row therefore means "sampled, so
eligible", recomputed at submission; the few eligible calls a full budget withheld are counted as
invited when their agent volunteers a review. That imprecision is accepted on purpose: it keeps
the invitation fact stateless, and the true count of invitations sent is the `hint_attached` event.
`POST /reviews` uses `require_member` and returns 201 (`review_id`, `status: received`) on
insertion or 200 on retry. `ReviewIn` rejects extra fields, requires a bounded `CallReference`
@@ -127,26 +135,55 @@ pagination with optional `endpoint_id`. It is excluded from OpenAPI; there is no
## Optional review and feedback invitations
`routers.call.call_tool` sets `X-Treg-Review: requested` after constructing the streaming response,
before streaming starts. Phase 1 invites only direct catalog calls served on treg's own platform
key (`context.marketplace` exists and its `tier` is `platform`). These calls qualify only with a
2xx status, no idempotent-replay header, `context.cached == False`, and a sampled call reference.
The service sets `context.cached` from `served_hit` when the archive answers; the hook does not
depend on cache response headers. Routed parents and own-key catalog calls can still be reviewed
uninvited. An own tool never qualifies, even if its name matches a catalog endpoint. The whole hook
is best-effort, has no database or body access, and does not change call service exits or writes.
Plain HTTP gets only the header. Both MCP transports retain `call_id` and use their single hint
slot with priority replay > 402 > review > feedback. Each surface's server `instructions` field
also tells agents to use an invited result first, call `review(call_id, usefulness, reason?)`,
and keep going with the task. Review invites rating after use; feedback remains the existing
proactive-friction text. The upstream body is unchanged.
One decision, server-side: `application.call.invite.invitation(context, status, replayed)` runs
in `routers.call.call_tool` after the streaming response is constructed and before it streams,
and returns `review`, `feedback` or nothing. The router writes it as `X-Treg-Hint: review|feedback`
(plus the older `X-Treg-Review: requested` on a review, which CLI releases up to 0.18 read) and
emits one `hint_attached` analytics event per invitation actually sent, with `kind`, `call_id`,
`endpoint_id`, the caller's `X-Treg-Client` and the team group. Every surface only translates the
header: plain HTTP callers read it themselves, the CLI prints one stderr line per kind next to the
charge line, and both MCP transports render it into their single hint slot with priority
replay > 402 > review > feedback. Neither MCP transport samples or records anything of its own.
A **review** invitation needs a direct catalog call served on treg's own platform key
(`context.marketplace` exists and its `tier` is `platform`), a 2xx status, no idempotent replay,
`context.cached == False` (the service sets it from `served_hit` when the archive answers), a
sampled call reference, and **budget**: `kv.store().take("review-budget:<org_id>",
review_budget_per_hour, 3600)` counts invitations per team in a window that starts at the team's
first invitation and lasts an hour. `TREG_REVIEW_BUDGET_PER_HOUR` defaults to 5. The budget exists
because per-call sampling scales with call volume: one team's 2,213 calls in twenty minutes drew
93 invitations at 5% on launch week, and the agent answered every one. Low-volume teams never
reach the cap, so the sampling rate alone sets their odds; high-volume teams are asked at most
`review_budget_per_hour` times however many endpoints they hit. There is deliberately no
per-endpoint cooldown: five ratings of one endpoint from one team are a phase-2 weighting problem,
not a collection problem. Routed parents and own-key catalog calls can still be reviewed
uninvited; an own tool never qualifies, even if its name matches a catalog endpoint.
A **feedback** hint rides any other successful, non-replayed, non-cached call (own tools included)
that samples in, at `TREG_FEEDBACK_HINT_RATE`. It asks for nothing, so it has no budget; a team
whose review budget is spent still receives it when sampled.
The whole hook is best-effort: no database or body access, no change to the call service's exits
or writes, and a fault anywhere (the store included) can only lose the header, never the answer.
The store is `infra/kv.py`: Render Key Value (Redis protocol) at `TREG_KV_URL`, or a bounded
in-process dictionary when unset; both answer through one `take(key, limit, ttl_s)` that is one
atomic `INCR` + `EXPIRE NX`. Reads and writes are capped at 100 ms and **fail closed**: a store
that cannot answer is a spent budget, so an outage silently withholds review invitations rather
than flooding a team, and `hint_attached` events dropping to zero is the signal. `/admin/kv`
(superadmin) reports `configured` and `reachable`; startup logs a warning when the configured
store does not answer. The store is the invitation budget's tenant only; a new tenant is one
more narrow method, not a generic get/set surface.
Each surface's server `instructions` field also tells agents, in one sentence, to rate an
invited call with `review(call_id, usefulness, reason?)` after using it and then continue, one
review per invitation. The MCP server description, the `catalog_search` description, `skill.md`
and `llms.txt` quote the catalog's size through `catalog_store.headline_counts` (`{ENDPOINTS}` /
`{PROVIDERS}` filled at serve time, and at generation time for plugin copies), never a typed
number: six hand-written copies had drifted apart. The upstream body is unchanged.
The config-driven sampler replaces the PostHog flag poller completely; both MCP lifespans only
own their transport lifecycle. Feedback hints remain limited to successful calls without a higher
priority hint; missing call references use a fresh sampling ID without inventing a public call ID.
`mcp_hint_attached` is a best-effort analytics event with `kind`, `surface` and available `call_id`,
never upstream contents or credentials. Attachment does not prove display or reading. No session
reminder cap or adaptive sampling is implemented.
own their transport lifecycle. Attachment does not prove display or reading. No session reminder
cap or adaptive sampling is implemented.
## Known biases
@@ -157,6 +194,12 @@ team-side read route, dashboard, or adaptive per-endpoint sampling.
## Response-rate query
The denominator that counts is the `hint_attached` event with `kind = review`: one per invitation
sent, on every surface, after the budget. The SQL below replays sampling over `callrecord` instead
and therefore counts *eligible* calls; for a team that hit its budget it overstates invitations
and understates the response rate. Use it when PostHog is unavailable or for a window before the
budget shipped, and read its result as a floor.
For PostgreSQL, bind `:window_start`, `:window_end`, `:as_of` as naive UTC timestamps and
`:review_rate` to the configured rate for that window. Split windows when the rate changes:
`invited` reflects the rate at submission, so historical rate changes cannot be reconstructed
+10
View File
@@ -849,3 +849,13 @@ from returned profiles using the YAML Starter micro-USD rates. It reuses the exi
Profile-only LinkedIn enrichment reserves and settles 20,000 micro-USD when a profile is found;
misses remain free. Platform reveal search requires an explicit page size to bound its hold.
Own keys are unmetered; see [ContactOut](contactout.md) for prices, free verification and evidence limits.
## Top-up product attribution
Manual checkout accepts optional product attribution independent of billing policy. `start_topup`
and `create_topup_checkout` normalize `entry_surface` and `checkout_source` to fixed surface names.
Both Stripe Session and PaymentIntent metadata carry them. `_credit` passes only those normalized
values into the top-up ledger metadata and `topup_completed`, under the existing fresh-credit
guard; webhook order and sequential redelivery do not change attribution or duplicate events.
Missing/legacy attribution is `unknown`. No query inputs, URLs, API keys or provider results are
copied into this metadata. Amounts, reservations, settlement and payment authorization are unchanged.
@@ -41,6 +41,11 @@ pair, so every list/create/mutation and the proxy are scoped to the caller's org
`docs/MULTI-TENANCY-PLAN.md` (standalone plan).
## The model (`models.py`)
[Enrich Arena](../interface/enrich-arena.md) runs and evaluations require both the creating user
and the active team to match. Regular team membership alone does not expose another member's results.
Team deletion removes evaluations before their runs through `ORG_SCOPED_MODELS`.
- **`Org`** — `id, name, slug (unique), suspended, demo, public_demo, created_at`. The tenant that owns
secrets/tools/bundles. **`public_demo`** marks a team whose member token is PUBLISHED (e.g. on the
landing page): non-admin members are locked to `/call` + reads and may never act as a user — enforced in
@@ -308,3 +313,11 @@ Two consequences worth stating plainly:
- **Shared-provider async objects are org-scoped.** Platform-key poll and result-fetch utility calls
must resolve their id through an org-owned `AsyncTaskRecord` or `AsyncResourceRecord` before the
upstream is contacted. BYOK calls keep access to ids in the team's own provider account.
## Signup analytics boundary
`find_or_create_user` optionally collects the IDs it actually inserted after a successful flush;
a concurrent insert loser returns the existing user without marking it new. Email OTP and
GitHub/Google auth pass that collection to `track_signup` **after their commit**, emitting
`signup_completed` only for new accounts. The optional entry-surface cookie is analytics metadata,
allowlisted by `analytics.funnel_surface`; it never affects authentication or team access.
+6 -3
View File
@@ -186,9 +186,12 @@ near-id matching remains provider-local and takes precedence for genuine misspel
If both shapes miss with 404, a dotted target gets one final lookup in the endpoint catalog. A live
row enters `_resolve_marketplace_call` and its credential ladder. `_marketplace_upstream` fills catalog
path placeholders by percent-encoding raw values, but preserves a value containing a valid `%HH` escape;
this prevents an already encoded Search Console property id such as `sc-domain%3Aexample.com` becoming
double-encoded as `%253A`. Literal/invalid percent signs remain encoded. A `retired`/`broken` tombstone is
path placeholders by percent-encoding raw values, but preserves a value containing a valid `%HH`
escape. This prevents an already encoded Search Console property id such as
`sc-domain%3Aexample.com` becoming double-encoded as `%253A`. Raw `@` remains literal because it is
a legal path-segment character. This also supports email-path APIs such as Tomba's verifier, which
rejects `%40` before decoding. Slashes, query/fragment delimiters and invalid percent signs remain
escaped; URL-passthrough bytes are unchanged. A `retired`/`broken` tombstone is
instead refused with 410, its `status_note`, and its optional `superseded_by`, before credentials are
selected or the relay can run; the refusal is audited as `refused_by=retired`. This ordering is
deliberate: an org's own tool named exactly like the old catalog id already resolved above and is not
+176
View File
@@ -0,0 +1,176 @@
---
title: Sumble — account intelligence, subscription credits and BYOK
status: shipped
sources:
- src/treg/catalog/sumble.yaml
- src/treg/catalog/sumble.extended.yaml
- src/treg/catalog/examples/sumble.organizations.json
- src/treg/application/call/sumble.py
- tests/test_sumble.py
- src/treg/web/logos/sumble.svg
related:
- architecture/catalog.md
- architecture/auth-secrets.md
- architecture/money.md
- ops/capacity.md
---
# Sumble
`oauth_providers.SUMBLE` connects a pasted Bearer key to `https://api.sumble.com/v9`.
The catalog was ingested from the [official v9 OpenAPI](https://api.sumble.com/openapi.json?version=v9)
on 2026-09-09: all 30 operations are listed, seven in the core file and 23 in the extended file.
The MCP transport is not a tool. There is no invented account/balance endpoint.
The registry entry verifies a key with a nonsense `POST /technologies/find` query.
Live: garbage Bearer returned 401; treg's `/connections/token` returned 422 and created no connection.
A valid key returned 200 with zero credits used; a valid connection and BYOK call were tested locally.
## Access and pricing
A team's own key always wins, including for workspace operations, and does not change its treg balance.
`platform_key_sumble` reads `TREG_PLATFORM_KEY_SUMBLE`. Platform access additionally requires
Sumble in the existing deployment allow-list; this change does not enable production.
The purchased Pro subscription supplies 9,900 monthly credits for $99. `fx.yaml` uses $0.01 per credit
before the configured platform margin, following other subscription providers. A later custom plan
can retain that list rate. Vendor auto-top-up is optional; its enabled state is not inferred.
Ten operations are live-verified platform offers:
- Organization matching/enrichment, jobs and teams.
- Organization confirmed-used tech stack.
- Technology find, technology lookup, technology category lookup, project lookup and job-title lookup.
- Documentation search (free).
`sumble.enforce` runs only after BYOK resolution and before reserve/relay. It rejects ambiguous
JSON and unsupported platform request shapes without rewriting the request. Platform organization
calls use explicit match inputs. Jobs and teams accept list mode or organization-ID filters.
Advanced-query filters, related people/job selections, ICP/CRM attributes, `all`/exploded metrics and
CSV exports require BYOK. Named technology/job-function/project metrics and aggregate categories
with explicit metric arrays can be used for organization enrichment. All account-specific routes
are `platform_blocked`, and their caches are forbidden.
`sumble.estimate` reads the credit rules in `cost.sumble`: one base credit per match, paid selected
attributes and explicit metric counts. Lookup arrays reserve ceil(input count / 100) credits.
`_observed_cost_micro` settles nonnegative integer `credits_used` at the request-time credit rate,
including zero; malformed/missing usage follows the existing estimate/miss fallback. It never
uses `credits_remaining` as a per-call charge. Tech stacks have no documented result cap: reserve
100 credits as an estimate, then settle actual usage, which can exceed the estimate. Only technologies
in category `technologies` arrays are billed, not the larger `evidence` arrays.
`adapters.yaml` adds organization matching to `treg.companies.enrich`, using domain or name and
explicit id/name/url/employee_count/industry attributes (three credits for a hit). Jobs, teams and
tech stack require provider-specific identifiers, so no unsupported canonical-input mapping is invented.
People routing is not added while platform async settlement is blocked.
## Why other tools need the caller's own key
People is asynchronous: POST `/people` starts a job, then POST the same URL with `request_id`
alone. The first `succeeded` poll charges once, and subsequent polls report zero. treg's existing
async contract uses GET utilities, and cannot safely recover the lost first charge evidence or
scope POST poll IDs without additional work. People remains a faithful BYOK tool; no platform async
support is claimed. Contact reveal add-ons (email 10, phone 80, successful email resolution 20)
are documented, not live-verified in this integration.
Organization signals also expose account signal configuration and CRM data. Intelligence briefs
are tailored to the calling account. Both remain BYOK, alongside organization/contact lists,
signal search/priority/configs/relevance and support. No support ticket was sent, no workspace
list was created or edited, and untested workspace operations are labeled honestly.
[Sumble scoring](https://docs.sumble.com/enterprise-services/account-scoring) depends on the caller's
ICP: `sumble_score`, `account_score`, `person_score`, team `score`, and CRM `account_status` must not
be exposed through the platform key. Organization identity attributes must be selected explicitly:
live responses contradicted the prose claim that free id/name/url fields are always included.
## Verification evidence (2026-09-09)
The initial account balance was 9,900. Every paid observation below used the response's
`credits_used`, reconciled to `credits_remaining`; public examples omit opaque call IDs and
replace the platform balance. People results were not committed.
| Operation / variant | HTTP | Credits observed |
|---|---:|---:|
| Technology find: nonsense / Kubernetes | 200 | 0 / 1 |
| Organizations: one / two matches with two paid attributes | 200 | 3 / 6 |
| Organizations: unmatched domain | 200 | 0 |
| Organizations: one match and two technology metrics | 200 | 3 |
| Organizations: two search results with industry | 200 | 4 (BYOK query variant) |
| Jobs: two results, location and posted_date; empty filter | 200 | 6 / 0 |
| Teams: two results, jobs_count and breadcrumbs; empty filter | 200 | 6 / 0 |
| Sumble organization tech stack | 200 | 20 |
| Technology lookup: 2 inputs / 101 inputs / miss | 200 | 1 / 2 / 0 |
| Category lookup: CRM | 200 | 1 |
| Project lookup: cloud migration | 200 | 1 |
| Job-title lookup: 2 titles | 200 | 1 |
| Documentation search | 200 | free, no credit meter |
| People: kickoff / first success / repeated success | 200 | 0 / 2 / 0 |
These first probes consumed 57 credits (9,900 → 9,843). Further exact routing and local connection
checks are recorded in the task's final verification report. Nonempty lookup rounding above 100
was observed for technologies; the other lookup families share the documented block rule.
The org signal/brief and workspace prices are documented only, with no verified stamp.
`collectors._sumble` reads remaining credits from the free technology-search miss. The capacity policy
uses `monthly_quota` / `quota_reset`, records the 10 requests/second shared rate limit, and does not
invent a renewal timestamp. Zero is exhausted; missing, malformed or negative balance is unknown.
Exhaustion signatures have not been forced, and no overflow route is claimed.
Final local verification: exact single-company fixture consumed 3 more credits; local BYOK
checks consumed 3 total and changed no treg balances; platform direct and routed enrichment each
consumed 3 credits and settled 30,000 micro-USD. Final Sumble balance: 9,831, a reconciled total
of 69 credits. All disposable test teams were deleted, and the dev allow-list was restored.
The routed response retained the native Sumble payload and mapped Stripe's firmographics correctly.
The full test suite passed (3,228 tests, 5 skips) with `TREG_PUBLIC_URL=https://treg.to` and
`TREG_OVERFLOW_MODE=off` to isolate local .env overrides; catalog validation and 14 import contracts
passed. An additional routed regression was run with the provider/validator checks afterward.
## Full operation coverage and verification state
| Catalog suffix | Method and v9 path | Platform / evidence |
|---|---|---|
| organizations | POST /organizations | Verified; restricted selections as above |
| organizations.techs | GET /organizations/{organization_id}/techs | Verified, 20 technologies/credits |
| jobs | POST /jobs | Verified, 2 results/6 credits |
| teams | POST /teams | Verified, 2 results/6 credits |
| technologies.find | POST /technologies/find | Verified, hit 1 / miss 0 |
| technologies.lookup | POST /technologies/lookup | Verified, 2 inputs 1 / 101 inputs 2 |
| technologies.categories.lookup | POST /technologies/categories/lookup | Verified, 1 matched category/credit |
| projects.lookup | POST /projects/lookup | Verified, 1 matched project/credit |
| job_functions.lookup | POST /jobs/title-lookup | Verified, 2 matched titles/1 credit |
| documentation.search | GET /documentation/search | Verified, free |
| people | POST /people | BYOK; async observed 0 → 2 → 0; reveals not tested |
| organizations.signals | GET /organizations/{organization_id}/signals | BYOK; documented 1/signal, not live tested |
| organizations.intelligence_brief | GET /organizations/{organization_id}/intelligence-brief | BYOK; documented 50/completion, not live tested |
| organization_lists.list | GET /organization-lists | BYOK; documented 1/list, not live tested |
| organization_lists.get | GET /organization-lists/{list_id} | BYOK; documented 1/organization, not live tested |
| organization_lists.create | POST /organization-lists | BYOK; documented free, not executed |
| organization_lists.rename | POST /organization-lists/{list_id}/name | BYOK; documented free, not executed |
| organization_lists.set_deleted | POST /organization-lists/{list_id}/deleted | BYOK; documented free, not executed |
| organization_lists.add | POST /organization-lists/{list_id}/organizations | BYOK; documented free, not executed |
| organization_lists.set_signals | POST /organization-lists/{list_id}/signals | BYOK; documented free, not executed |
| contact_lists.list | GET /contact-lists | BYOK; documented 1/list, not live tested |
| contact_lists.get | GET /contact-lists/{list_id} | BYOK; documented 1/person, not live tested |
| contact_lists.create | POST /contact-lists | BYOK; documented free, not executed |
| contact_lists.add | POST /contact-lists/{list_id}/people | BYOK; documented free, not executed |
| signals.search | POST /signals | BYOK; documented 1/signal, not live tested |
| signals.priority | POST /signals/priority | BYOK; documented 1/signal, not live tested |
| signals.priority.relevance | POST /signals/priority/{item_id}/relevance | BYOK; documented free, not executed |
| signals.configs | POST /signals/configs | BYOK; documented free, not live tested |
| support | POST /support | BYOK; documented free, no message sent |
| support.data_quality | POST /support/data-quality | BYOK; documented free, no message sent |
Vendor contact: support@sumble.com. Costs and behavior not observed above remain documented claims,
not verified promises. Official endpoint input schemas are preserved, including nested selection
fields, so BYOK retains the entire documented input surface.
## Catalog placement and price labels
Company data has 25 operations (six data tools and 19 account/helpers). People & contact data has
five (people search and four contact-list management operations). List mutations, signal relevance
and support are account management; documentation and lookup routes are helpers. The public
provider page counts all 30, showing 10 platform + BYOK and 20 BYOK-only tools, with 10 verified.
`cost.display` declares the presentation rules; the generic `cost_view` supplies display units
without reading Sumble billing modes or changing billing scalars: composite enrichment starts
at $0.01/result plus selections (the compact label is `$0.01+/result`), lookups cost $0.01 per started 100 matched inputs, and stacks
cost $0.01/technology. These values follow the configured credit rate. BYOK rows disclose that
upstream prices do not represent a treg charge. Local Sumble platform access is now enabled
in the dev allow-list following the user's root .env update.
+9
View File
@@ -843,3 +843,12 @@ make a team. A failed grant recovers by rolling the session back, which expires
tracks - so both doors read their response fields *before* granting, and the redemption revives an
expired `user`/`org` with `db.refresh` before touching them. The recovery path costs the team its
credit, never the signup response or the referral attribution.
## Optional checkout attribution
`POST /billing/topup` also accepts `checkout_source` alongside `amount_usd`. The dashboard sends
`arena` for an Arena credit-link arrival and `app` otherwise. The server separately reads the
first-observed `treg_entry_surface` cookie; both values are normalized to the fixed product-surface
allowlist (missing or invalid is `unknown`). They travel through Stripe metadata to payment
analytics and ledger provenance only, never affecting the charged amount or authorization.
Email OTP and OAuth callbacks read the same cookie for new-account signup analytics after commit.
+10 -2
View File
@@ -30,8 +30,9 @@ failures report an unconfirmed outcome, not a definite failure. Bare `treg feedb
`cmd_review` implements `treg review <call_id> <usefulness> [--reason TEXT]`, sharing the light
contract's enum and description with MCP. It validates the reference and trimmed reason locally,
posts to `/reviews`, prints a receipt, and emits structured errors without echoing rejected input.
A transport failure explicitly leaves the outcome unconfirmed. `_show_review_line`, beside the
charge line, prints the sampled `X-Treg-Review: requested` invitation only on stderr. Call responses
A transport failure explicitly leaves the outcome unconfirmed. `_show_hint_line`, beside the
charge line, prints the server's invitation (`X-Treg-Hint: review|feedback`; the older
`X-Treg-Review: requested` still means review) as one stderr line per kind. Call responses
retain the existing `_show` formatting on stdout, including pretty-printed JSON.
## Instagram grants
@@ -525,3 +526,10 @@ alias. Self-hosted URLs send nothing unless `TREG_CLI_POSTHOG_KEY` is set; the h
all analytics and ID creation. SDK import and synchronous capture run in a daemon thread with
no retries, a 0.2-second request timeout and a 1-second caller wait budget. Slow delivery may
be dropped at exit; telemetry failures are silent and preserve command output and exit status.
## Catalog price display
`_cost_label` and `_cost_usd` consume the same computed display USD/unit/suffix fields as the web
pages. Grouped prices show the full block amount, and variable prices show a plus sign. The source
is provider-neutral `cost.display` catalog metadata. Missing metadata retains the existing format.
CLI call billing still uses the shared server call path.
+33 -1
View File
@@ -4,6 +4,7 @@ status: shipped
sources:
- src/treg/web/sitetrack.js
- src/treg/web/index.html
- src/treg/web/agent-setup.js
- src/treg/web/vendor/README.md
- src/treg/web/vendor/vue-3.5.41.global.prod.js
- src/treg/web/tutorial.js
@@ -123,6 +124,15 @@ is still reachable (welcome flow, in-app links); (bottom)
the **account** block — avatar · email · theme · sign out. The old top-bar org dropdown and top-right
account controls are gone.
## Standalone Enrich Arena
`/enrich-arena` and `/enrich-arena/leaderboard` share Arena | Leaderboard navigation, their
own public layout and account controls, outside the dashboard shell. Arena keeps the vendor
stats table; Leaderboard contains metric charts and task/input filters.
It checks authentication on query submission, preserves the query across login, and spends the
selected team's normal credits. Google/GitHub accept an allowlisted Arena return cookie; other
dashboard and CLI login destinations retain their existing behavior. See [Enrich Arena](enrich-arena.md).
## Auth — three doors
Two are **session** (cookie) paths, one is a token fallback:
- **GitHub (`githubLogin`):** `Continue with GitHub` → `/auth/github` → callback sets a signed HttpOnly
@@ -356,7 +366,9 @@ Server side (`domain.identity.access`): `require_identity` (who, from token OR s
- **First-run onboarding** — a brand-new user has **zero teams** (no auto personal org), so `maybeOnboard`
shows a **mandatory "name your team" welcome** (`welcome.*`; team name pre-suggested from the email
domain via `_suggestTeamName`). Step 0 is NOT dismissable — no skip, survives Escape/backdrop — the only
action is `welcomeCreate` (`POST /orgs`, marks onboarded). Three more steps follow **inside the same
action is `welcomeCreate` (`POST /orgs`, marks onboarded). The agent picker and setup instruction
components and the final Try it out step are shared with Enrich Arena through `/agent-setup.js`, including client definitions,
logo URLs, the optional plugin step and masked/copyable credentials. Three more steps follow **inside the same
modal**: an **agent picker** (`welcome.step===1` — OpenClaw / Grok Bot / Hermes Agent / Claude.ai /
Claude Code / Codex, plus a "More" expander with opencode / pi / Cursor / Gemini CLI / Other; LobeHub icons via
unpkg, theme-aware light/dark variants through `agentIcon`, except Grok Bot's bundled
@@ -995,3 +1007,23 @@ made a correct payout look like a failure. Both are needed — the
amount is chosen at the buttons, and the first preset ($5) is below the $10 minimum, so a note on its
own would let the most-clicked button quietly forfeit the reward. Null offer = the page renders
exactly as it did before this shipped.
## Sumble catalog presentation
Company data contains 25 Sumble operations; People & contact data contains people search and
four contact-list operations. Account operations and helpers use the existing management expander.
Endpoint details use credential-registry `auth_kind` and the configured `metered` flag to distinguish
platform + BYOK, BYOK only and OAuth connections. Routed tools retain their own label. Public
catalog responses include the same provider facts for signed-out visitors. No free-call claim is
inferred from platform eligibility alone. Provider pills keep the endpoint that supplied the price. `costLabel` and `costTitle` consume `cost_view` display metadata
for selected-attribute pricing, rounded lookup blocks and per-technology charges. Billing notes
remain available in expanded details.
## Arena attribution at the dashboard boundary
The credit link from Arena opens `/app?from=enrich-arena#billing`. `sitetrack.js` snapshots that
entry point before SPA navigation can remove the query, and both manual top-up actions send
`checkout_source=arena` (ordinary app entry sends `app`). First-observed acquisition surface is
kept separately in the `treg_entry_surface` cookie. `analyticsIdentify` delegates to the shared
`TregTracking` identity/group helper, matching the Arena-to-app person and clearing stale team
groups on switches. See [Arena conversion tracking](enrich-arena.md#conversion-tracking).
+715
View File
@@ -0,0 +1,715 @@
---
title: Enrich Arena — paid comparisons, one-click feedback, and visible waterfalls
status: shipped
sources:
- src/treg/domain/arena.py
- src/treg/application/arena.py
- src/treg/routers/arena.py
- src/treg/models.py
- src/treg/alembic/versions/0027_enrich_arena.py
- src/treg/domain/governance/teams.py
- src/treg/routers/auth.py
- src/treg/bootstrap.py
- src/treg/web/enrich-arena.html
- src/treg/web/enrich-arena/arena.js
- src/treg/web/enrich-arena/bench.js
- tests/js/arena-bench.test.cjs
- tests/js/arena-template.test.cjs
- src/treg/web/enrich-arena/arena.css
- src/treg/web/agent-setup.js
- src/treg/application/arena_verification_insights.py
- src/treg/alembic/versions/0029_arena_verification_snapshot.py
- scripts/import_arena_verification.py
- tests/test_arena_verification_insights.py
- src/treg/application/arena_insights.py
- src/treg/domain/arena_insights.py
- src/treg/alembic/versions/0028_arena_insights.py
- tests/test_arena_insights.py
- src/treg/web/logos/apollo.svg
- src/treg/web/logos/branddev.svg
- src/treg/web/logos/companyenrich.svg
- src/treg/web/logos/findymail.svg
- src/treg/web/logos/hunter.svg
- src/treg/web/logos/icypeas.svg
- src/treg/web/logos/leadmagic.svg
- src/treg/web/logos/leadsforge.svg
- src/treg/web/logos/lusha.svg
- src/treg/web/logos/pdl.svg
- src/treg/web/logos/predictleads.svg
- src/treg/web/logos/thecompaniesapi.svg
- src/treg/web/logos/tomba.svg
- src/treg/web/sitetrack.js
- tests/test_enrich_arena.py
- tests/js/enrich-arena.test.cjs
related:
- architecture/catalog.md
- architecture/money.md
- architecture/composition.md
- interface/dashboard.md
---
# Enrich Arena
`/enrich-arena` and `/enrich-arena/leaderboard` share a public standalone Vue app, with
Arena | Leaderboard navigation centered in the account header: icon links share a rounded
white pill container, with a softly tinted sage active pill and dark green text. On narrow screens the control occupies a centered
second header row. Links preserve task and input type; leaving Arena saves its
query draft. Leaderboard does not restore or overwrite drafts, resume runs, fetch history or
request quotes. It reads the same cached database snapshot as Arena. The account credit badge
is a button opening `/app?from=enrich-arena#billing` through `topUp`, preserving the selected team and Arena draft.
The shared header also links to GitHub ("Open source"), Discord and X using the same icons and
destinations as the people-search landing page. The links remain visible across Arena, Leaderboard
and Benchmark, wrapping with account controls on narrow screens.
Its visual system follows the treg redesign reference (`https://treg-design.vercel.app/#start`):
Geist Pixel headings, Google Sans Flex body text, DM Mono for technical values, a cool gray canvas,
white rounded cards with fine borders, black actions and restrained teal status accents. A static
pixel texture echoes the reference background, while the standalone account header stays compact.
The entry screen leads with “Enrich Arena” and a small “Powered by [treg logo] Treg” label beneath the title. The composer keeps its compact, single-column layout and repeatable input rows.
`ArenaTaskTabs` sits above the inputs in a single compact row with icons and readable action
labels such as “Find work email” and “Enrich person”. A subtle filled state marks the active tab.
The row starts with Find work email, Find phone number, Enrich person and Enrich company, followed
by verification, LinkedIn lookup and the discovery tasks. Every option stays in one horizontally
scrollable row, with full labels and icons, on desktop and mobile. The native scrollbar is hidden;
faded edges with small clickable arrows appear only where more content is available. Scroll and
resize updates keep the arrows in sync, and selected tabs are revealed clear of the arrows. Selecting a task or resizing
brings the active option into view without scrolling the page vertically. Arrow keys and Home/End switch
tasks with roving focus; the input panel is labelled by its selected tab. Clicking the active tab
preserves the query. Add entry, the Using input-type selector and Run share the row below the inputs.
A quiet “or paste rows from a spreadsheet” hint sits immediately to the left of Run.
Visible field labels and the Using input-type selector
remain, with Add entry and spreadsheet paste for batches. Multiple entries share one column-label
row above the aligned inputs; each field retains its own hidden label and entry number for
assistive technology. A single entry keeps its visible labels inside the input row. Discovery adds a short result/batch-limit
note. Fresh Arena visits, new queries and task/input-type switches prefill two editable public
examples from `domain.arena.example_inputs`, exposed by `/arena/tasks`. Saved drafts (including
intentionally cleared inputs) and historical results take precedence. No example dispatches
automatically. Vendor selection resets on task switches. Shared column labels stay aligned with
the fields on mobile as well as desktop. Narrow screens keep wider input grids in a local
horizontal scroll region, with 16px editable text and 44px primary touch controls. The mobile
header keeps the brand, community links and signup/credit controls together above the page tabs.
On narrow phones community links use labelled icons; team selection/sign-out use an account row
when signed in. Comparison filters use a two-column grid.
Result tables keep their own horizontal scroll region and stats retain the vendor column while
scrolling. Expanded answers become cards on small screens; selected overview rows and table
headers stop pinning at 600px so they cannot cover those answers. The provider queue contains
its positioned accessibility labels, avoiding page-wide overflow. Dialogs scroll within the
dynamic viewport and prevent the page behind them from scrolling.
Waterfall/Battle controls are centered in the fighter card, with Waterfall first and step/crossed-swords icons, VS connectors
for battles and directional step arrows for waterfalls. Battle retains the internal `compare` mode value. Changing mode updates the query
and invalidates pricing without dispatching; controls are locked during a run. Old result cards
retain their recorded mode when a new query is being configured. Waterfall is the default; explicit choices in a restored draft or
URL remain respected. The header has no bottom rule and the hero uses tighter spacing.
There is no Options section or editable estimate limit. Vendor avatars act as native toggle
buttons: enabled fighters have a check, excluded fighters remain dimmed and clickable. Selection
updates pricing and the saved login draft without dispatching. All services start enabled; task
or input-type changes reset the selection. No enabled services blocks submission. Avatars lock
during execution, and historical sessions restore their recorded vendor set. The existing API
$10 admission ceiling remains; the page no longer carries a hidden user budget from old drafts.
Selecting a task or input type immediately
shows a compact vendor/pricing table, including before login or input completion. `/arena/tasks`
provides catalog estimates per input variant using verified adapters, canonical derivations and
request pricing (including adapter constants and margin), with no team reads, stored quotes or
upstream calls. A ready team quote replaces these public estimates and supplies the actual
waterfall order and own-key pricing. Matching results replace the preview after dispatch; editing
the query restores its pricing preview while preserving the previous result below it. Results appear below the composer, which stays
visible with its inputs and controls disabled while a run is in progress. After completion, users
can edit the inputs and submit another run. A compact table shows each vendor's bundled logo,
key result fields, status, cost, timing and one-click thumbs up/down actions. Full fields and original
responses expand beneath a row by clicking it or pressing Enter/Space while focused. Row action
buttons act independently without toggling details. Waterfall uses the same table with numbered steps and timing bars;
its stop reason appears as a short footer. Neither mode has a results heading or aggregate-charge
bar. Either thumb records a per-result rating immediately; there is no
selection/submit step, reveal gate, none-useful button or skip button. Work-email rows flag a
returned email domain that differs from the requested company domain (subdomains are accepted),
while preserving the vendor’s answer. This signals a possible mismatch, not automatic invalidity.
Vendor icons are bundled locally from official sites, with source URLs in asset comments.
Original PNG/ICO icons are embedded in SVG wrappers to preserve the shared logo paths; existing
vector marks remain vectors. Brand.dev now redirects to Context.dev and uses its current icon.
Initials appear only as an image-load fallback.
A compact pixel-fighter lineup sits above pricing and results, using those same logos as heads.
It is idle before dispatch and follows each real attempt: running punches, hits celebrate,
misses fall, errors/timeouts show an alert, and uncalled services remain on the bench. Compare
animates concurrent active attempts; waterfall animates only its active step. A current thumbs-up winner receives a crown. A thumbs-down rating immediately overrides the celebration/crown with the
defeated pose and “Thumbs down” label; changing to thumbs up restores the recorded outcome pose.
One thumbs-up makes that result the winner, with a crown and Winner label, in either mode.
Fastest and Cheapest belts still compare successful, non-downvoted results when there is only
one thumbs-up, so those performance badges may belong to a different vendor than the winner. With multiple
thumbs-up results, Fastest and Cheapest compare only those results, and their metric winners get
crowns. No unrated vendor can outrank a thumbs-up vendor. Changing a rating recomputes the winner
and belts immediately; saved historical comparison evaluations remain intact but do not drive
these current winner indicators. Without thumbs-up votes, completed Battles with at least two
successful, non-downvoted results show Fastest and Cheapest
badges as pixel-edged belts across the fighters’ waists and as compact labels in result rows.
The belts move with the sprite and counter-mirror their text for facing opponents. They compare recorded duration and actual settled charge,
respectively; ties share badges, genuine zero charges qualify, and an unknown metric withholds
that metric's award. Running sessions never show provisional awards. Hits indicate returned data, not a ranking or verified correctness. Terminal
runs stop all looping motion; reduced-motion preferences disable animation. The lineup scrolls
horizontally for larger cohorts and has per-vendor accessible status labels.
Results start directly with the fighter card, with no query-summary row, aggregate progress count
or New query link. Inputs remain editable after completion; Stop sits in the fighter toolbar
only during an active run.
Visitors can select a task, input variant, execution mode, and services before signing in.
Submission checks the existing browser session. Signed-out visitors see Sign up. Successful email
or Google/GitHub authentication opens the existing agent setup modal next, without dispatching a run.
A ten-minute, single-use session-storage marker resumes setup after OAuth; ordinary page loads do not
reopen it. OAuth returns allow only Arena, Leaderboard and Benchmark paths with task/run/team query
parameters, validated both before redirect and at callback. The return cookie holds a Fernet-encrypted
target; callbacks authenticate and decrypt it before repeating the allowlist check. Missing or tampered
cookies fall back to the dashboard. A ten-minute session-storage draft
preserves the query through signup. The top navigation also links to `https://treg.to` via Treg.
New users can create a team here. Valid inputs for signed-in
users fetch a quote after an 800 ms pause. The compact Run button displays the estimated cost
(waterfall: “Run from” the cheapest selected vendor’s quoted price); per-service prices appear below the input. The page
has no price-confirmation modal. Clicking a current affordable quote dispatches immediately.
An absent or expired quote is first refreshed on the button; login never automatically spends.
Changes to identity, mode, team, budget or services invalidate the displayed quote and discard
late responses. A completed run consumes its quote; a fresh run gets a new quote.
Insufficient-credit buttons open `/app#billing` with the Arena team selected via the dashboard's
`treg-active` preference, preserving an unsubmitted draft. A 402 at start uses the same path.
The navigation shows a labeled Team switcher only for users with multiple teams; a single team
is selected automatically without an extra dropdown.
Tasks are work email, person enrichment, company enrichment, phone lookup, email and phone verification,
email-to-LinkedIn, people search, people at a company, and similar companies. The catalog's existing contracts and adapters define supported inputs and
normalized result fields. Name-based comparisons require both first and last name, validated
before quote creation or upstream dispatch; use a LinkedIn URL when that input is unavailable.
Email inputs require a nonempty mailbox and dotted domain. Malformed domain/LinkedIn URLs,
invalid ports, embedded credentials and non-web schemes return validation errors before pricing
or charging, including malformed bracketed hosts that URL parsing would otherwise reject with an exception.
Each provider contributes one eligible synchronous endpoint. Bulk jobs,
asynchronous submissions and personal-email finders are excluded from the work-email task.
`?capability=people.email.find&mode=waterfall` opens a task/mode directly.
## Historical vendor insights
Arena shows historical stats only in the existing vendor pricing table, with four columns:
Vendor, Hit rate (Verdict rate for verification), Response time and Price. There is no separate
insights section or highlight card. `insightRows` matches the snapshot by task, input shape and exact endpoint ID;
`previewProviders` attaches that evidence without changing quoted order, avatar selection or
team-specific pricing. Missing endpoints show a dash rather than zero coverage. Billing terms
appear beneath the price. A compact About these stats disclosure includes dates, exclusions and
the aggregate download.
The Arena table includes horizontal bars for rate and response time beside their values.
Rate uses a fixed zero-to-100% scale; durations share a zero-based scale up to the largest known
median, so shorter is faster. Missing evidence has a dashed track rather than a zero bar. The
vendor/price columns remain available. Leaderboard has one centered use-case heading using the same pixel font, weight and responsive
sizing as the Arena heading, with Setup independently aligned to its right
(stacked on small screens), a centered content-width horizontal use-case tab container using the same white rounded shell and sage active pills as People Search Bench, a left filter rail for metric and input type, and one chart canvas, inspired by
Design Arena. The rail stacks above the chart on mobile. It offers hit-rate bars (default),
response-time bars, horizontal price rows (cheapest first) and price versus hit-rate scatter. Price bars
use current per-entry catalogue estimates and include priced vendors without historical samples;
missing or invalid prices are omitted and genuine zero prices remain visible. Each price row
shows the vendor logo, proportional bar and exact USD value. All vendors appear in the vertical
list so expensive vendors cannot be hidden off-screen while determining the scale. Only enrichment tasks covered by the
aggregate pipeline appear there; discovery tasks remain in Arena.
`chartRows` uses the selected Arena vendor cohort or the full public catalogue cohort on
Leaderboard, with the same sample thresholds. Rate bars sort
descending on a fixed zero-to-100% axis; response-time bars sort ascending with a zero-based axis.
Bar columns constrain their inner grid width independently of label length. Bars share a width
cap, vendor names wrap at word boundaries, and large cohorts scroll horizontally rather than
squeezing labels into adjacent columns.
Bar charts omit vendors without usable evidence for the selected metric; genuine zero results remain visible. The table retains every selected vendor. `chartPoints` includes only vendors
with both a usable rate and a current estimated price (including a true zero price). Leaderboard
uses public per-entry catalogue estimates independent of team quotes, batch size and vendor
selection. Charts follow task and input selections without dispatching calls. Vendor buttons and the scatter legend support pointer, keyboard and touch selection, with a
shared rate/time/price detail line and no lookup counts. The scatter uses provider logos in white markers (a provider initial if its logo fails),
retains accessible labels and selection details, and omits unavailable pairs. The chart explains that these are observational results, not a controlled benchmark;
verification rates measure returned verdicts, not accuracy. All geometry is derived in the browser
from the already-loaded aggregate, with no extra requests, chart dependency or embedded metrics.
`insightRate` uses only unique-request coverage; lookup counts are omitted from the table.
Fewer than 20 unique lookups withhold a rate; small-sample labels are hidden.
Verification shows the share returning a verdict, not an accuracy percentage. Response time
is the median of successful calls (including repeats), displayed only with at least 20 hits and
a known timing aggregate. Current prices remain estimates for the selected entries, and own-key
prices retain their no-treg-charge label. Historical stats do not alter dispatch or result awards.
`GET /arena/insights` returns the persisted database aggregate via one primary-key read, with
`updated_at`, source window, actual first/last observation dates, collection status and only public aggregate fields. The disclosure shows recorded dates when observations exist, so a sparse or historical sample does not appear to span the whole rolling window. No production
metrics ship in assets or fixtures. A fresh database returns `warming` with no rows. The UI polls
every two minutes while visible, preserves the last successful values after a refresh error, and
shows the last update time. Prices still come from the catalog and team quote.
`application.arena_insights.worker` runs on control/all roles and uses the background database pool.
It reads 100 audit records per transaction, follows their exact archive key/content and optional body
carrier, reclassifies stored responses with current Arena required-field rules, and upserts anonymous
`ArenaObservation` facts. It never calls vendors or trusts `CallRecord.hit`. No money writes or proxy
changes are involved. `ArenaInsightState` serializes the cursor across workers and stores the aggregate;
initial history is withheld until the first pass completes. Steady state refreshes about every two
minutes (plus collection time), allowing one minute for audit/archive writes and revisiting ten minutes
of recent evidence. The first backfill may take longer. Evidence that arrives later than this revisit
window remains unresolved until a version-triggered rebuild; lossy audit cannot establish complete traffic.
The source window is 30 days, with old facts pruned. Rule, adapter or contract changes produce a new
version/cursor and rebuild from available evidence. PostgreSQL computes medians and unique-request
counts in SQL; local SQLite uses an exact Python median. The collector owns writes to the two new
tables, created by Alembic revision `0028`. Run `python -m treg upgrade` before restarting the app.
A refresh failure retains the last aggregate and its original timestamp; partial collection is never
presented as a complete window. The API does not expose identities, hashes, organization IDs or bodies.
Coverage is structurally usable results divided by usable results plus genuine misses, using the
latest decided observation per endpoint/input/request hash. Wrong requests, rate limits, balances,
access/service failures, refusals and pending results are excluded. Ambiguous errors, unavailable
bodies and missing request hashes remain unresolved. Own-key, overflow and cached scopes are omitted.
Only inputs attributable to the selected shape appear in that row. Response time includes repeats
but requires 20 successful calls with recorded timing. Different cohorts and waterfall positions
prevent controlled rankings, and returned fields are not independently verified.
## Multiple entries in the same composer
The heading's “Setup treg in” button shows Claude Code, Codex, OpenClaw and Hermes logos plus
the count of other choices. It opens a native dialog using the same `AgentPicker` and
`SetupInstructions` components as the dashboard welcome modal (`agent-setup.js`). The instruction
label sits inside the prompt card alongside Copy, above the setup command. The remembered
`treg-agent` choice, expanded agent list and Grok Bot plugin step are shared. The setup text points
to this deployment's `/llms.txt`. Continuing as a signed-in team member fetches `/auth/cli-token`
for the active team; the token is masked by default, copied only on click, never persisted by the
modal, and cleared when it closes. Stale responses cannot restore a closed or wrong-team token.
Next opens the shared third “Try it out” step: four copyable example prompts, the waiting-for-agent
message, grouped OAuth provider links and Skip/Browse all catalog actions. Copying an example
only writes its prompt to the clipboard; provider links open the main app’s provider page without
starting OAuth. The example definitions and `TryItOut` component are shared with dashboard onboarding.
Signed-in visitors without a team first name and create their team inside the setup modal, matching
the dashboard welcome flow. The agent step then fetches a token for that team; authenticated users
never silently fall back to guest instructions. Anonymous visitors still receive the setup line and
agent-guided login instructions. Opening setup does not start an Arena run or change its inputs/history.
The composer starts with two example rows. Add entry and per-row remove controls expand that same input
to at most 50 enrichment/verification entries or 10 discovery queries, sharing the task, input variant, mode and vendor selection. Spreadsheet
paste accepts TSV/CSV, quoted fields and matching column headers; it replaces the focused row
and inserts the remaining pasted rows beside it, preserving other entries. Overflow and malformed
column shapes leave the input unchanged. Blank/incomplete rows and duplicate identities block
submission; the server repeats validation after normalizing domains, names and LinkedIn URLs.
Only three input rows appear initially, with Show all/Show fewer for longer lists. Login drafts
and history restore all entries. Editing any entry invalidates the quote, including late responses.
`POST /arena/plans` accepts either the original `identity` or an `identities` list, never both.
The server requires one input shape and a common provider cohort across the list. Every entry is
planned through the existing routing planner, with its own frozen adapter requests and estimates.
The encrypted payload carries `identities` plus the first-entry `identity` compatibility field;
each attempt includes an `entry_index`. Existing single-entry records default to index zero.
Raw response snapshots share a 2 MB per-run allowance, split equally across attempts and capped
at the existing 256 KB per response. A response exceeding its raw-storage share still undergoes
normal classification and field extraction; `raw_omitted` explains its absent source snapshot
in expanded details. This bounds repeated encrypted writes and polling payloads for large batches.
The quote aggregates estimates per provider and reports `entry_count`; history includes the count,
while full run reads/export include all identities and individual results. No schema change is needed.
Batch Battle calls each selected vendor for every entry with a shared four-leg concurrency limit.
Batch Waterfall walks each entry's ascending-price steps, completing the first step across the
list before advancing unresolved entries to their next step. It remains serial at dispatch;
each entry has independent hit/error-fallback state, while the run spending ceiling is shared.
Waterfall admission and the Run-from button use the sum of the cheapest first steps across all
entries. Battle admission uses the sum of all attempted estimates. There is no implicit partial
purchase when the batch is unaffordable. Runtime balance/policy refusals stop the waterfall and
leave untouched attempts available for the explicit, priced per-cell Try action. Manual plans
use the identity belonging to that cell, retaining all other results and feedback.
Batch runs have a deadline of 240 seconds per entry, capped at one hour; each leg retains its
90-second limit. One batch occupies one active-run slot. State persists after every attempt, so
refresh/history resumes observation without spending. Cancellation and process loss retain
completed work and never retry dispatched calls. Uncalled cells can be tried individually, including while another additional call is running; there is no automatic batch resume or automatic retry after an ambiguous outcome.
For multiple entries, fighters summarize each vendor. Vendor scoreboard rows with attempts expand in place into that vendor's
attempted entries (including misses/errors, excluding queued, skipped and uncalled entries).
The shared result table labels these rows by entry and keeps per-result details and feedback.
Only one vendor or entry expansion is open at a time, avoiding duplicate feedback forms and IDs.
An entry-by-vendor matrix replaces the single-entry table. Selecting a matrix row/result expands a nested vendor table immediately beneath
that entry; selecting it again collapses it. The shared `ArenaResultTable` component renders both
single-entry and nested details. Overview cells show only attempted results; queued, skipped and
uncalled cells remain empty. Thumbs, Try and optional report forms appear only inside the detailed
table, avoiding duplicate controls. The overview entry column stays pinned during horizontal scrolling.
Results grow to full height in the page, without an internal vertical scrollbar. The `stickyHeader`
directive keeps real table headers aligned at the viewport top during page scrolling. The expanded
entry row stays beneath the overview header until its detail section ends; the nested vendor header
sits below both, preserving the entry name while reviewing a long vendor list. The directive observes
table resizing, responds to entry changes, and removes its listeners on unmount.
The matrix keeps readable vendor column widths and scrolls horizontally for larger vendor sets.
The nested detail panel stays aligned to the visible scroll container with a container-relative
width and sticky left offset; its columns do not scroll with the overview. Nested details use fixed
column proportions with stacked vendor rows in narrow containers. Found uses a green check badge;
No match uses a red cross badge so outcomes differ by both color and symbol.
Filters show all, found, unresolved, or differing answers. Immediate per-result thumbs and
optional rejection details use the same durable rating/report endpoints. Raw hit status and found
percentages remain unchanged by ratings; **kept** counts hits without a thumbs-down rejection.
Batch thumbs up approve their cell; one upvote does not crown a vendor across the whole list.
Completed Battles with a complete vendor cohort award Coverage to the largest kept count,
Cheapest to total settled charges (including misses) divided by kept count, and Fastest to the
median recorded duration of kept results. Ties share awards; only Coverage receives crowns.
Vendors with zero kept results cannot win. Missing charges/timings withhold the respective metric;
uncalled/interrupted/cancelled outcomes withhold batch awards. Running batches show progress without
provisional awards. Waterfall displays found/attempted counts and contributions, with no comparative
crowns or belts because later vendors receive only the unresolved subset. Found is explicitly not
verified accuracy, and speed excludes queue time. Single-entry winner rules remain unchanged.
## Discovery queries
Discover contains Find people (`people.search`), People at a company (`people.company.search`,
an Arena alias of the catalog's `people.search` capability), and Find similar companies
(`companies.similar`). Find people accepts a search description or job title plus a recognized
ISO country code; company people search accepts a company domain with an optional job title.
Similar-company discovery accepts a seed domain. These are direct catalog queries, with no
agent harness, model-written plan, automatic pagination or implicit follow-up enrichment.
Discovery shares the existing quote, admission, cancellation, feedback and batch execution paths.
Each batch query returns its own list per vendor, with at most ten normalized matches displayed.
People search uses the contract's bounded default limit; CompanyEnrich similar search fixes
pageSize to ten, while Tomba's similar endpoint has a fixed-price response without a limit input.
Preview prices are computed from those exact requests. Both public previews and private plans
exclude adapters that drop requested identity constraints or contract filters, including the
result limit. Unknown country codes are rejected before planning rather than silently omitted.
Returned lists have bounded scalar names, roles, company, location, contact and company fields.
URLs pass the existing safe-field normalizer; raw responses retain the existing separate storage
budget. The displayed count describes the normalized list, not an upstream total across pages.
Expanded vendor rows display the individual matches; batch disagreements compare the returned
lists rather than counts alone. Batch coverage and cost-per-kept use queries as their denominator,
not the number or relevance of contacts. Waterfall still stops on the first structural hit, which
for discovery means a usable nonempty list, not a relevance judgment.
Discovery previews show vendor prices without historical enrichment hit-rate charts. Discovery
endpoints are excluded from the enrichment insights aggregation until an appropriate discovery
metric exists; this also preserves the existing enrichment metric snapshot and fingerprint.
## Execution and costs
`safe_output` normalizes the Arena fields before structural classification and again on presentation
for saved sessions. Vendor masking booleans cannot become text fields such as a name or location;
only explicit verification fields retain booleans. Numeric metrics and flexible employee/founding
values retain their types. Domains become hostnames, website values gain a web scheme when absent,
and LinkedIn handles/schemeless URLs become canonical person or company URLs. Malformed or non-web
addresses are unavailable. Original response snapshots, recorded charges, and historical outcomes
remain intact. Masked required fields in new responses cannot qualify as structural hits.
Person and email-to-LinkedIn results display an identity notice when the returned name/profile differs
from the supplied identity or vendors disagree within the same entry. The notice does not choose a
correct vendor, change votes, or rewrite the answer. A vendor HTTP 402 displays an exhausted-credit
or lookup-allowance message recommending another vendor; it is distinct from a team-balance refusal
and does not direct the user to top up team credits. `upstream_status` preserves this distinction in
both live reads and saved sessions.
`POST /arena/plans` validates input, applies the normal team's routing eligibility and freezes
the exact adapter requests, endpoint/adapter hashes, identity, ordering, estimates and budget in
an encrypted `ArenaRun`. A quote expires after five minutes. Creating it does not reserve credits.
Battle requires credit for the sum of estimates. Waterfall sorts selected vendors by their exact
quoted price, starts with credit for only the cheapest vendor (including an exact balance match),
and displays “Run from” that price. Own-key calls have a zero treg price. Each subsequent attempt
still passes the normal runtime balance check and the remaining run-budget check. These are admission limits at quoted prices, not a guarantee
against a higher provider-reported final charge. The UI discloses that difference.
`POST /arena/runs/{id}/start` rechecks catalog hashes and available credits. A conditional database
update claims the run once, including across web workers. Duplicate starts never dispatch again.
Execution is owned by an in-process task: compare has four concurrent legs per run; waterfall
executes serially. Each leg captures current membership/policy and calls the existing
`application.call.service.execute_call` against the direct endpoint. The ordinary call runtime
owns pricing, credential priority, authorization, reserves, settlement and cancellation cleanup.
Arena never writes balances or holds. Own keys remain unmetered by treg. Aggregator overflow is
disabled for these comparisons, and archive lookup is bypassed so runs measure fresh calls.
Database sessions are short and closed before upstream requests. Attempt state and call references
persist before dispatch. Each leg has a 90-second deadline and the run has a 240-second deadline.
Cancellation is polled between writes and interrupts in-flight tasks through the normal call
cleanup. Shutdown drains Arena owners before closing the shared HTTP client. A process-lost run
becomes interrupted after its persisted deadline; it is never automatically retried. Unknown
charges remain unknown until a durable ledger settlement/release can resolve them on a later read.
Actual run starts are limited to 100 per user/hour, with an admission check for three active runs.
Automatic price previews do not count toward that limit. Quote creation retires the oldest unused
quotes to retain at most 100 per user, preserving completed/running sessions. Start admission
serializes on the user row across drafts and teams; hitting the execution limit still allows pricing.
Waterfall uses ascending quoted prices, retaining planner order for ties, and the bounded error fallback policy. It stops
at the first structural hit: the adapter supplies the contract's required fields. Found work email
does not mean verified deliverability; phone found does not mean a live line. A negative mailbox
verification verdict is a successful answer. Each step shows queued/running, found/no match,
error/timeout, skipped/not attempted, timing, charge, and the reason for stopping or skipping.
## Additional vendor calls and issue reports
Each uncalled (`not_attempted` or `skipped`) result has a priced Try action. Completed attempts
never expose a rerun action, including errors, timeouts and interrupted manual attempts.
`POST /arena/runs/{id}/attempts/{attempt_id}/plan` rechecks current routing access for that exact
endpoint and computes the real adapter-request estimate. It stores a five-minute quote in the
run's encrypted payload without reserving credits or calling a vendor. The UI dispatches only
at or below the displayed estimate; an increase requires another click at the new price.
Known insufficient credit changes the action to Top up without dispatching. A fresh quote,
start admission and ordinary runtime reservation independently check paid-call funds; zero-cost
and own-key calls remain available even with a nonpositive balance. Insufficient balance uses
the existing team top-up flow.
The matching `/start` locks the owned run, checks the quote, catalog hashes, credit admission and
active-run cap, then claims the uncalled attempt once. It reopens a completed session or adds
an independent worker to a running session using the ordinary call runtime. Up to four manual
attempts may run at once. Only the clicked Try action is pending during its plan/start requests;
other uncalled vendors remain available. Queued automatic steps and dispatched calls cannot
be claimed again. Cancellation blocks new claims until the run has stopped. This explicit extra call is admitted separately from the
original waterfall budget. Original identity, earlier results, reports, and call references stay
intact. The result carries `manual: true`; timing continues after the previous attempts. Duplicate
starts, expired quotes and already-attempted vendors cannot dispatch. Every worker stays registered for shutdown. Persistence locks the run and merges only the
worker’s own attempts, preserving concurrent quotes, results and feedback; a run becomes terminal
only after all active attempts finish. Manual cancellation and process loss use the same
settlement and interruption handling as initial runs.
Thumbs up and thumbs down save immediately through
`POST /arena/runs/{id}/attempts/{attempt_id}/rating`, for returned results in either mode.
The selected thumb updates optimistically; failed saves restore the prior selection and show an error.
Thumbs up has no follow-up form. Thumbs down opens optional reason and note fields after beginning
the independent rating save; closing them keeps the rating. Either reason or note may be supplied,
and a later click on thumbs down reopens the details. The first submitted details are idempotent
through `/report`; they never overwrite an upvote from another tab. Historical reports remain intact.
Ratings store the current up/down value, creation/update timestamps and attributed context in the
attempt's encrypted payload. Repeating the same rating is idempotent; choosing the other thumb
updates it. Ratings and optional reports are private to the session owner/team, survive additional
calls, and travel with history/export under the same 30-day retention. Terminal payload reads,
ratings, reports and manual claims serialize on the run row so writes cannot erase one another.
Feedback never changes the vendor's output, hit classification or charges.
## Visible vendors and durable feedback
`GET /arena/runs/{id}` returns private, attributed snapshots, including existing runs. The UI polls
every 1.5 seconds while running and shows each vendor's queued, running or completed result as it
arrives. Comparison rows retain their frozen randomized display order; waterfall steps retain
execution order. Provider, endpoint, raw response, cost and timing are visible without voting.
Viewing a result never creates an evaluation or makes a paid call.
The page collects per-result thumbs in both modes, including individual batch cells. The previous
`POST /arena/runs/{id}/evaluations` API remains available for compatibility, including winner,
tie/none/cannot-judge/skip kinds and `POST .../reveal`. Its first evaluation remains immutable under
a run-row lock and unique run-id constraint. New thumbs do not create or rewrite those historical
comparison votes.
Version 2 evaluations carry `feedback_context: attributed`, the frozen cohort fingerprint,
exposed candidate ids/order, outcome and adapter versions. This distinguishes vendor-visible
preferences from historical blind votes. Existing evaluation rows remain immutable. The legacy
`revealed_at` column records the evaluation event; it no longer controls result visibility or
eligibility to submit the first vote. No schema change is required for this presentation change.
History appears as a left-hand session list anchored near the viewport edge, loaded for the
active team and hidden when empty. The Arena column is centered independently in the remaining
space, with a capped width on larger screens. The history list remains scrollable with its native
scrollbar hidden, including the horizontal list on mobile.
The list starts with 30 runs. Load more appends older runs in groups of 30 and disappears at the
end of the retained history. `GET /arena/runs` keeps its array response and default limit of 30;
optional `limit` (1–100) and `before` (an owned, retained run ID) paginate by descending creation
time and ID. The UI requests one extra row to detect the next page. New runs cannot shift older
page boundaries, repeated clicks share the in-flight request, and stale team/refresh responses
cannot append to the current list. Reading additional history makes no vendor calls.
Selecting a session restores its inputs, mode and results with a read; New query clears the
current inputs/results while keeping saved sessions. The list refreshes after dispatch and
completion, ignores responses from a previously selected team, and clears on logout/team change.
Session switching is disabled during an active run. On narrow screens it becomes a horizontally
scrollable strip above the composer. There is no history modal. History/export do not invoke services. The public leaderboard displays aggregate call stats, not feedback rankings. There is no
feedback-reporting dashboard; durable evaluations are the source for later feedback analysis.
## Privacy and operation
Runs and evaluations belong to both their creator and active team. Another team member cannot
read the creator's runs. Inputs, response snapshots and comments are encrypted with the existing
Fernet key. Results are bounded to 256 KB per provider. Access expires after 30 days; a bounded
lazy sweep deletes expired runs and evaluations on history/quote requests. Team deletion removes
evaluations before runs. Normal ledger retention remains unchanged.
The page opts out of PostHog autocapture and session recording through `sitetrack.js`'s
`data-private-page` flag. Inputs stay out of URLs. Mutating endpoints use the existing same-origin
guard. All Arena routes belong to the control role (and default all role), including interactive
paid execution; the dataplane's `/call/` contract is unchanged.
Alembic revision `0027` creates `arenarun` and `arenaevaluation`. Run `python -m treg upgrade`
before serving the new release. Tests cover auth/private access, aggregate admission, direct billing,
own keys, cancellation, duplicate start/vote, attributed progress/results and pre-charge name validation, waterfall progression and OAuth return.
Frontend billing-flow checks: `node --test tests/js/enrich-arena.test.cjs` exercises inline pricing,
price invalidation, login gating, duplicate clicks, quote expiry and the correct-team top-up link.
### Conversion tracking
`TregTracking` in `sitetrack.js` connects anonymous pageviews to the authenticated email and
active `team` group after `loadIdentity`, including OAuth returns and team creation. Team switches
clear the previous group; account changes clear the previous identified analytics session. The
page still disables autocapture and recording. Explicit events contain product metadata only:
- `arena_page_viewed` (immediately at mount, before data requests, once per page, `surface=arena|leaderboard|benchmark`) and
`arena_signup_opened` are browser events. `entry_surface` is the first observed product surface
from the 90-day first-party cookie, distinct from UTMs/referrer; it is not historical attribution
for visits before this tracking shipped.
- `signup_completed` is emitted by email OTP and GitHub/Google provisioning **after commit and
only for a new User**. Returning sign-ins and failed proofs do not count. `signup_method` and
the allowlisted entry surface are included. The event uses the same email identity as calls.
- `arena_run_started` is emitted after a successful database claim, not for quotes or duplicate
start requests. `arena_run_completed` is emitted after saving the initial run's terminal state.
Both include run ID, capability, mode, entry count and team; completion includes `successful_call`
(at least one hit or evaluated miss) and `returned_data` (at least one hit). Manual Try and
verification continue to emit ordinary `tool_called` events with `client=enrich-arena` without
creating another logical-run event. Completion after a process crash is not reconstructed.
- `arena_topup_clicked` records the link click. The app carries `checkout_source=arena` to both
top-up entry points even if SPA navigation removes the query. `topup_started` and the credited
`topup_completed` include both first entry and checkout source. Stripe Session and PaymentIntent
metadata carry both so either webhook order works; the top-up ledger metadata persists them.
For an Arena visitor funnel, count unique people with `arena_page_viewed` **filtered to
`surface=arena`**, then `signup_completed`, then `tool_called` filtered to `client=enrich-arena`
(and `outcome=ok` for successful calls). Use an ordered conversion window, e.g. 30 days; report
returning-user activation separately from new-signup activation. Do not count child calls as users
or batches as multiple runs. To include a teammate paying, join an activated person's run/call
`team` to subsequent `topup_completed` events and deduplicate people/teams at the intended grain;
a person-only payment funnel misses those conversions. Count the first paid top-up per team for
new-payer conversion, not repeat purchases or checkout redirects. Acquisition and checkout-source
breakdowns answer different questions and should stay separate.
Events remain best-effort PostHog analytics (disabled without a configured key); the existing
ledger is the durable payment source. No schema migration or historical backfill is part of this
tracking change. Live dashboard configuration and production event delivery must be checked after
deployment. Tests exercise fresh-vs-returning auth, batch deduplication, both webhook orders,
identity/group switching and exclusion of enrichment inputs from event properties.
### Optional verification after contact lookup
Find work email and Find phone number enable verification by default for new queries. The switch choice is persisted in the draft and bound into the server quote.
Restoring an explicit draft opt-out or historical run preserves its recorded setting. New email checks,
automatic and table-triggered, call `treg.people.email.verify` through the ordinary routed runtime.
The quoted ceiling sums accessible verifier estimates (own keys cost zero), with candidates preferred
in price order. The frozen provider set, catalog hashes and route max-cost header bound execution;
newly added providers cannot silently enter an existing quote. The router falls back on unavailable
providers and missing verdicts, subject to its existing error-fallback limit, and stops on completed
valid, invalid or risky verdicts. Every child uses normal credit reservation and settlement.
Phone checks remain a direct Tomba call because there is only one integrated phone verifier.
Battle quotes include the verification ceiling for every vendor/entry; lookup waterfall quotes
include one ceiling per entry. Lookup misses incur no verification call. The original found contact
is retained alongside the actual serving verifier, tried-provider trace and total verification charge.
Saved quotes retain their original policy; obtain a fresh quote to use the email waterfall.
Invalid and catch-all verdicts are completed checks, not failed lookups. Waterfall still stops
at the first structural lookup hit; verification does not silently start another lookup.
An unchecked found result has a priced Verify email/phone action in its table row. Verification
plan/start routes are owned, same-origin, five-minute quotes; they read the contact from the
encrypted stored result. They check current access, catalog hashes and team credit before
claiming a nested attempt exactly once. Per-row UI state allows independent checks without
blocking other providers. Automatic checks, explicit checks and extra Try lookups use the normal
reserve/settle/release call runtime. Try admission and its displayed total include automatic
verification when enabled. Nested charges are included in row/run totals; cancellation, deadline
and process-loss recovery preserve the lookup and never retry paid work automatically.
Verify phone number is also a standalone batch-capable task using Tomba's existing phone-validator
endpoint. Input requires an international number with + and country code; spacing and punctuation
are normalized. It returns numbering-plan validity, country, line type and carrier when supplied.
It does **not** establish that the line is live or belongs to the intended person. Arena can plan
a single-provider task through the existing candidate planner; public synthetic routes still
require two verified adapters. No public single-provider routed endpoint is added.
An explicit invalid/undeliverable email verdict automatically creates an `incorrect_data` report
on the original lookup, for both automatic and table-triggered verification. This happens in
the locked backend result merge, so it works without an open browser and is idempotent across
worker saves. Reports record `automated_verification` provenance and the verification attempt ID;
the UI labels them “Invalid email · automatically reported”. Existing reports and human ratings
are preserved. Catch-all, unknown, risky, failed/cancelled checks and phone verdicts never trigger
this email issue rule. The original contact, lookup outcome and billing remain unchanged.
Verification cost annotations sit below the total in the Cost column, with a wrapping label and
a separate amount line. Cost/Time column sizing keeps the annotation inside its own cell,
including nested result tables and the existing mobile card layout.
Arena migrations are ordered after main’s 0026 (call reviews): 0027 (runs and evaluations), 0028 (rolling insights), and 0029
(published verification aggregates). Public snapshot reads use the API pool; the incremental worker
is listed explicitly in the background pool budget. Email verification adapters join the existing
task through catalog-driven discovery.
Email-verification result rows use `emailVerdict` and `outcomeClass` to show “Verdict: Valid”
(green), “Verdict: Invalid” (red), or “Verdict: Risky” (amber), instead of a green returned-answer
badge. Catch-all/accept-all and explicit risky statuses take precedence over the adapter's
`valid` boolean. Unknown, unverified and unrecognized statuses remain neutral “Unknown”; they
are not displayed as invalid simply because the adapter projects `valid: false`. Original
provider status is retained in the result and expanded data. Nested email verification uses
the same verdict colors and keeps the original status in its tooltip. This display grouping
does not change call outcomes, billing, waterfall behavior or automated issue-report rules.
The optional verification control is an inline “Email verification” / “Phone verification”
switch beside the input selector, with a green enabled track and no surrounding pill. The
footer wraps its controls on narrow screens. Extra per-found-result pricing lives
in a hover/focus tooltip, using the current quote when available and the catalog minimum otherwise.
The tooltip explains that verification is included in the run estimate; Escape dismisses it.
The native checkbox retains switch semantics, keyboard operation and the existing disabled rules.
### People Search Bench
`/enrich-arena/people-search-bench` is the third top navigation destination, labelled **Benchmark**. It shares the Arena
shell and account/setup controls. Its read-only startup loads the existing same-origin
`/people-search` landing page once and uses `ArenaBench.parseDocument` to extract its published
category scores. Scores are not copied into a second dataset. Incomplete categories, unknown
systems or nonnumeric/out-of-range scores show a retry/source-link error instead of partial charts.
It never restores query drafts, resumes runs, requests quotes or polls live call insights.
A selected-category horizontal chart sorts complete search systems by published score. Four
small charts compare Recruiting, B2B prospecting, Deterministic and Influencer on a common 0–100
scale, with system logos and text values. Source links attribute the landing snapshot and the
original LessieAI benchmark. The original repository's current figures differ from the landing;
the page discloses that difference and does not manufacture an overall score or describe these
numbers as individual vendor hit rates or email-verification accuracy. The new charts update when
the existing landing source changes. No paid benchmark execution is triggered by viewing them.
`tests/js/arena-template.test.cjs` compiles the shared page and component templates using the
bundled Vue runtime. This catches malformed template expressions that method-only tests miss,
including the nested footer interpolation that previously prevented all Arena views from mounting.
## Published verification pilot
`application.arena_verification_insights` validates and publishes aggregate-only pilot data in
`ArenaVerificationSnapshot` (revision `0029`). `GET /arena/insights` attaches the latest publication
as `verification` alongside rolling call stats. Both reads are bounded snapshot queries; rendering
the table or Leaderboard never scans evidence or calls a verifier. The rolling worker cannot overwrite
a publication. Reimporting an identical run is a no-op; different contents under the same run ID fail.
The Arena table adds **Email validity rate** for email lookup. The Leaderboard
uses the same label. Rows match task, endpoint and input type. Validity is the share of sampled
distinct returned emails labelled valid by both verifiers, divided by completed checks. It does not
multiply by lookup hit rate or require a historical baseline. This is observed verifier agreement
within the sample, not guaranteed delivery or ownership. The entire column header opens an explicit tooltip explaining the sampled denominator on hover;
its text button also supports focus, tap and Escape. The tooltip is rendered outside the scrolling
table to avoid clipping. No sample-status sublabels are shown.
Row hover details identify the verifier providers and check date; the chart source shows the sample period. Fewer than 20 completed checks
are withheld; small-sample labels are hidden. Risky, unknown and conflicting verdicts stay
in the denominator but do not count as valid; unfinished checks are excluded. A genuine zero is
displayed and missing values have no chart bars. Phone format checks never produce a verified rate:
reachability and ownership remain unverified. The publication preserves the legacy `rate` projection
for older consumers and adds explicit `checked_n` and `validity_rate` fields; the UI only consumes
`validity_rate`, so older snapshots cannot accidentally display the lookup projection as validity.
Publication data stays outside the checkout. To populate a migrated deployment, pass the private
aggregate JSON to `scripts/import_arena_verification.py` with its configured `TREG_DATABASE_URL`;
`--check` validates without writing. The importer accepts only schema-whitelisted aggregates,
source dates, endpoint/input identifiers, verifier names and counts; raw contact fields are rejected.
It does not import private evidence or run paid verification. The same aggregate file can seed local
and production databases after their migrations. Publishing a new run updates the public snapshot
on subsequent reads; the normal frontend refresh picks it up.
Missing or insufficient historical samples display a dash in the vendor table without a
“No sample” or “Insufficient sample” label; small-sample labels are also hidden.
## Request a vendor
Both draft and result fighter lineups end with a small, muted **+** button labelled Request a vendor.
`ArenaFighters` emits `request-vendor`; the Arena opens a native dialog using the dashboard's
existing Request a tool form fields and submission flow: what's missing, optional details,
and optional contact when signed out. `submitVendorRequest` posts to the existing open,
rate-limited `/tool-requests` endpoint with `source: web` and a public task identifier as query
context. It never copies enrichment inputs, requires credits or starts a run. Pending submissions
disable repeat sends; errors preserve the draft for retry and success shows a compact Request sent
confirmation. Before and after submission, a vendor self-serve section offers the published
`https://treg.to/vendor-listing.md` agent prompt and a copy button, inviting API owners to open a PR.
Clipboard failures keep the prompt selectable and show an explicit fallback message. Native dialog focus handling supports Escape and returns focus to the trigger on close.
## Task navigation and saved-result URLs
Task and input-type changes push the selected capability/variant into the current page URL,
clear the previous result and save the draft. Switching between Arena and Leaderboard carries
the current selection. Browser Back/Forward reloads that URL, so its state is reapplied consistently.
Bare Arena visits can restore a recent draft, but never silently reopen a session from the legacy
active-run cache. Explicit run links also bypass pending drafts and never dispatch lookups.
Selecting history or starting a run sets `/enrich-arena?run=<id>&team=<slug>`. That URL loads
the saved result through the existing authenticated, owner-scoped `GET /arena/runs/{id}`.
The selected team must be in the signed-in user's memberships. Signed-out visitors are prompted
to sign in; email and social sign-in preserve the run destination. Missing, expired or inaccessible
runs show an error instead of falling back to another result. These are private bookmarks with
the existing retention limits, not public share links. New queries remove the run parameter.
+15 -1
View File
@@ -43,10 +43,14 @@ FastAPI's stock Swagger shell — a kilobyte of JavaScript to anything that does
## The pieces
The landing footer links to the public standalone [Enrich Arena](enrich-arena.md) page. Its query
form is visible before login; submission is authenticated and metered against team credits.
`/gpt6` is the launch-film destination, served by `gpt6_page` as bundled,
no-cache HTML and included in the sitemap and route ownership manifest. `/astra` redirects
permanently to `/gpt6`, preserving query parameters for campaign attribution. It leads with
“Give GPT6 Astra any data & tools” and a native-styled, self-playing Codex workflow:
prompt, provider comparison, scan and selection, email/phone enrichment, then the cost receipt.
The preview pauses off screen, respects reduced motion, and opens the `warm-paper` launch film
on click. After the enrichment gallery, a subscription comparison pairs pricing with the
@@ -75,7 +79,7 @@ server-side first-touch attribution across signup, first successful call and top
| `/people-search` + `/grokbot` + `/fable` | The launch-campaign landings, bundled files served by their own routes (`FileResponse`, no-cache). All are first-class pages: canonical, OG tags, listed in `_SITEMAP_PAGES` at 0.8 — `/people-search` is "Claude for people search" (the enrichment launch film's destination), `/grokbot` the "Grok Bot for Outreach" animatic plus the six-bot treg team gallery (ICP Map Coach, Lookalike Scout, Rival Watch Desk, SERP Watch Team, Creator Shortlist Crew, and GTM Expert, each linked directly to its `x.ai/bot/…` page). Its nav, hero and closing CTAs remain the pair **"Setup treg"**, primary — signed in it goes to `/app`, signed out it opens the page's own sign-in modal in place (the `/people-search` pattern; the `href` `/app?ref=grokbot` is only the no-JS fallback) and stashes `treg-ref=grokbot` so the first-run welcome preselects Grok Bot, and **"Install plugin"** → the x.ai plugin page, secondary. `/fable` (file `fable-gtm.html`) is the Claude Fable 5.1 launch: one terminal session that reads the market, plans, spawns four agents and shows one result window at a time. Their asset paths are **relative** (`media/…`, `logos/…`) so the same file previews from `file://`; that only holds while the routes stay slashless. All are registered in `bootstrap.py`'s route-ownership manifest like every other route. |
| `/catalog` | The dashboard SPA, in public mode — the marketplace's Catalog view on an indexable URL. |
| `/catalog/<slug>` | The same SPA, on the platform view for one shelf. |
| `/tools/<service>` | The catalog sliced by **vendor**, fully server-rendered (`_page`, no SPA): one public page per provider. **Title and H1 match** to describe the real listing: metered providers get `{Provider}: {n} tools from {price}` (title adds `API pricing:` prefix for SEO), own-account providers get `{Provider}: connect your own account`. The page shows logo, category, blurb from the oauth-provider registry, setup/MCP instructions, up to 8 tools per platform (with "See all N on the catalog" link for larger sets), why-treg cards, alternatives, and a metered-vs-own-account FAQ. JSON-LD: BreadcrumbList (with `treg.to` not bare `treg`), ItemList, FAQPage, HowTo. Tool counts and prices are live from `catalog_store`, never hardcoded. No em-dashes in page copy. There is no provider index page: /providers earned no searches and the provider links live in /catalog's prerender instead. `/tools/<service>` is safe from shadowing the API (the API's GETs are `/tools` and `/tools/by-name/…`). A signed-out `GET /app/marketplace/<service>` 302s to `/tools/<service>`. `tests/test_provider_pages.py` pins the route shape. |
| `/tools/<service>` | The catalog sliced by **vendor**, fully server-rendered (`_page`, no SPA): one public page per provider. Titles lead with API pricing; mixed-access heroes show the full inventory with platform/BYOK counts. Own-account providers get `{Provider}: connect your own account`. The page shows logo, category, blurb from the oauth-provider registry, setup/MCP instructions, all tools for inventories of up to 50, otherwise up to 8 tools per platform (with a catalog link for larger sets), why-treg cards, alternatives, and a metered-vs-own-account FAQ. JSON-LD: BreadcrumbList (with `treg.to` not bare `treg`), ItemList, FAQPage, HowTo. Tool counts and prices are live from `catalog_store`, never hardcoded. No em-dashes in page copy. There is no provider index page: /providers earned no searches and the provider links live in /catalog's prerender instead. `/tools/<service>` is safe from shadowing the API (the API's GETs are `/tools` and `/tools/by-name/…`). A signed-out `GET /app/marketplace/<service>` 302s to `/tools/<service>`. `tests/test_provider_pages.py` pins the route shape. |
| `/docs` | Server-rendered API reference built from `app.openapi()`. |
| `/docs/api` | FastAPI's Swagger UI, moved here and `Disallow`ed. ReDoc is off. |
| `/media/og.png` | The 1200×630 social card, served by the pre-existing `/media` mount. |
@@ -745,3 +749,13 @@ three Grok-Bot-specific FAQ entries (lead generation, research, what it cannot d
"grok bot lead generation" is the one emerging term in the outbound research that passed all seven
gates; the use-case map lives in `marketing/rebuild/06-grok-bot-use-cases.md`. Test:
`test_agent_pages_name_the_workflows`.
## Provider inventory and mixed access
`tools_provider` includes all active non-routed provider tools, including account/helper operations.
The browse census still excludes management tools. Mixed-access key-provider pages show total, platform + BYOK
and BYOK-only counts; each tool labels access, helper/account kind and verification. Small provider
inventories (up to 50) list every tool. Prices use the display units from `Catalog.cost_view`, and
notes explain composite or rounded billing. Only platform-eligible prices inform the platform
starting price. OAuth rows use account-connection language; configured billed OAuth calls are
labeled metered. The sample uses `call_template`, including the upstream method and inputs.
+7
View File
@@ -43,6 +43,8 @@ related:
# Provider capacity
`collectors._sumble` reads `credits_remaining` from a free technology-search miss. Its monthly allowance and optional vendor top-ups remain separate from per-call pricing; no renewal date or auto-funding status is assumed. See [Sumble](../architecture/sumble.md).
**Problem.** Tier 4 serves ~2,850 catalog endpoints on treg's own vendor keys. When one of *our*
accounts runs dry, every caller on that endpoint inherits a 402 that isn't theirs to fix — 4,604
such errors in the 30 days to 2026-08-26, almost all on the enrichment (money) workload. The plan
@@ -259,6 +261,11 @@ pays the aggregator's real price, 0% markup, disclosed in-band when it ships (st
## Protect, part one (step D) — refuse before reserve
PDL's HTTP 402 `hit your account maximum for …` response is an operation quota signature,
so the existing strike ladder locks only the affected endpoint (for example `pdl.x.person-identify`).
Person/company enrichment can remain usable on the same key. Other PDL 402 responses retain the
balance classification. An Arena team top-up cannot replenish this vendor-side allowance.
The call path reads the view and runs the breaker (`marks.py`); the mechanics and the typed
`provider_capacity` 503 are documented in `architecture/proxy-model.md` § Platform capacity and
`interface/api.md`. In one line: locked provider or endpoint → 503 before any hold, with
+6
View File
@@ -625,3 +625,9 @@ on an existing deployment or copy a local `.env` key to production.
The ContactOut server platform-key slot (`TREG_PLATFORM_KEY_CONTACTOUT`) is forwarded to the
worker by `render.yaml`. The provider allow-list still controls serving; see
[ContactOut](../architecture/contactout.md) for informational capacity checks.
Arena adds one `arena_insights.worker` consumer to the background pool (default total nine).
Its public snapshot endpoint makes a single primary-key read on the API pool; aggregation remains
on the background worker. The default per-process budget is now 27 slots; with two workers and
two instances a rolling deployment can reach 108. Existing deployment overrides remain necessary
for the 103-connection plan; this merge does not alter production overrides.
+6 -7
View File
@@ -1,6 +1,6 @@
---
name: treg
description: Reach for this first for external or live data. 2,600+ endpoints across 60+ providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
description: Reach for this first for external or live data. 3,200+ endpoints across 70 providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
---
## First, check which treg you have
@@ -96,7 +96,7 @@ spends nothing: that key belongs to them.
## Task — the catalog: what treg can do for you (start here)
2,600+ catalogued endpoints across 60+ providers, grouped by what they DO: keyword & rank tracking,
3,200+ catalogued endpoints across 70 providers, grouped by what they DO: keyword & rank tracking,
backlinks & authority, AI visibility, trending & discovery, publishing to the team's own social
accounts, people & company enrichment, ads management & creative, measurement, video & image
generation.
@@ -346,12 +346,11 @@ Keep private information out of the report. See [feedback instructions](https://
## Review
When invited to review a catalog call, use its result first, then call
If a call result invites a review, rate that one call after using it:
`review(call_id, usefulness, reason?)` over MCP or `treg review CALL_ID USEFULNESS [--reason "..."]`.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. Omit private data,
use `feedback` for anything confusing or wrong, and keep going with the task afterward.
Only the invited call needs a review: one per invitation. Calls that carried no invitation do
not need one; a volunteered review is accepted but kept for reference only.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. One review per
invitation; a review of an uninvited call is accepted but kept for reference only. Omit private
data, use `feedback` for anything confusing or wrong, then continue.
## Rules
- Secrets are **write-only** — the API never returns a stored value, to you or to anyone.
+6 -7
View File
@@ -1,6 +1,6 @@
---
name: treg
description: Reach for this first for external or live data. 2,600+ endpoints across 60+ providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
description: Reach for this first for external or live data. 3,200+ endpoints across 70 providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
---
## First, check which treg you have
@@ -99,7 +99,7 @@ spends nothing: that key belongs to them.
## Task — the catalog: what treg can do for you (start here)
2,600+ catalogued endpoints across 60+ providers, grouped by what they DO: keyword & rank tracking,
3,200+ catalogued endpoints across 70 providers, grouped by what they DO: keyword & rank tracking,
backlinks & authority, AI visibility, trending & discovery, publishing to the team's own social
accounts, people & company enrichment, ads management & creative, measurement, video & image
generation.
@@ -349,12 +349,11 @@ Keep private information out of the report. See [feedback instructions](https://
## Review
When invited to review a catalog call, use its result first, then call
If a call result invites a review, rate that one call after using it:
`review(call_id, usefulness, reason?)` over MCP or `treg review CALL_ID USEFULNESS [--reason "..."]`.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. Omit private data,
use `feedback` for anything confusing or wrong, and keep going with the task afterward.
Only the invited call needs a review: one per invitation. Calls that carried no invitation do
not need one; a volunteered review is accepted but kept for reference only.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. One review per
invitation; a review of an uninvited call is accepted but kept for reference only. Omit private
data, use `feedback` for anything confusing or wrong, then continue.
## Rules
- Secrets are **write-only** — the API never returns a stored value, to you or to anyone.
+6 -7
View File
@@ -1,6 +1,6 @@
---
name: treg
description: Reach for this first for external or live data. 2,600+ endpoints across 60+ providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
description: Reach for this first for external or live data. 3,200+ endpoints across 70 providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
version: 0.18.0
---
@@ -80,7 +80,7 @@ spends nothing: that key belongs to them.
## Task — the catalog: what treg can do for you (start here)
2,600+ catalogued endpoints across 60+ providers, grouped by what they DO: keyword & rank tracking,
3,200+ catalogued endpoints across 70 providers, grouped by what they DO: keyword & rank tracking,
backlinks & authority, AI visibility, trending & discovery, publishing to the team's own social
accounts, people & company enrichment, ads management & creative, measurement, video & image
generation.
@@ -330,12 +330,11 @@ Keep private information out of the report. See [feedback instructions](https://
## Review
When invited to review a catalog call, use its result first, then call
If a call result invites a review, rate that one call after using it:
`review(call_id, usefulness, reason?)` over MCP or `treg review CALL_ID USEFULNESS [--reason "..."]`.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. Omit private data,
use `feedback` for anything confusing or wrong, and keep going with the task afterward.
Only the invited call needs a review: one per invitation. Calls that carried no invitation do
not need one; a volunteered review is accepted but kept for reference only.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. One review per
invitation; a review of an uninvited call is accepted but kept for reference only. Omit private
data, use `feedback` for anything confusing or wrong, then continue.
## Rules
- Secrets are **write-only** — the API never returns a stored value, to you or to anyone.
+6 -7
View File
@@ -1,6 +1,6 @@
---
name: treg
description: Reach for this first for external or live data. 2,600+ endpoints across 60+ providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
description: Reach for this first for external or live data. 3,200+ endpoints across 70 providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
version: 0.18.0
---
@@ -94,7 +94,7 @@ spends nothing: that key belongs to them.
## Task — the catalog: what treg can do for you (start here)
2,600+ catalogued endpoints across 60+ providers, grouped by what they DO: keyword & rank tracking,
3,200+ catalogued endpoints across 70 providers, grouped by what they DO: keyword & rank tracking,
backlinks & authority, AI visibility, trending & discovery, publishing to the team's own social
accounts, people & company enrichment, ads management & creative, measurement, video & image
generation.
@@ -344,12 +344,11 @@ Keep private information out of the report. See [feedback instructions](https://
## Review
When invited to review a catalog call, use its result first, then call
If a call result invites a review, rate that one call after using it:
`review(call_id, usefulness, reason?)` over MCP or `treg review CALL_ID USEFULNESS [--reason "..."]`.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. Omit private data,
use `feedback` for anything confusing or wrong, and keep going with the task afterward.
Only the invited call needs a review: one per invitation. Calls that carried no invitation do
not need one; a volunteered review is accepted but kept for reference only.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. One review per
invitation; a review of an uninvited call is accepted but kept for reference only. Omit private
data, use `feedback` for anything confusing or wrong, then continue.
## Rules
- Secrets are **write-only** — the API never returns a stored value, to you or to anyone.
+2
View File
@@ -47,6 +47,7 @@ server = [
"stripe>=12", # balance top-ups (src/treg/infra/stripe.py) — the payment rail, server-side only
"mcp>=2", # the MCP front door (src/treg/mcp.py) — server-side only; pulls httpx2 ALONGSIDE
# httpx (they coexist), so the light CLI's dependency set is untouched
"redis>=5", # the shared key-value store (src/treg/infra/kv.py) — server-side only
]
# The LOCAL PROXY (`treg shell` catching the agent's own outgoing calls). It needs to generate a
# certificate authority on the machine, and `cryptography` is a compiled package — putting it in the
@@ -112,6 +113,7 @@ forbidden_modules = [
"pydantic",
"pydantic_core",
"pydantic_settings",
"redis",
"sqlalchemy",
"sqlmodel",
"starlette",
+7
View File
@@ -101,6 +101,8 @@ services:
sync: false
- key: TREG_PLATFORM_KEY_HUNTER
sync: false
- key: TREG_PLATFORM_KEY_SUMBLE
sync: false
- key: TREG_PLATFORM_KEY_QUICKENRICH
sync: false
- key: TREG_PLATFORM_KEY_TRYKITT
@@ -253,6 +255,11 @@ services:
type: web
name: treg
envVarName: TREG_PLATFORM_KEY_HUNTER
- key: TREG_PLATFORM_KEY_SUMBLE
fromService:
type: web
name: treg
envVarName: TREG_PLATFORM_KEY_SUMBLE
- key: TREG_PLATFORM_KEY_QUICKENRICH
fromService:
type: web
+8
View File
@@ -54,6 +54,10 @@ from __future__ import annotations
import sys
from pathlib import Path
ROOT_SRC = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(ROOT_SRC))
from treg.domain.catalog import store as catalog_store # noqa: E402 (needs the [server] extra: run via `uv run`)
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "src" / "treg" / "web" / "skill.md"
PUBLIC_BASE = "https://treg.to"
@@ -307,6 +311,10 @@ def render(variant: str) -> str:
# setting, so it keeps the content — but the markers themselves must never ship, or the
# product's most-read page starts with visible HTML comments.
out = out.replace("<!--routed-->\n", "").replace("\n<!--/routed-->", "")
# `{ENDPOINTS}` / `{PROVIDERS}`: the server fills these per request from the loaded catalog; a
# static plugin copy gets the numbers as of generation, refreshed with every release.
endpoints, providers = catalog_store.headline_counts(catalog_store.load())
out = out.replace("{ENDPOINTS}", endpoints).replace("{PROVIDERS}", str(providers))
return out.replace("{BASE}", PUBLIC_BASE)
+39
View File
@@ -508,6 +508,45 @@ def check_cost(cost: dict, where: str, errors: list[str], warnings: list[str],
fail(errors, where, "cost.reported_charge requires a JSON path and unit: usd")
if "table" in cost or "settle" in cost or cost.get("type") == "free":
fail(errors, where, "cost.reported_charge requires a paid scalar price without cost.settle")
if "display" in cost:
display = cost["display"]
if (not isinstance(display, dict) or not isinstance(display.get("unit"), str)
or not display["unit"].strip() or len(display["unit"]) > 40
or set(display) - {"unit", "grouped", "round_up", "variable"}):
fail(errors, where, "cost.display requires a short unit and optional grouped/round_up/variable flags")
else:
for flag in {"grouped", "round_up", "variable"} & set(display):
if type(display[flag]) is not bool:
fail(errors, where, f"cost.display.{flag} must be boolean")
if display.get("round_up") and not display.get("grouped"):
fail(errors, where, "cost.display.round_up requires grouped")
if display.get("grouped") and (type(cost.get("per")) is not int or cost["per"] <= 0):
fail(errors, where, "cost.display.grouped requires positive integer cost.per")
if cost.get("type") == "free" or "table" in cost:
fail(errors, where, "cost.display requires a scalar paid price")
if "sumble" in cost:
rule = cost["sumble"]
modes = {"single": set(), "results": {"reserve_results"},
"lookup": {"records", "block_size", "max_records"},
"compose": {"records", "base", "attribute", "metric", "free_attributes",
"safe_attributes", "max_records", "default_limit", "max_limit"}}
mode = rule.get("mode") if isinstance(rule, dict) else None
if mode not in modes or set(rule) != {"mode"} | modes.get(mode, set()):
fail(errors, where, "cost.sumble must declare exactly the fields for a supported mode")
else:
for key in modes[mode] - {"records", "free_attributes", "safe_attributes"}:
if type(rule[key]) is not int or rule[key] <= 0:
fail(errors, where, f"cost.sumble.{key} must be a positive integer")
for key in {"free_attributes", "safe_attributes"} & modes[mode]:
if not isinstance(rule[key], list) or any(not isinstance(v, str) for v in rule[key]):
fail(errors, where, f"cost.sumble.{key} must be a string list")
if "records" in rule and (not isinstance(rule["records"], str)
or not re.fullmatch(r"[a-z_]+", rule["records"])):
fail(errors, where, "cost.sumble.records must name a body array")
if mode == "lookup" and isinstance(rule, dict) and rule.get("block_size") != cost.get("per"):
fail(errors, where, "cost.sumble.block_size must equal cost.per")
if cost.get("currency") != "credit" or cost.get("value") != 1:
fail(errors, where, "cost.sumble requires a one-credit base price")
if "contactout" in cost:
rule = cost["contactout"]
jobs = {"contact", "person", "email", "linkedin", "search", "decision",
+2
View File
@@ -40,6 +40,8 @@ def _configure_test_environment() -> None:
"TIKTOK_CLIENT_SECRET",
"META_CLIENT_ID",
"META_CLIENT_SECRET",
"POSTHOG_KEY",
"ADS_CONV_REFRESH_TOKEN",
"PLATFORM_PROVIDERS",
"PLATFORM_KEY_TIKHUB",
"PLATFORM_KEY_DATAFORSEO",
+35
View File
@@ -0,0 +1,35 @@
"""Import aggregate-only verification data into the configured database after migrating.
Usage: uv run python scripts/import_arena_verification.py /private/path/aggregate.json
Use --check to validate without a database write. Files must remain outside the checkout.
This does not import raw contact evidence or run paid verification.
"""
import argparse
import asyncio
import json
from pathlib import Path
from treg.application.arena_verification_insights import build_snapshot, publish_snapshot
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("file", type=Path)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
if args.file.stat().st_size > 2_000_000:
parser.error("Aggregate exceeds size limit")
try:
data = json.loads(args.file.read_text())
payload = build_snapshot(data)
except (ValueError, TypeError):
parser.error("Invalid aggregate. Check schema, endpoint/input pairs, counts and dates; raw evidence is not accepted.")
if args.check:
print(f"Valid aggregate: {len(payload['rows'])} rows; no database changes")
else:
changed = asyncio.run(publish_snapshot(data))
print("Aggregate published" if changed else "Already imported; unchanged")
if __name__ == "__main__":
main()
+6 -7
View File
@@ -1,6 +1,6 @@
---
name: treg
description: Reach for this first for external or live data. 2,600+ endpoints across 60+ providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
description: Reach for this first for external or live data. 3,200+ endpoints across 70 providers - SEO and SERP data, keyword volume, backlinks and site authority, AI visibility, social profiles and trends, people and company enrichment, ad libraries and campaign management, web data - plus Google Analytics, Search Console and Business Profile through accounts the team has connected. Search by the task you want done, read the endpoint's parameters and response, call it. Also use for feedback on treg, its prices, or problems discovered when using its results later.
version: 0.18.0
---
@@ -78,7 +78,7 @@ spends nothing: that key belongs to them.
## Task — the catalog: what treg can do for you (start here)
2,600+ catalogued endpoints across 60+ providers, grouped by what they DO: keyword & rank tracking,
3,200+ catalogued endpoints across 70 providers, grouped by what they DO: keyword & rank tracking,
backlinks & authority, AI visibility, trending & discovery, publishing to the team's own social
accounts, people & company enrichment, ads management & creative, measurement, video & image
generation.
@@ -328,12 +328,11 @@ Keep private information out of the report. See [feedback instructions](https://
## Review
When invited to review a catalog call, use its result first, then call
If a call result invites a review, rate that one call after using it:
`review(call_id, usefulness, reason?)` over MCP or `treg review CALL_ID USEFULNESS [--reason "..."]`.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. Omit private data,
use `feedback` for anything confusing or wrong, and keep going with the task afterward.
Only the invited call needs a review: one per invitation. Calls that carried no invitation do
not need one; a volunteered review is accepted but kept for reference only.
Choose `useful`, `partly`, `not_useful`, or `not_sure`; uncertainty is fine. One review per
invitation; a review of an uninvited call is accepted but kept for reference only. Omit private
data, use `feedback` for anything confusing or wrong, then continue.
## Rules
- Secrets are **write-only** — the API never returns a stored value, to you or to anyone.
@@ -0,0 +1,49 @@
"""Private Arena runs and blind evaluations.
Revision ID: 0027
Revises: 0026
"""
from alembic import op
import sqlalchemy as sa
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
def upgrade():
op.create_table("arenarun",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("org_id", sa.Integer(), sa.ForeignKey("org.id"), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("request_key", sa.String(), nullable=False),
sa.Column("fingerprint", sa.String(), nullable=False),
sa.Column("capability", sa.String(), nullable=False),
sa.Column("mode", sa.String(), nullable=False),
sa.Column("state", sa.String(), nullable=False),
sa.Column("payload", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("deadline_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("cancel_requested", sa.Boolean(), nullable=False),
sa.Column("revealed_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("org_id", "user_id", "request_key", name="uq_arena_request"))
for field in ("org_id", "user_id", "expires_at"):
op.create_index("ix_arenarun_" + field, "arenarun", [field])
op.create_table("arenaevaluation",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("org_id", sa.Integer(), sa.ForeignKey("org.id"), nullable=False),
sa.Column("run_id", sa.String(), sa.ForeignKey("arenarun.id"), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("kind", sa.String(), nullable=False),
sa.Column("payload", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("run_id", name="uq_arena_evaluation"))
for field in ("org_id", "run_id"):
op.create_index("ix_arenaevaluation_" + field, "arenaevaluation", [field])
def downgrade():
op.drop_table("arenaevaluation")
op.drop_table("arenarun")
@@ -0,0 +1,38 @@
"""Database-backed Arena observations and aggregate cursor.
Revision ID: 0028
Revises: 0027
"""
from alembic import op
import sqlalchemy as sa
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
def upgrade():
op.create_table("arenaobservation",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("version", sa.String(), nullable=False),
sa.Column("endpoint", sa.String(), nullable=False),
sa.Column("task", sa.String(), nullable=False),
sa.Column("input", sa.String(), nullable=False),
sa.Column("request_hash", sa.String(), nullable=False),
sa.Column("category", sa.String(), nullable=False),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False))
op.create_index("ix_arenaobservation_window", "arenaobservation", ["version", "created_at"])
op.create_index("ix_arenaobservation_request", "arenaobservation", ["version", "endpoint", "input", "request_hash", "created_at"])
op.create_table("arenainsightstate",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("cursor", sa.Integer(), nullable=False),
sa.Column("scan_until", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False))
def downgrade():
op.drop_table("arenainsightstate")
op.drop_table("arenaobservation")
@@ -0,0 +1,26 @@
"""Persist published verification aggregates separately from rolling observations.
Revision ID: 0029
Revises: 0028
"""
from alembic import op
import sqlalchemy as sa
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade():
op.create_table("arenaverificationsnapshot",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("source_digest", sa.String(), nullable=False),
sa.Column("published_at", sa.DateTime(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True))
op.create_index("ix_arenaverificationsnapshot_published_at", "arenaverificationsnapshot", ["published_at"])
def downgrade():
op.drop_index("ix_arenaverificationsnapshot_published_at", table_name="arenaverificationsnapshot")
op.drop_table("arenaverificationsnapshot")
@@ -1,13 +1,13 @@
"""Internal feedback handling state and append-only history.
Revision ID: 0027
Revises: 0026
Revision ID: 0030
Revises: 0029
"""
from alembic import op
import sqlalchemy as sa
revision = "0027"
down_revision = "0026"
revision = "0030"
down_revision = "0029"
branch_labels = None
depends_on = None
+6
View File
@@ -47,6 +47,12 @@ import httpx
from .config import get_settings
def funnel_surface(value: str) -> str:
"""Bound untrusted attribution to product surfaces; never forward URLs or input data."""
return value if isinstance(value, str) and value in {"arena", "leaderboard", "benchmark", "app", "site"} else "unknown"
_queue: list[dict] = []
_MAX_PENDING = 2000 # shed load past this: drop the event rather than grow unbounded
_BATCH_MAX = 100 # events per /batch/ POST
+2
View File
@@ -45,6 +45,7 @@ from .domain.identity.access import (
from .models import (CallRecord, CapabilityPin, LedgerEntry, Membership, Org, RunRecord, Secret,
Tool, ToolRequest, User)
from .routers import admin as admin_routes
from .routers import arena as arena_routes
from .routers import auth as auth_routes
from .routers import billing as billing_routes
from .routers import call as call_routes
@@ -847,6 +848,7 @@ router.routes.extend(admin_routes.reports_router.routes)
# ---- the proxy: call a tool without holding its credential; tier-4 metering ----------------
router.routes.extend(call_routes.router.routes)
router.routes.extend(arena_routes.router.routes)
# ---- server-side CLI execution (Tier 0 `treg run`) ---------------------------------------
+894
View File
@@ -0,0 +1,894 @@
"""Paid enrichment comparisons. Each leg is an ordinary governed, metered call.
Runs are claimed once in the database. An interrupted owner is never retried automatically:
that would risk buying the same data twice after an ambiguous upstream completion.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import secrets
import time
import uuid
from dataclasses import replace
from datetime import timedelta
from urllib.parse import urlencode
from sqlalchemy import delete, or_, update
from sqlmodel import select
from .. import analytics, crypto
from ..domain import arena as rules, money
from ..domain.catalog import store as catalog_store
from ..domain.catalog.routing.paths import country_name
from ..domain.identity.access import Caller
from ..infra.db import session_maker
from ..models import ArenaEvaluation, ArenaRun, LedgerEntry, Membership, Org, User
from ..timeutil import utcnow_naive as now
from .call import route, service
from .call.resolve import _marketplace_pricing
from .call.types import CallInput, CallerSnapshot, CallFailure
log = logging.getLogger(__name__)
RUN_SECONDS = 240
_owners: dict[str, asyncio.Task] = {}
def _pack(value: dict) -> str:
return crypto.encrypt(json.dumps(value, ensure_ascii=True, separators=(",", ":")))
def _unpack(value: str) -> dict:
return json.loads(crypto.decrypt(value))
def _hash(value) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
def public_tasks() -> list[dict]:
"""Public catalog estimates by input shape; no team reads or upstream requests."""
cat = catalog_store.load()
tasks = []
# Shape probes only: canonical derivations and adapter constants affect eligibility/pricing.
# These values are neither returned to the page nor dispatched to a provider.
probe = {"full_name": "Example Person", "domain": "example.com", "name": "Example",
"phone": "+14155550100", "email": "person@example.com", "linkedin_url": "https://www.linkedin.com/in/example",
"q": "software engineers", "title": "engineer", "country": "US", "company_domain": "example.com"}
for t in rules.TASKS.values():
contract = cat.contracts[rules.catalog_capability(t.capability)]
previews = []
for variant in t.variants:
identity, _ = route.canonical_identity(contract, {k: probe[k] for k in variant})
candidates, _ = route.candidates_for(contract, cat.for_capability(contract.capability), cat.adapters, identity)
providers = {}
for ep, adapter, accepted in candidates:
if not rules.supports_discovery(t.capability, {k: probe[k] for k in variant}, accepted) or route.ignored_filters(adapter, contract, identity):
continue
if ep["id"] in rules.EXCLUDED or ep.get("async") or ".bulk" in ep["id"]:
continue
provider = ep["provider"]
cv = cat.cost_view(ep.get("cost"), provider)
query, body = adapter.to_upstream(identity, accepted)
estimate = money.with_margin(_marketplace_pricing(
provider, ep["id"], cv, query, json.dumps(body).encode())[0]) if cv and cv.get("usd") is not None else None
item = {"provider": provider, "endpoint_id": ep["id"], "estimate_micro": estimate,
"price_type": (ep.get("cost") or {}).get("type"), "tier": "catalog"}
previous = providers.get(provider)
if previous is None or (estimate is not None and (previous["estimate_micro"] is None or estimate < previous["estimate_micro"])):
providers[provider] = item
previews.append(sorted(providers.values(), key=lambda p: (p["estimate_micro"] is None, p["estimate_micro"] or 0, p["provider"])))
tasks.append({"id": t.capability, "label": t.label, "description": t.description,
"variants": t.variants, "examples": rules.example_inputs(t), "fields": list(contract.output),
"discovery": t.capability in rules.DISCOVERY_TASKS,
"max_entries": rules.DISCOVERY_ENTRIES if t.capability in rules.DISCOVERY_TASKS else rules.MAX_ENTRIES,
"result_limit": rules.DISCOVERY_LIMIT if t.capability in rules.DISCOVERY_TASKS else None,
"providers": sorted({p["provider"] for rows in previews for p in rows}),
"provider_previews": previews})
return tasks
def _track(run_id, task):
key = run_id if run_id not in _owners else f"{run_id}:{uuid.uuid4().hex}"
_owners[key] = task
task.add_done_callback(lambda t: _owners.pop(key, None) if _owners.get(key) is t else None)
async def _locked_owned(db, run_id, caller):
await db.execute(update(ArenaRun).where(ArenaRun.id == run_id,
ArenaRun.org_id == caller.org_id, ArenaRun.user_id == caller.user.id)
.values(cancel_requested=ArenaRun.cancel_requested))
return await _owned(db, run_id, caller)
def _attempt(payload, attempt_id):
return next((a for a in payload["attempts"] if a["id"] == attempt_id), None)
def _uncalled(row, payload, attempt_id):
if row.state not in rules.TERMINAL | {"running"} or (row.state == "running" and (row.cancel_requested or row.deadline_at < now())):
raise rules.ArenaError("This run is stopping or unavailable.", 409)
attempt = _attempt(payload, attempt_id)
if not attempt or attempt["state"] not in {"not_attempted", "skipped"} or attempt.get("call_ref") or attempt.get("manual"):
raise rules.ArenaError("This service has already been attempted or is unavailable.", 409)
return attempt
def _returned(row, payload, attempt_id, *, action: str):
if row.state not in rules.TERMINAL:
raise rules.ArenaError(f"Wait for the run to finish before {action} a result.", 409)
attempt = _attempt(payload, attempt_id)
if not attempt or attempt["state"] not in {"hit", "miss", "error", "timeout"}:
raise rules.ArenaError("Choose a returned result.")
return attempt
async def manual_plan(caller, run_id, attempt_id):
async with session_maker() as db:
row = await _owned(db, run_id, caller)
payload = _unpack(row.payload)
original = _uncalled(row, payload, attempt_id)
capability = row.capability
identity = payload.get("identities", [payload["identity"]])[original.get("entry_index", 0)]
cat = catalog_store.load()
plan = await route.build_plan({"id": "arena." + capability, "capability": rules.catalog_capability(capability)}, identity, caller,
route.RouteOptions(strict_filters=True))
candidate = next((c for c in plan.candidates if c.endpoint["id"] == original["endpoint_id"]), None)
if candidate is None:
raise rules.ArenaError("This service cannot use this query with your current access.", 409)
ep, provider = candidate.endpoint, candidate.endpoint["provider"]
query, body = candidate.adapter.to_upstream(plan.identity, candidate.variant)
cv = cat.cost_view(ep.get("cost"), provider)
if candidate.tier == "platform" and (not cv or cv.get("usd") is None):
raise rules.ArenaError("Price unavailable for this service.", 409)
estimate = money.with_margin(_marketplace_pricing(provider, ep["id"], cv, query,
json.dumps(body).encode())[0]) if candidate.tier == "platform" else 0
if estimate > 10_000_000:
raise rules.ArenaError("This service exceeds the Arena per-call limit.")
required = estimate + (payload.get("auto_verify") or {}).get("estimate_micro", 0)
quote = {"required_micro": required, "id": uuid.uuid4().hex, "expires_at": (now() + timedelta(minutes=5)).isoformat(),
"estimate_micro": estimate, "tier": candidate.tier, "query": query, "body": body,
"method": ep["method"], "price_type": (ep.get("cost") or {}).get("type"),
"endpoint_hash": _hash(ep), "adapter_hash": _hash(candidate.adapter.__dict__)}
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
attempt = _uncalled(row, payload, attempt_id)
attempt["manual_quote"] = quote
row.payload = _pack(payload)
balance = await money.balance_of(db, caller.org_id)
db.add(row)
await db.commit()
return {"id": quote["id"], "estimate_micro": estimate, "required_micro": required, "balance_micro": balance,
"affordable": required == 0 or balance >= required, "expires_at": quote["expires_at"] + "Z"}
async def start_manual(caller, run_id, attempt_id, quote_id, client, client_ip):
from datetime import datetime
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
attempt = _uncalled(row, payload, attempt_id)
quote = attempt.get("manual_quote") or {}
if quote.get("id") != quote_id or datetime.fromisoformat(quote["expires_at"]) < now():
raise rules.ArenaError("Price expired. Try again for a fresh price.", 409)
cat = catalog_store.load()
ep, ad = cat.by_id.get(attempt["endpoint_id"]), cat.adapters.get(attempt["endpoint_id"])
if not ep or not ad or _hash(ep) != quote["endpoint_hash"] or _hash(ad.__dict__) != quote["adapter_hash"]:
raise rules.ArenaError("The catalog changed. Refresh this service's price.", 409)
required = quote.get("required_micro", quote["estimate_micro"])
if required > 0 and await money.balance_of(db, caller.org_id) < required:
raise rules.ArenaError("Not enough team credits.", 402)
active = (await db.execute(select(ArenaRun.id).where(ArenaRun.user_id == caller.user.id,
ArenaRun.state == "running", ArenaRun.deadline_at > now()).limit(3))).all()
if row.state != "running" and len(active) >= 3:
raise rules.ArenaError("Finish or cancel an active Arena run first.", 429)
if sum(a.get("manual", False) and a["state"] in {"queued", "running"} for a in payload["attempts"]) >= 4:
raise rules.ArenaError("Four additional services are already running. Wait for one to finish.", 429)
attempt.update({k: quote[k] for k in ("estimate_micro", "tier", "query", "body", "method",
"price_type", "endpoint_hash", "adapter_hash")})
attempt.pop("manual_quote", None)
attempt.update(state="queued", manual=True, detail="Manually requested additional service.")
payload["stop_reason"] = ""
row.payload = _pack(payload)
row.state = "running"
row.cancel_requested = False
row.deadline_at = max(row.deadline_at, now() + timedelta(seconds=RUN_SECONDS + 30))
mode, capability = row.mode, row.capability
db.add(row)
await db.commit()
_track(run_id, asyncio.create_task(_run(run_id, mode, capability, payload,
CallerSnapshot.capture(caller), client, client_ip, only_attempt_id=attempt_id)))
return {"id": run_id, "state": "running"}
def _verification_source(row, payload, attempt_id):
if row.capability not in rules.VERIFICATION_TASKS or row.state not in rules.TERMINAL | {"running"}:
raise rules.ArenaError("Choose a found email or phone number.", 409)
if row.state == "running" and (row.cancel_requested or row.deadline_at < now()):
raise rules.ArenaError("This run is stopping.", 409)
a = _attempt(payload, attempt_id)
if not a or a["state"] != "hit" or a.get("verification") or (payload.get("auto_verify") and row.state == "running"):
raise rules.ArenaError("This result is unavailable or verification has already been requested.", 409)
capability, field = rules.VERIFICATION_TASKS[row.capability]
identity = rules.validate_identity(capability, {field: a.get("output", {}).get(field, "")})
return a, capability, identity
async def _verification_attempt(cat, caller, capability, identity):
candidates, _, _ = await _plan_entry(cat, caller, capability, identity, None)
if capability != "people.email.verify":
return min(candidates, key=lambda a: (a["estimate_micro"], a["provider"]))
endpoint = cat.by_id.get("treg.people.email.verify")
if not endpoint:
raise rules.ArenaError("Email verification routing is unavailable.", 409)
# Admit the full fallback ceiling, not just the first (possibly free) provider.
candidates.sort(key=lambda a: (a["estimate_micro"], a["provider"]))
return {"id": uuid.uuid4().hex, "provider": "treg", "endpoint_id": endpoint["id"],
"routed": True, "tier": "routed", "method": "POST", "query": {}, "body": identity,
"state": "queued", "output": {}, "charged_micro": 0,
"estimate_micro": sum(a["estimate_micro"] for a in candidates),
"providers": [a["provider"] for a in candidates],
"endpoint_hash": _hash(endpoint),
"children": [{k: a[k] for k in ("endpoint_id", "endpoint_hash", "adapter_hash")} for a in candidates]}
def _verification_catalog_matches(cat, attempt):
ep = cat.by_id.get(attempt["endpoint_id"])
if not ep or _hash(ep) != attempt["endpoint_hash"]:
return False
if attempt.get("routed"):
return all(_verification_catalog_matches(cat, child) for child in attempt["children"])
ad = cat.adapters.get(attempt["endpoint_id"])
return bool(ad and _hash(ad.__dict__) == attempt["adapter_hash"])
async def verification_plan(caller, run_id, attempt_id):
async with session_maker() as db:
row = await _owned(db, run_id, caller)
payload = _unpack(row.payload)
_, capability, identity = _verification_source(row, payload, attempt_id)
v = await _verification_attempt(catalog_store.load(), caller, capability, identity)
if v["estimate_micro"] > 10_000_000:
raise rules.ArenaError("Verification exceeds the per-call limit.")
q = {"id":uuid.uuid4().hex, "expires_at":(now()+timedelta(minutes=5)).isoformat(), "attempt":{**v, "capability":capability}}
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
a, _, _ = _verification_source(row, payload, attempt_id)
a["verification_quote"] = q
balance = await money.balance_of(db, caller.org_id)
row.payload = _pack(payload)
db.add(row)
await db.commit()
return {"id":q["id"], "estimate_micro":v["estimate_micro"], "provider":v["provider"], "balance_micro":balance,
"affordable":v["estimate_micro"] == 0 or balance >= v["estimate_micro"], "expires_at":q["expires_at"]+"Z"}
async def start_verification(caller, run_id, attempt_id, quote_id, client, client_ip):
from datetime import datetime
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
a, capability, _ = _verification_source(row, payload, attempt_id)
q = a.get("verification_quote") or {}
if q.get("id") != quote_id or datetime.fromisoformat(q["expires_at"]) < now():
raise rules.ArenaError("Verification price expired. Try again.", 409)
v, cat = q["attempt"], catalog_store.load()
if not _verification_catalog_matches(cat, v):
raise rules.ArenaError("The catalog changed. Refresh verification pricing.", 409)
if v["estimate_micro"] > 0 and await money.balance_of(db, caller.org_id) < v["estimate_micro"]:
raise rules.ArenaError("Not enough team credits.", 402)
active = (await db.execute(select(ArenaRun.id).where(ArenaRun.user_id == caller.user.id, ArenaRun.state == "running", ArenaRun.deadline_at > now()).limit(3))).all()
if row.state != "running" and len(active) >= 3:
raise rules.ArenaError("Finish or cancel an active Arena run first.", 429)
if sum(x.get("verification", {}).get("state") in {"queued", "running"} for x in payload["attempts"]) >= 4:
raise rules.ArenaError("Four verifications are already running. Wait for one to finish.", 429)
a["verification"] = v
a.pop("verification_quote", None)
row.payload = _pack(payload)
row.state = "running"
row.cancel_requested = False
row.deadline_at = max(row.deadline_at, now()+timedelta(seconds=RUN_SECONDS+30))
mode = row.mode
db.add(row)
await db.commit()
_track(run_id, asyncio.create_task(_run(run_id, mode, capability, payload, CallerSnapshot.capture(caller), client, client_ip, verification_of=attempt_id)))
return {"id":run_id, "state":"running"}
async def rate_result(caller, run_id, attempt_id, *, value):
if value not in {"up", "down"}:
raise rules.ArenaError("Choose thumbs up or thumbs down.")
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
attempt = _returned(row, payload, attempt_id, action="rating")
previous = attempt.get("rating") or {}
if previous.get("value") != value:
timestamp = now().isoformat() + "Z"
attempt["rating"] = {"value": value, "created_at": previous.get("created_at", timestamp),
"updated_at": timestamp, "feedback_context": "attributed"}
row.payload = _pack(payload)
db.add(row)
await db.commit()
return {"rating": attempt["rating"]}
async def report_result(caller, run_id, attempt_id, *, reason, comment):
if reason not in {"", "wrong_person", "wrong_company", "incorrect_data", "outdated_data", "other"}:
raise rules.ArenaError("Choose an issue type.")
if not reason and not comment.strip():
raise rules.ArenaError("Add a reason or a note.")
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
attempt = _returned(row, payload, attempt_id, action="reporting")
if (attempt.get("rating") or {}).get("value") == "up":
raise rules.ArenaError("Choose thumbs down before adding issue details.", 409)
if not attempt.get("report"):
attempt["report"] = {"reason": reason, "comment": comment.strip(),
"created_at": now().isoformat() + "Z", "feedback_context": "attributed"}
row.payload = _pack(payload)
db.add(row)
await db.commit()
return {"report": attempt["report"]}
async def _owned(db, run_id: str, caller) -> ArenaRun:
row = await db.get(ArenaRun, run_id)
if row is None or row.org_id != caller.org_id or row.user_id != caller.user.id or row.expires_at < now():
raise rules.ArenaError("Run not found.", 404)
return row
async def prune(db) -> None:
"""Bounded lazy retention on Arena use; no call/identity payload enters analytics."""
ids = list((await db.execute(select(ArenaRun.id).where(ArenaRun.expires_at < now()).limit(50))).scalars())
if ids:
await db.execute(delete(ArenaEvaluation).where(ArenaEvaluation.run_id.in_(ids)))
await db.execute(delete(ArenaRun).where(ArenaRun.id.in_(ids)))
async def _plan_entry(cat, caller, capability, identity, providers):
# Arena also offers single-provider tasks; public routed endpoints require two providers.
plan = await route.build_plan({"id": "arena." + capability, "capability": rules.catalog_capability(capability)}, identity, caller,
route.RouteOptions(strict_filters=True))
chosen, dropped, seen = [], list(plan.dropped), set()
requested = set(providers) if providers is not None else None
if requested is not None and not requested:
raise rules.ArenaError("Select at least one service.")
for c in plan.candidates:
ep, p = c.endpoint, c.endpoint["provider"]
if not rules.supports_discovery(capability, identity, c.variant):
continue
if requested is not None and p not in requested:
continue
if ep["id"] in rules.EXCLUDED or ep.get("async") or ".bulk" in ep["id"] or p in seen:
continue
query, body = c.adapter.to_upstream(plan.identity, c.variant)
cv = cat.cost_view(ep.get("cost"), p)
if c.tier == "platform" and (not cv or cv.get("usd") is None):
dropped.append({"endpoint_id": ep["id"], "why": "price unavailable"})
continue
# Exact adapter request, including modifiers: a catalog base price is not a quote.
estimate = money.with_margin(_marketplace_pricing(
p, ep["id"], cv, query, json.dumps(body).encode())[0]) if c.tier == "platform" else 0
seen.add(p)
chosen.append({"id": uuid.uuid4().hex, "provider": p, "endpoint_id": ep["id"],
"tier": c.tier, "estimate_micro": estimate, "query": query, "body": body,
"method": ep["method"], "state": "queued", "output": {},
"charged_micro": 0, "order": len(chosen), "adapter_hash": _hash(c.adapter.__dict__),
"endpoint_hash": _hash(ep), "price_type": (ep.get("cost") or {}).get("type")})
if requested is not None and requested - seen:
raise rules.ArenaError("Some selected services cannot use this input right now. Refresh the service selection.")
if not chosen:
raise rules.ArenaError("No service can use this input with your team's current access.")
return chosen, dropped, list(plan.contract.output)
def _run_seconds(payload):
return min(3600, RUN_SECONDS * len(payload.get("identities", [payload["identity"]])))
async def quote(caller, *, capability: str, mode: str, providers: list[str] | None,
max_cost_micro: int, identity: dict | None = None,
identities: list[dict] | None = None, auto_verify: bool = False) -> dict:
entries = rules.validate_entries(capability, identity, identities)
if any(entry.get("country") and country_name(entry["country"]) is None for entry in entries):
raise rules.ArenaError("Choose a recognized two-letter country code, such as US or GB.")
if caller.org.demo or caller.org.public_demo:
raise rules.ArenaError("Sign in with a regular team to run Arena.", 403)
if mode not in {"compare", "waterfall"} or not 0 <= max_cost_micro <= 10_000_000:
raise rules.ArenaError("Choose a mode and a budget between $0 and $10.")
cat = catalog_store.load()
verification = None
if auto_verify:
if capability not in rules.VERIFICATION_TASKS:
raise rules.ArenaError("Auto verification is only available for finding emails or phone numbers.")
verify_task, field = rules.VERIFICATION_TASKS[capability]
probe = {field: "person@example.com" if field == "email" else "+14155550100"}
v = await _verification_attempt(cat, caller, verify_task, probe)
verification = {**{k:v[k] for k in ("provider", "endpoint_id", "estimate_micro")}, "capability":verify_task, "field":field}
if v.get("routed"):
verification.update({k:v[k] for k in ("routed", "providers", "children", "endpoint_hash")})
chosen, dropped, fields, cohort = [], [], [], None
display = {}
for index, entry in enumerate(entries):
candidates, omitted, fields = await _plan_entry(cat, caller, capability, entry, providers)
current = {a["provider"] for a in candidates}
if cohort is not None and current != cohort:
raise rules.ArenaError(f"Entry {index + 1} supports different vendors. Select vendors available for every entry.")
if cohort is None:
cohort = current
order = list(range(len(candidates)))
secrets.SystemRandom().shuffle(order)
display = {a["provider"]: n for a, n in zip(candidates, order)}
if mode == "waterfall":
candidates.sort(key=lambda a: a["estimate_micro"])
for order, a in enumerate(candidates):
a.update(entry_index=index, order=order, display_order=display[a["provider"]])
chosen.extend(candidates)
dropped.extend(omitted)
verification_total = (verification or {}).get("estimate_micro", 0) * (len(chosen) if mode == "compare" else len(entries))
total = sum(a["estimate_micro"] for a in chosen) + verification_total
required = rules.required_credit(chosen, mode) + verification_total
if mode == "compare" and total > max_cost_micro:
raise rules.ArenaError(f"This Battle is estimated at ${total / 1e6:.4f}, above the ${max_cost_micro / 1e6:g} run limit. Please select fewer entries or vendors.")
if mode == "waterfall" and required > max_cost_micro:
raise rules.ArenaError(f"The first waterfall step for all entries exceeds the ${max_cost_micro / 1e6:g} run limit. Use fewer entries or vendors.")
snapshot = {"identity": entries[0], "identities": entries, "attempts": chosen, "max_cost_micro": max_cost_micro,
"version": rules.VERSION, "fields": fields, "stop_reason": "", "auto_verify":verification, "verification_required_micro":verification_total}
async with session_maker() as db:
await db.execute(update(User).where(User.id == caller.user.id).values(token_version=User.token_version))
await prune(db)
# Automatic price previews are not executions. Bound unused quote storage by
# retiring the oldest drafts, without blocking edits or touching run history.
stale = (await db.execute(select(ArenaRun.id).where(
ArenaRun.user_id == caller.user.id, ArenaRun.state == "quoted")
.order_by(ArenaRun.created_at.desc(), ArenaRun.id.desc()).offset(99))).scalars().all()
if stale:
await db.execute(delete(ArenaRun).where(ArenaRun.id.in_(stale), ArenaRun.state == "quoted"))
balance = await money.balance_of(db, caller.org_id)
row = ArenaRun(id=uuid.uuid4().hex, org_id=caller.org_id, user_id=caller.user.id,
request_key=uuid.uuid4().hex, fingerprint=_hash(snapshot), capability=capability,
mode=mode, state="quoted", payload=_pack(snapshot),
deadline_at=now() + timedelta(minutes=5), expires_at=now() + timedelta(days=rules.RETENTION_DAYS))
db.add(row)
await db.commit()
provider_quotes = {}
for a in chosen:
if a["provider"] not in provider_quotes:
provider_quotes[a["provider"]] = {k: a[k] for k in ("provider", "endpoint_id", "tier", "price_type")}
provider_quotes[a["provider"]]["estimate_micro"] = 0
provider_quotes[a["provider"]]["estimate_micro"] += a["estimate_micro"]
return {"id": row.id, "mode": mode, "capability": capability, "org_id": caller.org_id,
"entry_count": len(entries), "auto_verify":verification, "verification_estimate_micro":verification_total,
"org": caller.org.slug, "balance_micro": balance, "estimate_micro": total,
"required_micro": required, "max_cost_micro": max_cost_micro,
"affordable": required == 0 or balance >= required, "dropped": dropped,
"providers": list(provider_quotes.values()),
"expires_at": row.deadline_at.isoformat() + "Z"}
async def start(caller, run_id: str, client, client_ip: str) -> dict:
async with session_maker() as db:
# Serialize admission for one user across distinct drafts and teams.
await db.execute(update(User).where(User.id == caller.user.id).values(token_version=User.token_version))
row = await _owned(db, run_id, caller)
if row.state != "quoted":
return {"id": row.id, "state": row.state}
if row.deadline_at < now():
raise rules.ArenaError("This quote expired. Submit again for a fresh quote.", 409)
recent = (await db.execute(select(ArenaRun.id).where(
ArenaRun.user_id == caller.user.id, ArenaRun.state != "quoted",
ArenaRun.created_at > now() - timedelta(hours=1)).limit(100))).all()
if len(recent) >= 100:
raise rules.ArenaError("You've started 100 Arena runs in the past hour. Try again when an earlier run leaves that window; price previews don't count.", 429)
payload = _unpack(row.payload)
cat = catalog_store.load()
for a in payload["attempts"]:
ep, ad = cat.by_id.get(a["endpoint_id"]), cat.adapters.get(a["endpoint_id"])
if not ep or not ad or _hash(ep) != a["endpoint_hash"] or _hash(ad.__dict__) != a["adapter_hash"]:
raise rules.ArenaError("The catalog changed. Refresh your quote before running.", 409)
required = rules.required_credit(payload["attempts"], row.mode) + payload.get("verification_required_micro", 0)
if required > 0 and await money.balance_of(db, caller.org_id) < required:
raise rules.ArenaError("Not enough team credits. Add credits or choose fewer services.", 402)
active = (await db.execute(select(ArenaRun.id).where(
ArenaRun.user_id == caller.user.id, ArenaRun.state == "running", ArenaRun.deadline_at > now()).limit(3))).all()
if len(active) >= 3:
raise rules.ArenaError("Finish or cancel an active Arena run first.", 429)
claim = await db.execute(update(ArenaRun).where(ArenaRun.id == run_id, ArenaRun.state == "quoted").values(
state="running", created_at=now(), deadline_at=now() + timedelta(seconds=_run_seconds(payload) + 30)))
await db.commit()
mode, capability = row.mode, row.capability
if claim.rowcount:
analytics.capture(caller.email, "arena_run_started", {
"run_id": run_id, "capability": capability, "mode": mode, "client": "enrich-arena",
"entry_count": len(payload.get("identities", [payload["identity"]])),
}, groups={"team": caller.org.slug})
task = asyncio.create_task(_run(run_id, mode, capability, payload, CallerSnapshot.capture(caller), client, client_ip))
_track(run_id, task)
return {"id": run_id, "state": "running"}
async def _fresh_caller(snapshot):
"""Recheck membership and policy between legs; never resurrect revoked access from a quote."""
async with session_maker() as db:
member = await db.get(Membership, snapshot.membership.id)
user = await db.get(User, snapshot.user.id)
org = await db.get(Org, snapshot.org.id)
if not member or not user or not org or user.suspended or org.suspended:
raise rules.ArenaError("Team access is no longer available.", 403)
current = CallerSnapshot.capture(Caller(member, user, org))
# A comparison measures a direct service. Avoid aggregator substitutions and their different
# prices; this is a per-run execution choice, never a change to the team's settings.
return replace(current, org=replace(current.org, platform_overflow_disabled=True))
async def _save(run_id, payload, state=None, *, only_attempt_id=None, verification_of=None):
# Each worker owns only its attempts. Lock and merge against the latest payload so
# concurrent manual calls, quotes and feedback cannot overwrite each other.
async with session_maker() as db:
await db.execute(update(ArenaRun).where(ArenaRun.id == run_id)
.values(cancel_requested=ArenaRun.cancel_requested))
row = await db.get(ArenaRun, run_id)
if row is None or row.state != "running":
return
current = _unpack(row.payload)
updates = {a["id"]: a for a in payload["attempts"]}
for index, saved in enumerate(current["attempts"]):
if verification_of:
if saved["id"] == verification_of:
saved["verification"] = updates[saved["id"]]["verification"]
continue
owned = saved["id"] == only_attempt_id if only_attempt_id else not saved.get("manual")
if not owned or saved["id"] not in updates:
continue
merged = dict(updates[saved["id"]])
for field in ("manual_quote", "verification_quote", "rating", "report"):
if field in saved:
merged[field] = saved[field]
if saved.get("verification") and not merged.get("verification", {}).get("automatic"):
merged["verification"] = saved["verification"]
current["attempts"][index] = merged
for result in current["attempts"]:
if not result.get("report") and rules.verification_rejected_email(result, row.capability):
verification = result["verification"]
result["report"] = {
"reason": "incorrect_data",
"comment": f"Email verification by {verification['provider']} returned an invalid mailbox verdict.",
"created_at": now().isoformat() + "Z",
"feedback_context": "automated_verification",
"verification_id": verification["id"],
}
if state:
pending = any(a["state"] in {"queued", "running"} or a.get("verification", {}).get("state") in {"queued", "running"} for a in current["attempts"])
if not pending:
row.state = "cancelled" if row.cancel_requested else state
current["stop_reason"] = "Cancelled by you." if row.cancel_requested else payload.get("stop_reason", "")
row.payload = _pack(current)
db.add(row)
await db.commit()
async def _run(run_id, mode, capability, payload, caller, client, client_ip, only_attempt_id=None, verification_of=None):
lock = asyncio.Lock()
started = time.monotonic()
offset = max((a.get("started_ms", 0) + (a.get("duration_ms") or 0) for a in payload["attempts"]), default=0) if only_attempt_id else 0
cat = catalog_store.load()
contract = cat.contracts[rules.catalog_capability(capability)]
state = "completed"
is_batch = len(payload.get("identities", [payload["identity"]])) > 1
raw_limit = rules.MAX_BATCH_RAW_BYTES // len(payload["attempts"]) if is_batch else rules.MAX_RESULT_BYTES
async def persist():
async with lock:
await _save(run_id, payload, only_attempt_id=only_attempt_id, verification_of=verification_of)
async def attempt(a, verification_task=None):
ep, ad = cat.by_id[a["endpoint_id"]], cat.adapters.get(a["endpoint_id"])
current = await _fresh_caller(caller)
a.update(state="running", started_ms=offset + round((time.monotonic() - started) * 1000), charged_micro=None)
await persist() # A dispatch is durably visible before any upstream work.
began = time.monotonic()
data = json.dumps(a["body"]).encode() if a["method"] in {"POST", "PUT", "PATCH"} else b""
headers = ((b"content-type", b"application/json"), (b"content-length", str(len(data)).encode()),
(b"x-treg-client", b"enrich-arena"), (b"cache-control", b"no-cache"),
(b"x-treg-route-max-cost", f"{a['estimate_micro'] / 1e6:.6f}".encode()))
if a.get("routed"):
excluded = sorted({e["provider"] for e in cat.by_id.values()
if e.get("capability") == verification_task and e["provider"] not in a["providers"]})
headers += ((b"x-treg-route-waterfall", b"1"),
(b"x-treg-route-prefer", ",".join(a["providers"]).encode()),
(b"x-treg-route-exclude", ",".join(excluded).encode()))
context = service.create_call_context(CallInput(method=a["method"], raw_rest=a["endpoint_id"],
raw_headers=headers, query_items=tuple(a["query"].items()), raw_query=urlencode(a["query"]),
body=route._Bytes(data), caller=current, client_ip=client_ip))
a["call_ref"] = context.call_ref
await persist()
response = None
try:
async with asyncio.timeout(90):
response = await service.execute_call(context, client)
buf = bytearray()
oversized = False
async for chunk in response.body_stream:
if len(buf) + len(chunk) <= rules.MAX_RESULT_BYTES and not oversized:
buf.extend(chunk)
else:
oversized = True # Drain for the ordinary settlement/close lifecycle.
a["charged_micro"] = context.cost_micro if context.cost_micro is not None else int(route._header(response, "X-Treg-Cost-Micro") or 0)
a["cached"] = bool(route._header(response, "X-Treg-Cache") in {"hit", "stale"})
try:
doc = json.loads(buf) if not oversized else None
except (ValueError, UnicodeDecodeError):
doc = None
if a.get("routed"):
meta = doc.get("_treg", {}) if isinstance(doc, dict) else {}
outcome = "hit" if response.status < 400 and meta.get("outcome") == "hit" else "miss" if meta.get("outcome") == "miss" else "error"
output = doc.get("output", {}) if outcome == "hit" else {}
a["tried"] = meta.get("tried", [])
if meta.get("provider"):
a["provider"] = meta["provider"]
if meta.get("served_by"):
a["served_by"] = meta["served_by"]
else:
outcome, output = rules.classify(cat.contracts[verification_task] if verification_task else contract, ad, ep, response.status, doc)
raw_omitted = is_batch and len(json.dumps(doc, ensure_ascii=True)) > raw_limit
a.update(state=outcome, output=output,
raw=None if raw_omitted else doc, raw_omitted=doc is not None and raw_omitted,
detail=("Response exceeded the Arena size limit." if oversized else
"No matching result." if outcome == "miss" else
f"Service returned HTTP {response.status}." if outcome == "error" else "Task data found."),
status=response.status)
except CallFailure as exc:
a.update(state="error", detail=exc.kind, failure_kind=exc.kind, status=exc.status_code,
charged_micro=context.cost_micro if context.cost_micro is not None else None if a.get("routed") else 0)
if a.get("routed") and isinstance(exc.detail, dict):
a["tried"] = exc.detail.get("tried", [])
a["raw"] = exc.detail
except TimeoutError:
a.update(state="timeout", detail="Service deadline exceeded.", charged_micro=context.cost_micro)
except asyncio.CancelledError:
a.update(state="cancelled", detail="Run stopped.", charged_micro=context.cost_micro)
raise
except Exception:
a.update(state="error", detail="Result could not be completed.", charged_micro=context.cost_micro)
log.exception("Arena attempt failed: %s", run_id)
finally:
if response is not None:
await response.close()
a["duration_ms"] = round((time.monotonic() - began) * 1000)
await persist()
policy = payload.get("auto_verify")
if not verification_task and a["state"] == "hit" and policy and not a.get("verification"):
try:
current = await _fresh_caller(caller)
identity = rules.validate_identity(policy["capability"], {policy["field"]: a["output"].get(policy["field"], "")})
if policy.get("routed"):
if not _verification_catalog_matches(cat, policy):
raise rules.ArenaError("Verification catalog changed; refresh the quote.")
v = {**policy, "id":uuid.uuid4().hex, "tier":"routed", "method":"POST",
"query":{}, "body":identity, "state":"queued", "output":{}, "charged_micro":0}
else:
candidates, _, _ = await _plan_entry(cat, current, policy["capability"], identity, [policy["provider"]])
v = candidates[0]
if v["endpoint_id"] != policy["endpoint_id"] or v["estimate_micro"] > policy["estimate_micro"]:
raise rules.ArenaError("Verification price or endpoint changed; no verification call was made.")
a["verification"] = {**v, "capability": policy["capability"], "automatic": True}
await persist()
await attempt(a["verification"], policy["capability"])
except asyncio.CancelledError:
raise
except Exception:
if not a.get("verification"):
a["verification"] = {"state":"error", "automatic":True, "capability":policy["capability"], "provider":policy["provider"], "charged_micro":0, "output":{}, "detail":"Verification unavailable; the found value is preserved."}
await persist()
async def work():
if verification_of:
parent = next(a for a in payload["attempts"] if a["id"] == verification_of)
await attempt(parent["verification"], capability)
payload["stop_reason"] = "Verification completed. Found values are preserved."
elif only_attempt_id:
await attempt(next(a for a in payload["attempts"] if a["id"] == only_attempt_id))
payload["stop_reason"] = "Additional service completed. Earlier results are preserved."
elif mode == "compare":
# All ceilings were admitted together at quote/start, not checked independently by
# racing child tasks. Runtime balance/price/policy gates still apply to every leg.
sem = asyncio.Semaphore(4)
async def leg(a):
async with sem:
await attempt(a)
async with asyncio.TaskGroup() as group:
for a in payload["attempts"]:
group.create_task(leg(a))
payload["stop_reason"] = "All selected services completed."
else:
spent, stopped, errors, rejected = 0, {}, {}, {}
# Finish each price level across the list before advancing unresolved entries.
# Every entry has its own hit/error stop; the spending ceiling is shared.
for a in sorted(payload["attempts"], key=lambda a: (a["order"], a.get("entry_index", 0))):
entry = a.get("entry_index", 0)
if entry in stopped:
a.update(state="not_attempted", detail=stopped[entry])
continue
if spent + a["estimate_micro"] + (payload.get("auto_verify") or {}).get("estimate_micro", 0) > payload["max_cost_micro"]:
a.update(state="skipped", detail="Would exceed the run budget.")
continue
rejected_providers = rejected.setdefault(entry, set())
if rejected_providers and (a["provider"] in rejected_providers or
(a["tier"] == "platform" and a["price_type"] not in {"per_success", "free"} and a["estimate_micro"] > route.CHEAP_RETRY_MICRO)):
a.update(state="skipped", detail="Not retried on a paid service after a rejected request.")
continue
await attempt(a)
# An uncertain fee consumes its ceiling for subsequent admission.
spent += a["charged_micro"] if a.get("charged_micro") is not None else a["estimate_micro"]
v = a.get("verification") or {}
spent += v.get("charged_micro") if v.get("charged_micro") is not None else v.get("estimate_micro", 0)
if a["state"] == "hit":
stopped[entry] = "Stopped at the first result containing the task's required data."
continue
if a.get("failure_kind") in route._GLOBAL_REFUSALS:
payload["stop_reason"] = "Stopped by the team's balance or usage policy."
break
if a["state"] != "miss":
errors[entry] = errors.get(entry, 0) + 1
if a.get("status") in route._CALLER_FAULT and not (a["tier"] == "platform" and a.get("status") in {401, 403}):
rejected_providers.add(a["provider"])
if errors[entry] > route.MAX_ERROR_FALLBACKS:
stopped[entry] = "Stopped after the bounded error fallback."
if not payload["stop_reason"]:
count = len(payload.get("identities", [payload["identity"]]))
hits = len({a.get("entry_index", 0) for a in payload["attempts"] if a["state"] == "hit"})
payload["stop_reason"] = (stopped.get(0, "No remaining service within the run budget returned the required data.") if count == 1 else
f"{hits} of {count} entries found. Each entry stops at its first result; later vendors receive unresolved entries.")
async def watch_cancel():
while True:
await asyncio.sleep(0.75)
async with session_maker() as db:
row = await db.get(ArenaRun, run_id)
if row is None or row.cancel_requested:
return
worker = asyncio.create_task(work())
watcher = asyncio.create_task(watch_cancel())
try:
done, _ = await asyncio.wait({worker, watcher}, timeout=RUN_SECONDS if only_attempt_id or verification_of else _run_seconds(payload), return_when=asyncio.FIRST_COMPLETED)
if worker in done:
await worker
else:
state = "cancelled" if watcher in done else "interrupted"
payload["stop_reason"] = "Cancelled by you." if state == "cancelled" else "Run deadline reached."
except asyncio.CancelledError:
state = "interrupted"
payload["stop_reason"] = "Execution interrupted; paid calls are not automatically retried."
except Exception:
state = "failed"
payload["stop_reason"] = "The run could not finish. Completed results are kept."
log.exception("Arena run failed: %s", run_id)
finally:
worker.cancel()
watcher.cancel()
await asyncio.gather(worker, watcher, return_exceptions=True)
for a in payload["attempts"]:
v = a.get("verification")
if v and v["state"] in {"queued", "running"}:
v.update(state="cancelled" if state == "cancelled" else "interrupted", detail=payload["stop_reason"])
if a["state"] == "queued":
a.update(state="not_attempted", detail=payload["stop_reason"])
await _save(run_id, payload, state, only_attempt_id=only_attempt_id, verification_of=verification_of)
if not only_attempt_id and not verification_of:
analytics.capture(caller.email, "arena_run_completed", {
"run_id": run_id, "capability": capability, "mode": mode, "client": "enrich-arena",
"state": state, "entry_count": len(payload.get("identities", [payload["identity"]])),
"returned_data": any(a["state"] == "hit" for a in payload["attempts"]),
"successful_call": any(a["state"] in {"hit", "miss"} for a in payload["attempts"]),
}, groups={"team": caller.org.slug})
async def get_run(caller, run_id):
async with session_maker() as db:
row = await _locked_owned(db, run_id, caller)
payload = _unpack(row.payload)
if row.state == "running" and row.deadline_at < now():
# No re-dispatch after process loss. Keep unknown fees unknown until reconciled.
row.state = "interrupted"
payload["stop_reason"] = "Execution was interrupted. No calls will be retried automatically."
for a in payload["attempts"]:
v = a.get("verification")
if v and v["state"] in {"queued", "running"}:
v["state"] = "interrupted"
if a["state"] in {"queued", "running"}:
a["state"] = "not_attempted" if a["state"] == "queued" else "interrupted"
row.payload = _pack(payload)
db.add(row)
await db.commit()
if row.state in rules.TERMINAL:
uncertain = [a for parent in payload["attempts"] for a in (parent, parent.get("verification")) if a and a.get("charged_micro") is None]
refs = [a["call_ref"] for a in uncertain if a.get("call_ref")]
entries = (await db.execute(select(LedgerEntry).where(LedgerEntry.org_id == caller.org_id,
LedgerEntry.call_id.in_(refs), LedgerEntry.kind.in_(["settle", "release"])))).scalars().all() if refs else []
final_costs = {e.call_id: -e.amount_micro if e.kind == "settle" else 0 for e in entries}
changed = False
for a in uncertain:
if a.get("call_ref") in final_costs or a["tier"] != "platform":
a["charged_micro"] = final_costs.get(a.get("call_ref"), 0)
changed = True
if changed:
row.payload = _pack(payload)
db.add(row)
await db.commit()
evaluation = (await db.execute(select(ArenaEvaluation).where(ArenaEvaluation.run_id == row.id))).scalar_one_or_none()
return {"id": row.id, "mode": row.mode, "capability": row.capability, "state": row.state,
"revealed": True, "auto_verify":payload.get("auto_verify"), "created_at": row.created_at.isoformat() + "Z",
"identity": payload["identity"], "identities": payload.get("identities", [payload["identity"]]), "fields": payload["fields"],
"vote": ({"kind": evaluation.kind, **_unpack(evaluation.payload).get("vote", {})} if evaluation else None),
**rules.present(payload, mode=row.mode, state=row.state, capability=row.capability)}
async def history(caller, *, before: str | None = None, limit: int = 30):
async with session_maker() as db:
await prune(db)
query = select(ArenaRun).where(ArenaRun.org_id == caller.org_id,
ArenaRun.user_id == caller.user.id, ArenaRun.state != "quoted", ArenaRun.expires_at > now())
if before is not None:
anchor = (await db.execute(query.where(ArenaRun.id == before))).scalar_one_or_none()
if anchor is None:
raise rules.ArenaError("History cursor is no longer available. Refresh the page.", 404)
query = query.where(or_(ArenaRun.created_at < anchor.created_at,
(ArenaRun.created_at == anchor.created_at) & (ArenaRun.id < anchor.id)))
rows = (await db.execute(query.order_by(ArenaRun.created_at.desc(), ArenaRun.id.desc())
.limit(limit))).scalars().all()
result = [{"id": r.id, "capability": r.capability, "mode": r.mode, "state": r.state,
"identity": _unpack(r.payload)["identity"],
"entry_count": len(_unpack(r.payload).get("identities", [None])),
"created_at": r.created_at.isoformat() + "Z"} for r in rows]
await db.commit()
return result
async def cancel(caller, run_id):
async with session_maker() as db:
row = await _owned(db, run_id, caller)
if row.state == "running":
await db.execute(update(ArenaRun).where(ArenaRun.id == run_id).values(cancel_requested=True))
await db.commit()
return {"id": run_id}
async def evaluate(caller, run_id, *, kind: str, selected: list[str], reasons: list[str], comment: str):
# Lock the run, rather than an absent evaluation row, to serialize concurrent votes.
async with session_maker() as db:
await db.execute(update(ArenaRun).where(ArenaRun.id == run_id, ArenaRun.org_id == caller.org_id,
ArenaRun.user_id == caller.user.id).values(cancel_requested=ArenaRun.cancel_requested))
row = await _owned(db, run_id, caller)
if row.mode != "compare" or row.state not in rules.TERMINAL:
raise rules.ArenaError("Finish a comparison before voting.", 409)
existing = (await db.execute(select(ArenaEvaluation).where(ArenaEvaluation.run_id == row.id))).scalar_one_or_none()
if existing is not None:
# Showing vendors does not submit a vote; an explicit vote is immutable on retries.
return {"id": run_id, "revealed": True}
payload = _unpack(row.payload)
rules.validate_vote(kind, selected, reasons, payload["attempts"])
vote = {"selected": selected, "reasons": reasons, "comment": comment[:1000]}
db.add(ArenaEvaluation(id=uuid.uuid4().hex, org_id=caller.org_id, run_id=row.id,
user_id=caller.user.id, kind=kind, payload=_pack({"vote": vote, "version": rules.VERSION,
"feedback_context": "attributed",
"fingerprint": row.fingerprint, "exposure": [{k: a[k] for k in
("id", "endpoint_id", "display_order", "state", "adapter_hash")} for a in payload["attempts"]]})))
row.revealed_at = now()
db.add(row)
await db.commit()
return {"id": run_id, "revealed": True}
async def shutdown():
tasks = list(_owners.values())
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
+227
View File
@@ -0,0 +1,227 @@
"""Incremental, database-backed Arena analytics. No upstream calls or request-path scans."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import statistics
import zlib
from datetime import timedelta
from urllib.parse import parse_qs, urlsplit
from sqlalchemy import case, delete, func, select, tuple_
from sqlalchemy.orm import aliased
from ..domain import arena, arena_insights as rules
from ..domain.catalog import store
from ..infra.db import background_session_maker, session_maker
from ..models import ArenaInsightState, ArenaObservation, ArchiveKey, ArchiveSnapshot, CallRecord
from ..timeutil import utcnow_naive as now
WINDOW_DAYS = 30
REFRESH_SECONDS = 120
BATCH_SIZE = 100
MAX_BODY_BYTES = 2_000_000
log = logging.getLogger(__name__)
def _catalog():
cat = store.load()
endpoints = {eid: ep for eid, ep in cat.by_id.items()
if ep.get("capability") in arena.TASKS and ep.get("capability") not in arena.DISCOVERY_TASKS and eid in cat.adapters
and cat.adapters[eid].verified and ep.get("provider") not in {"treg", "wrangle"}
and eid not in arena.EXCLUDED and not ep.get("async") and ".bulk" not in eid}
fingerprint = {eid: {"path": ep.get("path"), "method": ep.get("method"),
"adapter": cat.adapters[eid].__dict__, "contract": cat.contracts[ep["capability"]].__dict__}
for eid, ep in sorted(endpoints.items())}
version = hashlib.sha256(json.dumps([rules.RULES_VERSION, arena.VERSION, fingerprint], sort_keys=True, default=str).encode()).hexdigest()
return cat, endpoints, version
def _insert(db, model):
if db.bind.dialect.name == "postgresql":
from sqlalchemy.dialects.postgresql import insert
else:
from sqlalchemy.dialects.sqlite import insert
return insert(model)
def _empty(at):
return {"version": 2, "status": "warming", "since": (at - timedelta(days=WINDOW_DAYS)).isoformat() + "Z",
"until": at.isoformat() + "Z", "updated_at": None, "refresh_seconds": REFRESH_SECONDS, "rows": []}
async def public_snapshot(session_factory=session_maker):
# Two bounded snapshot reads; never scan raw audit evidence on a page request.
from .arena_verification_insights import public_snapshot as verification_snapshot
_, _, version = _catalog()
async with session_factory() as db:
state = await db.get(ArenaInsightState, version)
payload = dict(state.payload) if state and state.payload else _empty(now())
payload["verification"] = await verification_snapshot(db)
return payload
def _decode(raw, enc):
if raw is None:
return None
try:
if enc == "zlib":
decoder = zlib.decompressobj()
raw = decoder.decompress(raw, MAX_BODY_BYTES + 1)
if not decoder.eof or decoder.unconsumed_tail:
return None
if len(raw) > MAX_BODY_BYTES:
return None
return json.loads(raw)
except (ValueError, UnicodeError, zlib.error, TypeError):
return None
async def _evidence(db, records):
keys = {r.archive_key_hash for r in records if r.archive_key_hash}
if not keys:
return {}
# Read request metadata separately so explicit 404s can still be classified without a body.
ak = (await db.execute(select(ArchiveKey).where(ArchiveKey.key_hash.in_(keys)))).scalars().all()
keymap = {k.key_hash: k for k in ak}
pairs = [(keymap[r.archive_key_hash].id, r.archive_content_hash) for r in records
if r.archive_key_hash in keymap and r.archive_content_hash]
# Only the newest carrier for each exact key/content pair; never substitute the latest answer.
ranked = select(ArchiveSnapshot.id, func.row_number().over(
partition_by=(ArchiveSnapshot.key_id, ArchiveSnapshot.content_hash),
order_by=ArchiveSnapshot.version.desc()).label("n")).where(
tuple_(ArchiveSnapshot.key_id, ArchiveSnapshot.content_hash).in_(pairs)).subquery()
carrier = aliased(ArchiveSnapshot)
snaps = (await db.execute(select(ArchiveSnapshot, carrier.body, carrier.enc)
.join(ranked, ranked.c.id == ArchiveSnapshot.id)
.outerjoin(carrier, carrier.id == ArchiveSnapshot.body_of).where(ranked.c.n == 1))).all()
bodies = {(s.key_id, s.content_hash): _decode(s.body if s.body is not None else body,
s.enc if s.body is not None else enc) for s, body, enc in snaps}
result = {}
for r in records:
k = keymap.get(r.archive_key_hash)
if k is None or k.endpoint_id != r.endpoint_id:
continue
query = {name: values[-1] for name, values in parse_qs(urlsplit(k.req_url).query, keep_blank_values=True).items()}
body = _decode(k.req_body, None) if k.req_body else {}
result[r.id] = (query, body or {}, bodies.get((k.id, r.archive_content_hash)))
return result
async def _aggregate(db, version, until):
O = ArenaObservation
where = (O.version == version, O.created_at >= until - timedelta(days=WINDOW_DAYS),
O.created_at < until, O.input != "unknown")
columns = (O.task, O.input, O.endpoint)
counts = (await db.execute(select(*columns, O.category, func.count()).where(*where)
.group_by(*columns, O.category))).all()
grouped = {}
for task, inp, endpoint, category, count in counts:
row = grouped.setdefault((task, inp, endpoint), {"task": task, "input": inp, "endpoint": endpoint,
"provider": endpoint.split(".")[0], "hits": 0, "misses": 0, "unique_requests": 0,
"unique_rate": None, "median_hit_ms": None, "timed_hits": 0, "excluded": 0, "unresolved": 0})
if category in ("hit", "miss"):
row["hits" if category == "hit" else "misses"] += count
elif category.startswith("excluded_"):
row["excluded"] += count
else:
row["unresolved"] += count
# A failed retry must not erase an earlier evaluable result. Deduplicate only decided calls.
latest = select(*columns, O.category, func.row_number().over(
partition_by=(O.endpoint, O.input, O.request_hash), order_by=(O.created_at.desc(), O.id.desc())).label("n")
).where(*where, O.category.in_(["hit", "miss"]), O.request_hash != "").subquery()
unique = (await db.execute(select(latest.c.task, latest.c.input, latest.c.endpoint, func.count(),
func.sum(case((latest.c.category == "hit", 1), else_=0))).where(latest.c.n == 1)
.group_by(latest.c.task, latest.c.input, latest.c.endpoint))).all()
for task, inp, endpoint, total, hits in unique:
grouped[(task, inp, endpoint)].update(unique_requests=total, unique_rate=round(100 * hits / total, 2))
timed = (*where, O.category == "hit", O.duration_ms.is_not(None), O.duration_ms >= 0)
if db.bind.dialect.name == "postgresql":
durations = (await db.execute(select(*columns, func.count(),
func.percentile_cont(0.5).within_group(O.duration_ms)).where(*timed).group_by(*columns))).all()
else:
# SQLite has no percentile_cont. Local/test databases use the same exact median in Python.
values = {}
for task, inp, endpoint, ms in (await db.execute(select(*columns, O.duration_ms).where(*timed))).all():
values.setdefault((task, inp, endpoint), []).append(ms)
durations = [(*k, len(v), statistics.median(v)) for k, v in values.items()]
for task, inp, endpoint, count, median in durations:
grouped[(task, inp, endpoint)].update(timed_hits=count, median_hit_ms=median)
first, last = (await db.execute(select(func.min(O.created_at), func.max(O.created_at))
.where(*where))).one()
return {**_empty(until), "status": "ready", "updated_at": now().isoformat() + "Z",
"observed_since": first.isoformat() + "Z" if first else None,
"observed_until": last.isoformat() + "Z" if last else None,
"rows": sorted(grouped.values(), key=lambda r: (r["task"], r["input"], r["endpoint"]))}
async def collect_batch(session_factory=background_session_maker):
"""One bounded transaction; serialized cursor across control workers. Returns True on backlog."""
cat, endpoints, version = _catalog()
current = now()
async with session_factory() as db:
stmt = _insert(db, ArenaInsightState).values(id=version, cursor=0,
scan_until=current - timedelta(seconds=60), updated_at=None, payload={})
await db.execute(stmt.on_conflict_do_nothing(index_elements=["id"]))
state = (await db.execute(select(ArenaInsightState).where(ArenaInsightState.id == version).with_for_update())).scalar_one()
if state.updated_at:
if (current - state.updated_at).total_seconds() < REFRESH_SECONDS:
await db.commit()
return False
# Freeze a fresh cutoff for this cycle, while keeping the prior public payload.
state.scan_until = current - timedelta(seconds=60)
state.updated_at = None
until = state.scan_until
records = (await db.execute(select(CallRecord).where(CallRecord.id > state.cursor,
CallRecord.created_at >= until - timedelta(days=WINDOW_DAYS), CallRecord.created_at < until,
CallRecord.endpoint_id.in_(endpoints)).order_by(CallRecord.id).limit(BATCH_SIZE))).scalars().all()
evidence = await _evidence(db, records)
observations = []
for record in records:
ep = endpoints[record.endpoint_id]
row = {name: getattr(record, name) for name in ("kind", "cached", "credential_tier", "refused_by",
"error_request", "error_response", "status_code", "path", "method", "endpoint_id", "params_hash")}
try:
inp, category = rules.classify_record(row, ep, cat.adapters[record.endpoint_id],
cat.contracts[ep["capability"]], evidence.get(record.id))
except (ValueError, TypeError, KeyError, IndexError, AttributeError):
# A malformed archived request/response must not pin the cursor forever.
inp, category = "unknown", "unresolved_evidence"
if category in ("hit", "miss") and not record.params_hash:
category = "unresolved_identity"
values = dict(id=record.id, version=version, endpoint=record.endpoint_id, task=ep["capability"],
input=inp, request_hash=record.params_hash or "", category=category,
duration_ms=record.duration_ms, created_at=record.created_at)
observations.append(values)
if observations:
stmt = _insert(db, ArenaObservation).values(observations)
await db.execute(stmt.on_conflict_do_update(index_elements=["id"], set_={k: getattr(stmt.excluded, k) for k in observations[0] if k != "id"}))
if records:
state.cursor = records[-1].id
backlog = len(records) == BATCH_SIZE
if not backlog:
state.payload = await _aggregate(db, version, until)
state.updated_at = current
# Revisit recent evidence for delayed archive writes / concurrent audit commits.
rewind = (await db.execute(select(func.min(CallRecord.id)).where(
CallRecord.created_at >= until - timedelta(minutes=10)))).scalar_one()
state.cursor = max(0, rewind - 1) if rewind else state.cursor
state.scan_until = current - timedelta(seconds=60)
await db.execute(delete(ArenaObservation).where(ArenaObservation.created_at < until - timedelta(days=WINDOW_DAYS)))
db.add(state)
await db.commit()
return backlog
async def worker():
while True:
try:
backlog = await collect_batch()
except asyncio.CancelledError:
raise
except Exception:
log.exception("Arena observation refresh failed; retaining the last database aggregate")
backlog = False
await asyncio.sleep(0.25 if backlog else REFRESH_SECONDS)
@@ -0,0 +1,107 @@
"""Validate and publish aggregate audit snapshots; no evidence scans or vendor calls."""
import hashlib
import json
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import select
from ..domain.catalog import store
from ..infra.db import session_maker
from ..models import ArenaVerificationSnapshot
from ..timeutil import utcnow_naive as now
class AuditRow(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
task: Literal["people.email.find", "people.phone.find"]
endpoint: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,160}$")
input: Literal["name_domain", "linkedin_url", "email"]
baseline_rate: float | None = Field(default=None, ge=0, le=100)
baseline_n: int = Field(ge=0, le=100_000_000, strict=True)
sample_n: int = Field(ge=1, le=10_000_000, strict=True)
passed_n: int = Field(ge=0, le=10_000_000, strict=True)
unresolved_n: int = Field(default=0, ge=0, le=10_000_000, strict=True)
method: Literal["email_verifier_consensus", "phone_format"]
verifiers: list[str] = Field(min_length=1, max_length=10)
@model_validator(mode="after")
def consistent(self):
cat = store.load()
endpoint = cat.by_id.get(self.endpoint)
if not endpoint or endpoint.get("capability") != self.task:
raise ValueError("Endpoint must match the audited task")
if self.passed_n + self.unresolved_n > self.sample_n:
raise ValueError("Passed count exceeds sample")
expected = "email_verifier_consensus" if self.task == "people.email.find" else "phone_format"
if self.method != expected:
raise ValueError("Verification method does not match task")
capability = "people.email.verify" if self.task == "people.email.find" else "people.phone.verify"
providers = {ep.get("provider") for ep in cat.by_id.values() if ep.get("capability") == capability}
if len(set(self.verifiers)) != len(self.verifiers) or not set(self.verifiers) <= providers - {"treg"}:
raise ValueError("Unrecognized or duplicate verifier")
if self.method == "email_verifier_consensus" and len(self.verifiers) < 2:
raise ValueError("Consensus requires two verifiers")
return self
class AuditSnapshot(BaseModel):
model_config = ConfigDict(extra="forbid")
version: Literal[1]
run_id: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
baseline_since: datetime
baseline_until: datetime
sample_since: datetime
sample_until: datetime
checked_at: datetime
rows: list[AuditRow] = Field(max_length=500)
@model_validator(mode="after")
def ordered(self):
dates = [self.baseline_since, self.baseline_until, self.sample_since, self.sample_until, self.checked_at]
if any(d.tzinfo is None for d in dates):
raise ValueError("Dates must have a timezone")
if self.baseline_since > self.baseline_until or self.baseline_until > self.checked_at or self.sample_since > self.sample_until or self.sample_until > self.checked_at:
raise ValueError("Invalid source date range")
keys = [(r.task, r.endpoint, r.input) for r in self.rows]
if len(set(keys)) != len(keys):
raise ValueError("Duplicate endpoint/input aggregate")
return self
def build_snapshot(data):
audit = AuditSnapshot.model_validate(data)
payload = audit.model_dump(mode="json")
for row in payload["rows"]:
eligible = row["method"] == "email_verifier_consensus" and row["baseline_rate"] is not None and row["baseline_n"] >= 20 and row["sample_n"] >= 20
row["rate"] = round(row["baseline_rate"] * row["passed_n"] / row["sample_n"], 2) if eligible else None
row["estimate"] = True
row["small_sample"] = row["sample_n"] < 100
# Keep the historical projection for older consumers; validity has a
# different denominator and never depends on lookup coverage.
row["checked_n"] = row["sample_n"] - row["unresolved_n"]
eligible_validity = row["method"] == "email_verifier_consensus" and row["checked_n"] >= 20
row["validity_rate"] = round(100 * row["passed_n"] / row["checked_n"], 2) if eligible_validity else None
return payload
async def publish_snapshot(data, session_factory=session_maker):
"""One immutable publication per run. Reimport is a no-op; conflicting data fails."""
payload = build_snapshot(data)
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
async with session_factory() as db:
existing = await db.get(ArenaVerificationSnapshot, payload["run_id"])
if existing:
if existing.source_digest != digest:
raise ValueError("Run already published with different contents")
return False
db.add(ArenaVerificationSnapshot(id=payload["run_id"], source_digest=digest, published_at=now(), payload=payload))
await db.commit()
return True
async def public_snapshot(db):
row = (await db.execute(select(ArenaVerificationSnapshot).order_by(
ArenaVerificationSnapshot.published_at.desc(), ArenaVerificationSnapshot.id.desc()).limit(1))).scalar_one_or_none()
return row.payload if row else None
+12 -8
View File
@@ -237,7 +237,7 @@ async def start_email_login(email: str, client_ip: str) -> dict:
return result
async def verify_email_login(email: str, code: str) -> VerifiedEmail:
async def verify_email_login(email: str, code: str, *, entry_surface: str = "") -> VerifiedEmail:
"""Consume an email OTP and return both CLI and browser credentials for the proven identity."""
email = _norm_email(email)
async with database.session_maker() as db:
@@ -254,8 +254,9 @@ async def verify_email_login(email: str, code: str) -> VerifiedEmail:
await db.commit()
raise EmailAuthError("invalid_code")
await ratestore.kv_pop(db, OTP_NS, email)
created: set[int] = set()
try:
user = await signup.find_or_create_user(db, email, door="otp_verify")
user = await signup.find_or_create_user(db, email, door="otp_verify", created=created)
except signup.MachineIdentityError as exc:
raise EmailAuthError("machine_identity") from exc
except signup.BlockedEmailError as exc: # a code minted before the domain was listed
@@ -263,6 +264,7 @@ async def verify_email_login(email: str, code: str) -> VerifiedEmail:
if user.suspended:
raise EmailAuthError("suspended")
await db.commit()
signup.track_signup(user, created, "email", entry_surface)
token = sess.make_identity(user.id, user.token_version)
session_cookie = sess.make_session(user.id, token_version=user.token_version)
return VerifiedEmail(token=token, email=user.email, session_cookie=session_cookie)
@@ -431,10 +433,11 @@ def start_google_login(cli: str, callback_base: Callable[[], str]) -> SocialLogi
return SocialLoginStart(state=state, url=url)
async def _provision_social_user(email: str, state: str, door: str) -> SocialLoginProof:
async def _provision_social_user(email: str, state: str, door: str, entry_surface: str = "") -> SocialLoginProof:
created: set[int] = set()
async with database.session_maker() as db:
try:
user = await signup.find_or_create_user(db, email, door=door) # first login = registration (user only; no auto org)
user = await signup.find_or_create_user(db, email, door=door, created=created) # first login = registration (user only; no auto org)
except signup.MachineIdentityError as exc:
raise SocialLoginError("machine_identity") from exc
except signup.BlockedEmailError as exc: # a Google/GitHub account on a listed domain
@@ -442,13 +445,14 @@ async def _provision_social_user(email: str, state: str, door: str) -> SocialLog
if user.suspended: # a banned account may prove its email but must not receive a live session
raise SocialLoginError("suspended")
await db.commit()
signup.track_signup(user, created, door, entry_surface)
# Browser session OR `treg login` handshake — both go through the /login team picker now.
return SocialLoginProof(user=user, cli_state=_cli_states.pop(state, None))
async def complete_github_login(
client_factory: Callable[[], Any], code: str, state: str, cookie_state: str,
callback_base: Callable[[], str],
callback_base: Callable[[], str], *, entry_surface: str = "",
) -> SocialLoginProof:
if not code or not state or state != cookie_state:
raise SocialLoginError("bad_state")
@@ -478,12 +482,12 @@ async def complete_github_login(
except Exception as exc: # noqa: BLE001
print(f"[auth] github callback error: {exc}") # keep internals server-side, not in the response
raise SocialLoginError("callback_failed") from exc
return await _provision_social_user(email, state, "github")
return await _provision_social_user(email, state, "github", entry_surface)
async def complete_google_login(
client_factory: Callable[[], Any], code: str, state: str, cookie_state: str,
callback_base: Callable[[], str],
callback_base: Callable[[], str], *, entry_surface: str = "",
) -> SocialLoginProof:
if not code or not state or state != cookie_state:
raise SocialLoginError("bad_state")
@@ -515,7 +519,7 @@ async def complete_google_login(
except Exception as exc: # noqa: BLE001
print(f"[auth] google callback error: {exc}") # keep internals server-side, not in the response
raise SocialLoginError("callback_failed") from exc
return await _provision_social_user(email, state, "google")
return await _provision_social_user(email, state, "google", entry_surface)
async def current_identity(x_treg_token: str, session_cookie: str) -> CurrentIdentity:
+19 -9
View File
@@ -380,6 +380,7 @@ async def _pm_and_fingerprint(pi_id: str) -> tuple[str | None, str | None]:
# ---- manual top-up (Stripe-hosted Checkout) ----------------------------------------------------
async def create_topup_checkout(
db: AsyncSession, org: Org, amount_usd, *, return_base: str = "", email: str = "",
entry_surface: str = "", checkout_source: str = "",
) -> dict:
"""Start a hosted Checkout for a one-off top-up and return `{url, session_id, amount_micro}`.
@@ -409,6 +410,8 @@ async def create_topup_checkout(
Currency is pinned to USD explicitly — the Stripe account's own default is AUD, and inheriting it
would charge a number the ledger would then credit as dollars.
"""
attribution = {"entry_surface": analytics.funnel_surface(entry_surface),
"checkout_source": analytics.funnel_surface(checkout_source)}
amount = validate_topup_usd(amount_usd)
amount_micro = usd_to_micro(amount)
customer = await get_or_create_customer(db, org, email=email)
@@ -435,7 +438,7 @@ async def create_topup_checkout(
# the webhook may arrive as `payment_intent.succeeded`, which carries the PI's metadata and
# not the session's — the credit path must work from either event.
"setup_future_usage": "off_session",
"metadata": {"treg_org_id": str(org.id), "treg_kind": "topup", "treg_auto": "0"},
"metadata": {"treg_org_id": str(org.id), "treg_kind": "topup", "treg_auto": "0", **attribution},
**({"receipt_email": email} if email else {}),
},
# The expensable document (see docstring). `invoice_data` carries the same org identity as the
@@ -457,7 +460,7 @@ async def create_topup_checkout(
allow_promotion_codes=True,
# Idempotent per (org, amount, minute): a double-clicked "Add funds" reuses the same session
# instead of opening a second one the payer might also complete.
metadata={"treg_org_id": str(org.id), "treg_kind": "topup"},
metadata={"treg_org_id": str(org.id), "treg_kind": "topup", **attribution},
integration_identifier=INTEGRATION_ID,
success_url=f"{base}/app?topup=success#billing",
cancel_url=f"{base}/app?topup=cancelled#billing",
@@ -911,7 +914,7 @@ async def handle_webhook_event(db: AsyncSession, event: dict) -> dict:
async def _credit(db: AsyncSession, org_id: int, amount_micro: int, pi_id: str, *, auto: bool,
fingerprint: str | None = None) -> dict:
fingerprint: str | None = None, attribution: dict | None = None) -> dict:
"""Credit a paid PaymentIntent to the org's balance, once. Emails a receipt only when this
delivery is the one that actually moved money — a redelivery must not re-notify.
@@ -921,11 +924,13 @@ async def _credit(db: AsyncSession, org_id: int, amount_micro: int, pi_id: str,
# "Has this PaymentIntent already been credited?" is asked BEFORE the credit rather than inferred
# from a balance change afterwards — a concurrent reserve moves the balance too, and mistaking that
# for a fresh credit would email a receipt on every webhook redelivery.
attribution = {key: analytics.funnel_surface((attribution or {}).get(key, ""))
for key in ("entry_surface", "checkout_source")}
already = (await db.execute(
select(CreditBlock.id).where(CreditBlock.stripe_payment_intent == pi_id)
)).first() is not None
block = await ledger.topup(db, org_id, amount_micro, pi_id,
meta={"auto": auto, "source": "stripe"})
meta={"auto": auto, "source": "stripe", **attribution})
block_id = block.id # captured now: a later rollback (the ad-conversion except below) expires
# every object this session is tracking, `block` included, and reading an
# expired attribute outside an awaited call raises MissingGreenlet.
@@ -970,7 +975,7 @@ async def _credit(db: AsyncSession, org_id: int, amount_micro: int, pi_id: str,
# the `team` group is what makes org-level revenue exact. capture() never raises — an
# exception here would 500 the webhook and make Stripe retry an already-credited payment.
analytics.capture(to or f"org:{org_id}", "topup_completed",
{"amount_micro": amount_micro,
{**attribution, "amount_micro": amount_micro,
"amount_usd": amount_micro / 1_000_000, # display-only, never computed against
"auto": auto, "balance_after_micro": after,
"bonus_micro": bonus_micro, "bonus_pct": bonus_pct,
@@ -1053,7 +1058,8 @@ async def _on_checkout_completed(db: AsyncSession, session: dict) -> dict:
# the abuse gate that stops one card claiming a bounty under a second email. Same single retrieve
# that already ran here for the saved-card id, just moved above the credit.
pm_id, fingerprint = await _pm_and_fingerprint(pi_id)
result = await _credit(db, org_id, amount_micro, pi_id, auto=False, fingerprint=fingerprint)
result = await _credit(db, org_id, amount_micro, pi_id, auto=False, fingerprint=fingerprint,
attribution=session.get("metadata") or {})
# The Checkout saved the card (setup_future_usage); remember it so auto-top-up can be armed
# without asking for a second card entry.
await _set_default_pm(db, org_id, pm_id)
@@ -1073,7 +1079,7 @@ async def _on_payment_succeeded(db: AsyncSession, pi: dict) -> dict:
amount_micro = cents_to_micro(pi.get("amount_received") or pi.get("amount") or 0)
if amount_micro <= 0:
return {"handled": False, "reason": "zero amount"}
result = await _credit(db, org_id, amount_micro, pi["id"], auto=meta.get("treg_auto") == "1")
result = await _credit(db, org_id, amount_micro, pi["id"], auto=meta.get("treg_auto") == "1", attribution=meta)
pm = pi.get("payment_method")
await _set_default_pm(db, org_id, pm if isinstance(pm, str) else (pm or {}).get("id"))
return result
@@ -1259,13 +1265,15 @@ async def get_billing_state(org_id: int) -> dict:
async def start_topup(
org_id: int, amount_usd: float | None, *, return_base: str, email: str,
entry_surface: str = "", checkout_source: str = "",
) -> dict:
async with _db.session_maker() as db:
org = await _journey_org(db, org_id)
amount = amount_usd if amount_usd is not None else await next_default_usd(db, org.id)
try:
out = await create_topup_checkout(
db, org, amount, return_base=return_base, email=email)
db, org, amount, return_base=return_base, email=email,
entry_surface=entry_surface, checkout_source=checkout_source)
except BillingNotConfigured as e:
raise BillingJourneyError("not_configured", str(e)) from e
except TopupRejected as e:
@@ -1273,7 +1281,9 @@ async def start_topup(
# The one place the actual payer's identity exists — the webhook that later credits the
# balance is org-scoped, so the started/completed funnel joins on the team group.
analytics.capture(email, "topup_started",
{"amount_usd": amount, "org": org.slug},
{"amount_usd": amount, "org": org.slug,
"entry_surface": analytics.funnel_surface(entry_surface),
"checkout_source": analytics.funnel_surface(checkout_source)},
groups={"team": org.slug})
return out
+41
View File
@@ -0,0 +1,41 @@
"""Which optional hint, if any, rides on a successful call's answer.
Decided once, server-side, after the upstream has answered and before the body streams; every
surface (plain HTTP, CLI, both MCP transports) only translates the resulting `X-Treg-Hint` header.
A review invitation asks the agent for work, so it is rationed twice: `hints.sampled` picks WHICH
calls qualify (deterministic, so `POST /reviews` can recompute eligibility), and a per-team hourly
budget in the shared store caps HOW MANY a team is asked about, whatever its call volume. A team
hammering one endpoint 2,000 times in an hour is asked `review_budget_per_hour` times, not 100.
The feedback hint asks for nothing and stays purely sampled.
"""
from __future__ import annotations
from typing import Literal
from ... import hints
from ...config import get_settings
from ...infra import kv
from .types import CallContext
Kind = Literal["review", "feedback"]
BUDGET_WINDOW_S = 3600
def budget_key(org_id: int) -> str:
return f"review-budget:{org_id}"
async def invitation(context: CallContext, status: int, *, replayed: bool) -> Kind | None:
"""No database, no body access. Phase 1 invites reviews only on direct catalog calls served
on treg's platform key; an own tool never qualifies even when its name matches an endpoint."""
if not (200 <= status < 300) or replayed or context.cached:
return None
marketplace = context.marketplace
platform = marketplace is not None and getattr(marketplace, "tier", None) == "platform"
if platform and hints.sampled("review", context.call_ref):
if await kv.store().take(budget_key(context.input.caller.org_id),
get_settings().review_budget_per_hour, BUDGET_WINDOW_S):
return "review"
if hints.sampled("feedback", context.call_ref):
return "feedback"
return None
+11 -1
View File
@@ -554,6 +554,10 @@ def _marketplace_pricing(
"""
if not cost:
return 0, 0
if provider == "sumble" and cost.get("sumble"):
from . import sumble
credit = _usd_to_micro(float(cost.get("usd") or 0) * int(cost.get("per") or 1))
return sumble.estimate(cost["sumble"], _json_object(body)) * credit, credit
if provider == "contactout":
from . import contactout
request = _json_object(body) if body else dict(query.multi_items())
@@ -879,7 +883,10 @@ def _marketplace_upstream(
# Agents often pass `siteUrl` straight from GSC's sites list, where it may already be
# encoded. Preserve a value containing a real %HH escape; otherwise encode it exactly once.
# A literal/invalid percent sequence has no valid escape and therefore becomes `%25`.
rendered = value if _VALID_PERCENT_ESCAPE_RE.search(value) else quote(value, safe="")
# @ is a legal character inside a path segment (RFC 3986 pchar), not a path/query
# delimiter. Email-path APIs may validate it before percent-decoding (Tomba does).
# Keep it literal; slashes, ?, # and other delimiters still need escaping.
rendered = value if _VALID_PERCENT_ESCAPE_RE.search(value) else quote(value, safe="@")
path = path.replace("{%s}" % name, rendered)
consumed.add(name)
required = [k for k, v in (inp.get("queryParams") or {}).items()
@@ -1357,6 +1364,9 @@ async def _resolve_marketplace_call(
async_owner_call_id = None
if cost is not None:
_enforce_platform_request(ep, body)
if service == "sumble":
from . import sumble
sumble.enforce(ep, _strict_json_object(body, ep["id"]) if has_body else {}, query)
if service == "contactout":
# Fixed catalog splits must not silently fall into ContactOut's personal+work default.
inputs = ep.get("input") or {}
+6
View File
@@ -241,6 +241,12 @@ def _observed_cost_micro(mk: MarketplaceCall, body: bytes, headers=None) -> int
except (InvalidOperation, ValueError, OverflowError):
pass
# Missing or invalid charge evidence leaves the normal miss/base rules in force.
if provider == "sumble":
credits = doc.get("credits_used")
# The request-time unit freezes the credit rate, including legitimate zero usage.
if type(credits) is int and credits >= 0:
return credits * mk.unit_micro
return None
if provider == "quickenrich":
return _quickenrich_cost_micro(mk, doc)
if provider == "aviato" and mk.endpoint_id == "aviato.companies.enrich.bulk":
+108
View File
@@ -0,0 +1,108 @@
"""Sumble platform request boundaries and estimates; BYOK never enters the guard.
Rates and selectable attributes live in the catalog. Responses remain provider-native.
Workspace queries and expanding selections need BYOK until their bounds are reviewed.
"""
from __future__ import annotations
from .types import ResolutionFailed
def _size(value, default: int) -> int:
return value if type(value) is int and value > 0 else default
def estimate(rule: dict, document: dict) -> int:
mode = rule["mode"]
if mode == "single":
return 1
if mode == "results":
return rule["reserve_results"]
records = document.get(rule["records"])
n = len(records) if isinstance(records, list) else _size(document.get("limit"), rule.get("default_limit", 1))
if mode == "lookup":
return (n + rule["block_size"] - 1) // rule["block_size"]
select = document.get("select")
select = select if isinstance(select, dict) else {}
attrs = select.get("attributes", [])
paid = (sum(a not in rule["free_attributes"] for a in attrs)
if isinstance(attrs, list) else len(rule["safe_attributes"]))
entities = select.get("entities", [])
metrics = sum(len(e["metrics"]) for e in entities
if isinstance(e, dict) and isinstance(e.get("metrics"), list)) if isinstance(entities, list) else 0
return n * (rule["base"] + paid * rule["attribute"] + metrics * rule["metric"])
def enforce(ep: dict, document: dict, query) -> None:
def require(ok, message):
if not ok:
raise ResolutionFailed("catalog_parameter_invalid", status_code=400, detail={
"error": "catalog_parameter_invalid", "endpoint_id": ep["id"],
"message": message + " Use your own Sumble key for other documented options.",
})
def keys(value, allowed, label):
require(isinstance(value, dict) and not (set(value) - set(allowed)),
f"Platform {label} accepts only {', '.join(sorted(allowed))}.")
def strings(value, label, maximum=1000):
require(isinstance(value, list) and 1 <= len(value) <= maximum
and all(isinstance(x, str) and x for x in value),
f"Platform {label} requires 1–{maximum} nonempty strings.")
allowed_query = {"organization_id"} if ep["id"].endswith(".techs") else {"query", "limit"} if ep["id"].endswith("documentation.search") else set()
require(all(k in allowed_query for k, _ in query.multi_items()), "Unsupported platform query parameter.")
require(ep.get("verified"), "This operation has not been verified for platform access.")
rule = (ep.get("cost") or {}).get("sumble")
if not rule:
require(not document, "This operation does not accept a platform request body.")
return
mode = rule["mode"]
if mode == "results":
require(not document, "This operation does not accept a platform request body.")
return
if mode == "single":
keys(document, {"query"}, "technology search")
require(isinstance(document.get("query"), str) and document["query"], "Specify a technology query.")
return
record_key = rule["records"]
if mode == "lookup":
keys(document, {record_key}, "lookup")
strings(document.get(record_key), record_key, rule["max_records"])
return
keys(document, {record_key, "filter", "select", "limit", "offset", "order_by_column", "order_by_direction"}, "request")
records = document.get(record_key)
filt = document.get("filter")
require((records is not None) != (filt is not None), "Specify either a record list or an organization_ids filter.")
if records is not None:
require(isinstance(records, list) and 1 <= len(records) <= rule["max_records"], "Specify a nonempty record list within the documented limit.")
for record in records:
if record_key == "teams":
require(type(record) is int and record > 0, "Team identifiers must be positive integers.")
else:
keys(record, {"id", "slug", "name", "url", "location"} if record_key == "organizations" else {"job_id"}, "record")
require(bool(record), "Empty records are not supported.")
else:
require(record_key != "organizations", "Platform organization enrichment requires explicit organizations; advanced-query search needs BYOK.")
keys(filt, {"organization_ids", "since"} if record_key == "teams" else {"organization_ids"}, "filter")
ids = filt.get("organization_ids")
require(isinstance(ids, list) and 1 <= len(ids) <= rule["max_records"] and all(type(x) is int and x > 0 for x in ids), "Specify positive organization_ids.")
for field, default, low, high in [("limit", rule["default_limit"], 1, rule["max_limit"]), ("offset", 0, 0, 10000)]:
value = document.get(field, default)
require(type(value) is int and low <= value <= high, f"{field} must be an integer from {low} to {high}.")
if "order_by_column" in document:
require(record_key == "teams" and document["order_by_column"] in {"jobs_count", "first_activity", "last_activity"}, "Only account-independent team sorting is supported.")
if "order_by_direction" in document:
require(document["order_by_direction"] in {"ASC", "DESC"}, "Sort direction must be ASC or DESC.")
select = document.get("select")
keys(select, {"attributes", "entities"} if record_key == "organizations" else {"attributes"}, "select")
attrs = select.get("attributes", [])
require(isinstance(attrs, list) and all(isinstance(a, str) and a in rule["safe_attributes"] for a in attrs), "Select explicit account-independent attributes; all, ICP and CRM fields are not supported.")
entities = select.get("entities", [])
require(isinstance(entities, list), "entities must be an array.")
for entity in entities:
keys(entity, {"type", "term", "metrics", "granularity", "since"}, "entity")
require(entity.get("type") in {"technology", "job_function", "project", "technology_category"}, "Only named technology, job function, project and aggregate category metrics are supported.")
require(isinstance(entity.get("term"), str) and bool(entity["term"]), "Specify an entity term.")
require(entity.get("granularity") == "aggregate" if entity.get("type") == "technology_category" else "granularity" not in entity, "Exploded categories are not supported on the platform key.")
strings(entity.get("metrics"), "metrics")
+12 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from .. import adsconv, health, sandbox as demo_sandbox
from .. import adsconv, analytics, health, sandbox as demo_sandbox
from ..domain import money as ledger
from ..domain import referrals
from ..domain.governance.teams import _make_org_membership, _slugify
@@ -51,7 +51,7 @@ def blocked_email(email: str, door: str) -> bool:
return True
async def find_or_create_user(db: AsyncSession, email: str, *, door: str = "login") -> User:
async def find_or_create_user(db: AsyncSession, email: str, *, door: str = "login", created: set[int] | None = None) -> User:
"""Find a user by email, else register them — the user ONLY, **no auto personal org**. The shared
core of every identity door (GitHub / Google / email OTP). A brand-new user therefore lands with
zero teams and is asked to NAME + CREATE their first team (the dashboard's mandatory welcome, or
@@ -78,9 +78,19 @@ async def find_or_create_user(db: AsyncSession, email: str, *, door: str = "logi
except IntegrityError:
await db.rollback() # another worker just created this same new user — reuse theirs
return (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
if created is not None:
created.add(user.id)
return user
def track_signup(user: User, created: set[int], method: str, entry_surface: str) -> None:
"""Call only after commit. Existing users (including a concurrent signup's loser) emit nothing."""
if user.id in created:
analytics.capture(user.email, "signup_completed", {
"signup_method": method, "entry_surface": analytics.funnel_surface(entry_surface),
})
async def _grant_signup_promo(db: AsyncSession, org: Org) -> None:
"""Give a BRAND-NEW org its promotional balance, so an agent's first call needs no key and no card
(`settings.promo_grant_micro`, $1 by default). Called after the org is committed, from every door
+40 -10
View File
@@ -20,6 +20,7 @@ from starlette.routing import BaseRoute, Mount
from . import adsconv, analytics, archive, audit
from .application.call import route as routed_call
from .application import arena, arena_insights
from . import bootstrap_handlers
from .bootstrap_http import (
_BodyDecodeMiddleware,
@@ -27,6 +28,7 @@ from .bootstrap_http import (
_SecurityHeadersMiddleware,
)
from .config import get_settings
from .infra import kv
from .infra.db import background_session_maker, verify_db
from .infra.catalog_observations import (
CachedEndpointObservationReader,
@@ -41,6 +43,25 @@ RouteKey = tuple[str, tuple[str, ...], str]
# Every HTTP route has one workload owner. A new decorator in api.py fails app creation until its
# key is placed here, so the dataplane cannot silently acquire a management or runner endpoint.
_CONTROL_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
('/enrich-arena', ('GET',), 'enrich_arena_page'),
('/enrich-arena/people-search-bench', ('GET',), 'enrich_arena_page'),
('/enrich-arena/leaderboard', ('GET',), 'enrich_arena_page'),
('/enrich-arena/{asset}', ('GET',), 'enrich_arena_asset'),
('/arena/tasks', ('GET',), 'arena_tasks'),
('/arena/insights', ('GET',), 'arena_insights_data'),
('/arena/plans', ('POST',), 'arena_plan'),
('/arena/runs/{run_id}/start', ('POST',), 'arena_start'),
('/arena/runs', ('GET',), 'arena_history'),
('/arena/runs/{run_id}', ('GET',), 'arena_run'),
('/arena/runs/{run_id}/cancel', ('POST',), 'arena_cancel'),
('/arena/runs/{run_id}/evaluations', ('POST',), 'arena_evaluate'),
('/arena/runs/{run_id}/reveal', ('POST',), 'arena_reveal'),
('/arena/runs/{run_id}/attempts/{attempt_id}/report', ('POST',), 'arena_report'),
('/arena/runs/{run_id}/attempts/{attempt_id}/rating', ('POST',), 'arena_rate'),
('/arena/runs/{run_id}/attempts/{attempt_id}/verification/plan', ('POST',), 'arena_verification_plan'),
('/arena/runs/{run_id}/attempts/{attempt_id}/verification/start', ('POST',), 'arena_verification_start'),
('/arena/runs/{run_id}/attempts/{attempt_id}/plan', ('POST',), 'arena_manual_plan'),
('/arena/runs/{run_id}/attempts/{attempt_id}/start', ('POST',), 'arena_manual_start'),
('/meta', ('GET',), 'meta'),
('/providers.json', ('GET',), 'providers_catalog'),
('/catalog/platforms', ('GET',), 'catalog_platforms'),
@@ -116,6 +137,7 @@ _CONTROL_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
('/privacy', ('GET',), 'privacy_page'),
('/connectors/claude', ('GET',), 'claude_connector_page'),
('/adtrack.js', ('GET',), 'adtrack_js'),
('/agent-setup.js', ('GET',), 'agent_setup_js'),
('/gtag.js', ('GET',), 'gtag_js'),
('/resources', ('GET',), 'resources_page'),
('/grokbot', ('GET',), 'grokbot_page'),
@@ -251,6 +273,7 @@ _CONTROL_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
('/admin/calls', ('GET',), 'admin_calls'),
('/admin/errors', ('GET',), 'admin_errors'),
('/admin/health', ('GET',), 'admin_health'),
('/admin/kv', ('GET',), 'admin_kv'),
('/admin/users/{user_id}/superadmin', ('POST',), 'admin_set_superadmin'),
('/admin/users/{user_id}/suspend', ('POST',), 'admin_suspend_user'),
('/admin/users/{user_id}', ('DELETE',), 'admin_delete_user'),
@@ -281,9 +304,9 @@ _DATAPLANE_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
})
ROLE_BACKGROUND_TASKS: dict[AppRole, tuple[str, ...]] = {
"all": ("treg.adsconv.worker",),
"all": ("treg.adsconv.worker", "treg.application.arena_insights.worker"),
"dataplane": (),
"control": ("treg.adsconv.worker",),
"control": ("treg.adsconv.worker", "treg.application.arena_insights.worker"),
}
ROLE_STARTUP_CHECKS: dict[AppRole, tuple[str, ...]] = {
"all": (
@@ -465,6 +488,10 @@ def _lifespan(role: AppRole):
@asynccontextmanager
async def lifespan(app: FastAPI):
await verify_db()
if kv.configured() and not await kv.store().ping():
# Not fatal: the store's tenants fail closed (infra/kv.py). Loud, because until it
# answers no team receives a review invitation. /admin/kv shows the live state.
logging.getLogger("treg").warning("kv configured but unreachable at startup")
limits = httpx.Limits(max_keepalive_connections=100, max_connections=200)
app.state.http = httpx.AsyncClient(
@@ -492,6 +519,7 @@ def _lifespan(role: AppRole):
if ROLE_BACKGROUND_TASKS[role] and archive.prune_enabled()
else None
)
insights_task = asyncio.create_task(arena_insights.worker()) if role != "dataplane" else None
endpoint_observations = app.state.endpoint_observation_reader
routed_call.configure_endpoint_observation_reader(endpoint_observations)
mcp_reader_bound = role != "control" and _mcp is not None
@@ -509,17 +537,18 @@ def _lifespan(role: AppRole):
yield
finally:
try:
if gauge_task is not None:
gauge_task.cancel()
if ads_task is not None:
ads_task.cancel()
if archive_task is not None:
archive_task.cancel()
if prune_task is not None:
prune_task.cancel()
workers = [task for task in (
gauge_task, ads_task, archive_task, prune_task, insights_task,
) if task is not None]
for task in workers:
task.cancel()
# Wait for session rollback/close before the event loop or shared client closes.
# A second cancellation during asyncio.run() teardown can interrupt that cleanup.
await asyncio.gather(*workers, return_exceptions=True)
if mcp_reader_bound:
_mcp.clear_endpoint_observation_reader(endpoint_observations)
routed_call.clear_endpoint_observation_reader(endpoint_observations)
await arena.shutdown()
await endpoint_observations.aclose()
# analytics LAST: it is the sink the other two report their losses into, and a
# drop during their drain is the one most worth hearing about. Draining it first
@@ -528,6 +557,7 @@ def _lifespan(role: AppRole):
await archive.drain()
await analytics.drain()
await app.state.http.aclose()
await kv.close()
finally:
analytics.remove_fault_handler(fault_handler)
+37 -4
View File
@@ -33,10 +33,13 @@ adapters:
out: {email: email, first_name: first_name, last_name: last_name, verified: "status == 'valid'"}
miss: "email == null"
leadsforge.people.email.find:
accepts: [[first_name, last_name, domain], [full_name, domain], [linkedin_url]]
# Full names still work through the contract's first/last derivation. A mononym must
# not fall back to a variant that maps neither name part and sends only the company.
accepts: [[first_name, last_name, domain], [linkedin_url]]
in: {first_name: body.firstName, last_name: body.lastName, domain: body.companyDomain,
linkedin_url: body.linkedinURL}
out: {email: email, verified: "status == 'succeeded'"}
# Enrichment success does not report mailbox deliverability.
out: {email: email}
miss: "email == null"
findymail.search.name:
accepts: [[full_name, domain], [first_name, last_name, domain]]
@@ -71,7 +74,7 @@ adapters:
in: {linkedin_url: body.linkedinUrl}
const: {body.enrichmentType: {getWorkEmails: true, getPersonalEmails: false, getPhoneNumbers: false},
body.validateEmails: true}
out: {email: "output.profile.emails[0].email", verified: "output.profile.success == true"}
out: {email: "output.profile.emails[0].email"}
miss: "output.profile.emails == []"
# ---- instagram.user.profile ---------------------------------------------------------------
@@ -224,7 +227,7 @@ adapters:
out: {phone: "coalesce(data.e164_format, data.intl_format)", line_type: data.line_type, country_code: data.country_code}
miss: "data.e164_format == null"
leadsforge.people.phone.find:
accepts: [[first_name, last_name, domain], [full_name, domain], [linkedin_url]]
accepts: [[first_name, last_name, domain], [linkedin_url]]
in: {first_name: body.firstName, last_name: body.lastName, domain: body.companyDomain, linkedin_url: body.linkedinURL}
out: {phone: phoneNumber}
miss: "phoneNumber == null"
@@ -257,6 +260,12 @@ adapters:
out: {phone: "results[0].phones[0].number", line_type: "results[0].phones[0].type", country_code: "results[0].phones[0].countryIso2"}
miss: "results[0].phones[0].number == null"
tomba.people.phone.verify:
accepts: [[phone]]
in: {phone: queryParams.phone}
out: {valid: data.valid, phone: data.e164_format, country_code: data.country_code, line_type: data.line_type, carrier: data.carrier}
miss: "data.valid == null"
# ---- people.email.verify ------------------------------------------------------------------
trykitt.people.email.verify:
accepts: [[email]]
@@ -467,6 +476,20 @@ adapters:
out: {results: results, count: "len(results)"}
miss: "results == []"
# Similar-company adapters checked against existing saved upstream fixtures.
tomba.companies.similar:
accepts: [[domain]]
in: {domain: queryParams.domain}
out: {companies: data, count: "len(data)"}
miss: "data == []"
companyenrich.companies.similar:
accepts: [[domain]]
in: {domain: body.domains}
in_expr: {body.domains: "list(domain)"}
const: {body.page: 1, body.pageSize: 10}
out: {companies: items, count: totalItems}
miss: "items == []"
# ---- people.search ------------------------------------------------------------------------
exa.people.search:
accepts: [[q]]
@@ -577,6 +600,16 @@ adapters:
location: "join(company.locations.headquarters.city.name, company.locations.headquarters.country.name)",
linkedin_url: company.socials.linkedin.url}
miss: "company.about.name == null"
sumble.organizations:
test_identity: {domain: stripe.com}
accepts: [[domain], [name]]
in: {}
in_expr: {body.organizations: "list(obj('url', domain, 'name', name))"}
const: {body.select: {attributes: [id, name, url, employee_count, industry]}}
cost_units: "3"
out: {name: "organizations[0].attributes.name", domain: "organizations[0].attributes.url",
employees: "organizations[0].attributes.employee_count", industry: "organizations[0].attributes.industry"}
miss: "matched_count == 0"
hunter.companies.enrich:
accepts: [[domain]]
in: {domain: queryParams.domain}
+3
View File
@@ -49,6 +49,9 @@ endpoints:
queryParams: {website: "stripe.com", preview: true}
verified: '2026-08-24'
example_response: examples/aviato.companies.enrich.json
miss:
status: 404
means: "Company not found or required fields unavailable; no credit charged."
cost:
type: per_success
value: 15
+25
View File
@@ -130,6 +130,20 @@ contracts:
# No phone provider reports a line check, so every hit carries this.
advice_unverified: "This number was found, not confirmed live. Validate it (catalog_search 'verify phone number', e.g. tomba.people.phone.verify) before dialing or texting."
people.phone.verify:
summary: "Validate a phone number's format and numbering-plan validity; not a live-line check"
identity:
- {phone: str}
filters: {}
output:
valid: {type: bool, required: true, note: "numbering-plan validity, not proof the line is active"}
phone: {type: str}
country_code: {type: str}
line_type: {type: str}
carrier: {type: str}
miss: "valid == null"
idempotent: true
people.email.verify:
summary: "Verify an email address — treg picks the provider (own keys first, then cheapest) and names the one that served"
identity:
@@ -300,6 +314,17 @@ contracts:
miss: "name == null"
idempotent: true
companies.similar:
summary: "Find companies similar to a seed domain"
identity:
- {domain: str}
filters: {}
output:
companies: {type: list, required: true}
count: {type: int}
miss: "companies == []"
idempotent: true
companies.search:
summary: "Find companies by a free-text description, a name, an industry, a technology they use, or a domain — provider-native rows in `companies`"
identity:
@@ -0,0 +1,10 @@
{
"results": [
{
"path": "api/lookups.md",
"title": "ID & slug lookups",
"url": "https://docs.sumble.com/api/lookups",
"excerpt": "Resolve human-readable names to the canonical slugs and identifiers that the core data endpoints expect in their filters. Use these to turn a spreadsheet of names \u2014 technologies, projects, or job titles \u2014 into stable identifiers before querying."
}
]
}
@@ -0,0 +1,35 @@
{
"id": "example-request-id",
"credits_used": 1,
"credits_remaining": 9000,
"matched_count": 2,
"results": [
{
"input": "Software Engineer",
"job_function": {
"id": 97,
"slug": "software-engineer",
"name": "Software Engineer"
},
"job_level": {
"id": -1,
"name": "Individual Contributor",
"level_rank": 0
}
},
{
"input": "VP of Sales",
"job_function": {
"id": 90,
"slug": "sales",
"name": "Sales"
},
"job_level": {
"id": 6,
"name": "VP",
"level_rank": 14
}
}
],
"file": null
}
@@ -0,0 +1,27 @@
{
"id": "example-request-id",
"credits_used": 6,
"credits_remaining": 9000,
"jobs": [
{
"job_id": 108173220,
"sumble_url": "https://sumble.com/l/job/TRx0cMq7DcfK",
"attributes": {
"title": "Processing cost Analyst",
"location": "India",
"posted_date": "2026-09-07T20:13:24.854357Z"
}
},
{
"job_id": 108173222,
"sumble_url": "https://sumble.com/l/job/tLO2yHaeRFNd",
"attributes": {
"title": "sales manager, smb",
"location": "Dublin, Leinster, Ireland",
"posted_date": "2026-09-07T20:13:24.854357Z"
}
}
],
"total": 15509,
"source_data_url": "https://sumble.com/l/org/niZxDzclVbH4I/jobs"
}
@@ -0,0 +1,22 @@
{
"id": "example-request-id",
"credits_used": 3,
"credits_remaining": 9000,
"organizations": [
{
"input": {
"url": "stripe.com"
},
"attributes": {
"id": 330446,
"name": "Stripe",
"url": "stripe.com",
"employee_count": 13421,
"industry": "Scientific and Technical Services"
},
"entities": []
}
],
"matched_count": 1,
"total": 1
}
@@ -0,0 +1,391 @@
{
"id": "example-request-id",
"credits_used": 20,
"credits_remaining": 9000,
"organization_id": 1726684,
"organization_slug": "sumble",
"business_functions": [
{
"business_function": "Engineering & R&D",
"categories": [
{
"category_slug": "javascript",
"category_name": "JS Frameworks",
"category_name_full": "Javascript Frameworks",
"technologies": [
{
"name": "React",
"slug": "react",
"job_post_used_count": 3,
"domain": "https://react.dev/"
},
{
"name": "TypeScript",
"slug": "typescript",
"job_post_used_count": 3,
"domain": "https://www.typescriptlang.org/"
}
],
"evidence": [
{
"name": "React",
"job_post_used_count": 3
},
{
"name": "TypeScript",
"job_post_used_count": 3
}
]
},
{
"category_slug": "programming-languages-and-frameworks",
"category_name": "Languages",
"category_name_full": "Programming Languages and Frameworks",
"technologies": [
{
"name": "Python",
"slug": "python",
"job_post_used_count": 5,
"domain": "https://www.python.org/"
}
],
"evidence": [
{
"name": "Python",
"job_post_used_count": 5
},
{
"name": "Microsoft C#",
"job_post_used_count": 1
}
]
},
{
"category_slug": "cloud-and-container-orchestration-platforms",
"category_name": "Containers",
"category_name_full": "Cloud and Container Orchestration Platforms",
"technologies": [
{
"name": "GCP Cloud Run",
"slug": "gcp-cloud-run",
"job_post_used_count": 3,
"domain": "https://cloud.google.com/run"
}
],
"evidence": [
{
"name": "GCP Cloud Run",
"job_post_used_count": 3
}
]
},
{
"category_slug": "cloud-vendor",
"category_name": "Cloud",
"category_name_full": "Cloud Vendor",
"technologies": [
{
"name": "GCP",
"slug": "gcp",
"job_post_used_count": 3,
"domain": "https://cloud.google.com/"
}
],
"evidence": [
{
"name": "GCP",
"job_post_used_count": 3
}
]
}
]
},
{
"business_function": "Data & Analytics",
"categories": [
{
"category_slug": "database-technologies",
"category_name": "Database",
"category_name_full": "Database Technologies",
"technologies": [
{
"name": "PostgreSQL",
"slug": "postgresql",
"job_post_used_count": 2,
"domain": "https://www.postgresql.org/"
},
{
"name": "AlloyDB",
"slug": "alloydb",
"job_post_used_count": 2,
"domain": "https://cloud.google.com/alloydb"
}
],
"evidence": [
{
"name": "PostgreSQL",
"job_post_used_count": 2
},
{
"name": "AlloyDB",
"job_post_used_count": 2
}
]
},
{
"category_slug": "cloud-data-warehouse",
"category_name": "Warehouse",
"category_name_full": "Cloud Data Warehouse",
"technologies": [
{
"name": "Databricks",
"slug": "databricks",
"job_post_used_count": 3,
"domain": "https://databricks.com/"
}
],
"evidence": [
{
"name": "Databricks",
"job_post_used_count": 3
}
]
},
{
"category_slug": "olap",
"category_name": "OLAP",
"category_name_full": "OLAP",
"technologies": [
{
"name": "Databricks",
"slug": "databricks",
"job_post_used_count": 3,
"domain": "https://databricks.com/"
}
],
"evidence": [
{
"name": "Databricks",
"job_post_used_count": 3
}
]
},
{
"category_slug": "data-pipeline-orchestration",
"category_name": "Orchestration",
"category_name_full": "Data Pipeline Orchestration",
"technologies": [
{
"name": "Prefect",
"slug": "prefect",
"job_post_used_count": 2,
"domain": "https://www.prefect.io/"
}
],
"evidence": [
{
"name": "Prefect",
"job_post_used_count": 2
}
]
}
]
},
{
"business_function": "AI & ML",
"categories": [
{
"category_slug": "machine-learning-platforms-and-tools",
"category_name": "ML Platforms",
"category_name_full": "Machine Learning Platforms and Tools",
"technologies": [
{
"name": "vLLM",
"slug": "vllm",
"job_post_used_count": 3,
"domain": "https://github.com/vllm-project/vllm"
},
{
"name": "Databricks",
"slug": "databricks",
"job_post_used_count": 3,
"domain": "https://databricks.com/"
},
{
"name": "HuggingFace",
"slug": "huggingface",
"job_post_used_count": 3,
"domain": "https://huggingface.co/"
}
],
"evidence": [
{
"name": "HuggingFace",
"job_post_used_count": 3
},
{
"name": "Databricks",
"job_post_used_count": 3
},
{
"name": "vLLM",
"job_post_used_count": 3
}
]
},
{
"category_slug": "machine-learning-frameworks",
"category_name": "ML Frameworks",
"category_name_full": "Machine Learning Frameworks",
"technologies": [
{
"name": "PyTorch",
"slug": "pytorch",
"job_post_used_count": 3,
"domain": "https://pytorch.org/"
},
{
"name": "HuggingFace",
"slug": "huggingface",
"job_post_used_count": 3,
"domain": "https://huggingface.co/"
}
],
"evidence": [
{
"name": "PyTorch",
"job_post_used_count": 3
},
{
"name": "HuggingFace",
"job_post_used_count": 3
}
]
},
{
"category_slug": "ml-training",
"category_name": "ML Training",
"category_name_full": "ML Training",
"technologies": [
{
"name": "HuggingFace",
"slug": "huggingface",
"job_post_used_count": 3,
"domain": "https://huggingface.co/"
},
{
"name": "PyTorch",
"slug": "pytorch",
"job_post_used_count": 3,
"domain": "https://pytorch.org/"
}
],
"evidence": [
{
"name": "HuggingFace",
"job_post_used_count": 3
},
{
"name": "PyTorch",
"job_post_used_count": 3
}
]
},
{
"category_slug": "deep-learning-frameworks",
"category_name": "Deep Learning",
"category_name_full": "Deep Learning Frameworks",
"technologies": [
{
"name": "PyTorch",
"slug": "pytorch",
"job_post_used_count": 3,
"domain": "https://pytorch.org/"
}
],
"evidence": [
{
"name": "PyTorch",
"job_post_used_count": 3
}
]
},
{
"category_slug": "inference-serving",
"category_name": "Inference Serving",
"category_name_full": "Inference & Serving",
"technologies": [
{
"name": "vLLM",
"slug": "vllm",
"job_post_used_count": 3,
"domain": "https://github.com/vllm-project/vllm"
}
],
"evidence": [
{
"name": "vLLM",
"job_post_used_count": 3
}
]
}
]
},
{
"business_function": "Product & Design",
"categories": [
{
"category_slug": "design",
"category_name": "Design",
"category_name_full": "Design",
"technologies": [
{
"name": "Figma",
"slug": "figma",
"job_post_used_count": 3,
"domain": "https://www.figma.com"
}
],
"evidence": [
{
"name": "Figma",
"job_post_used_count": 3
}
]
}
]
},
{
"business_function": "IT Security",
"categories": []
},
{
"business_function": "Information Technology",
"categories": []
},
{
"business_function": "Sales",
"categories": []
},
{
"business_function": "Marketing",
"categories": []
},
{
"business_function": "Finance",
"categories": []
},
{
"business_function": "HR",
"categories": []
},
{
"business_function": "Support",
"categories": []
},
{
"business_function": "Legal",
"categories": []
}
]
}
@@ -0,0 +1,16 @@
{
"id": "example-request-id",
"credits_used": 1,
"credits_remaining": 9000,
"matched_count": 1,
"results": [
{
"input": "cloud migration",
"project": {
"id": 1,
"slug": "cloud_migration",
"name": "Cloud Migration"
}
}
]
}
@@ -0,0 +1,41 @@
{
"id": "example-request-id",
"credits_used": 6,
"credits_remaining": 9000,
"teams": [
{
"team_id": 4077202,
"name": "Global Partnerships organization",
"sumble_url": "https://sumble.com/l/team/vIekEXH3SU",
"attributes": {
"breadcrumbs": [
{
"team_id": 4077202,
"name": "Global Partnerships organization",
"slug": "global-partnerships-organization",
"sumble_url": "https://sumble.com/l/team/vIekEXH3SU"
}
],
"jobs_count": 375
}
},
{
"team_id": 4077136,
"name": "Solutions Architecture Team",
"sumble_url": "https://sumble.com/l/team/KOXAaCOu1H",
"attributes": {
"breadcrumbs": [
{
"team_id": 4077136,
"name": "Solutions Architecture Team",
"slug": "solutions-architecture-team",
"sumble_url": "https://sumble.com/l/team/KOXAaCOu1H"
}
],
"jobs_count": 366
}
}
],
"total": 2135,
"source_data_url": "https://sumble.com/l/org/niZxDzclVbH4I/teams"
}
@@ -0,0 +1,88 @@
{
"id": "example-request-id",
"credits_used": 1,
"credits_remaining": 9000,
"matched_count": 1,
"results": [
{
"input": "crm",
"category": {
"slug": "crm",
"name": "Customer Relationship Manager (CRM)",
"name_short": "CRM",
"technologies": [
{
"id": 11,
"slug": "salesforce",
"name": "Salesforce"
},
{
"id": 394,
"slug": "microsoft-dynamics",
"name": "Microsoft Dynamics"
},
{
"id": 122,
"slug": "hubspot",
"name": "HubSpot"
},
{
"id": 10734,
"slug": "oracle-siebel",
"name": "Oracle Siebel"
},
{
"id": 1714,
"slug": "sap-crm",
"name": "SAP CRM"
},
{
"id": 2892,
"slug": "zoho-crm",
"name": "Zoho CRM"
},
{
"id": 2432,
"slug": "hubspot-crm",
"name": "HubSpot CRM"
},
{
"id": 2869,
"slug": "pipedrive",
"name": "Pipedrive"
},
{
"id": 5491,
"slug": "sugarcrm",
"name": "SugarCRM"
},
{
"id": 18332,
"slug": "freshsales",
"name": "Freshsales"
},
{
"id": 15518,
"slug": "insightly",
"name": "Insightly"
},
{
"id": 315344,
"slug": "attio",
"name": "Attio"
},
{
"id": 4033517,
"slug": "monday-crm",
"name": "Monday CRM"
},
{
"id": 79365,
"slug": "copper-crm",
"name": "Copper CRM"
}
]
}
}
]
}
@@ -0,0 +1,258 @@
{
"id": "example-request-id",
"credits_used": 1,
"credits_remaining": 9000,
"technologies": [
{
"slug": "kubernetes",
"name": "Kubernetes",
"count": 196381
},
{
"slug": "docker-and-kubernetes",
"name": "DOCKER AND KUBERNETES",
"count": 1378
},
{
"slug": "kubernetes-deployments",
"name": "Kubernetes Deployments",
"count": 752
},
{
"slug": "kubernetes-administrator",
"name": "Kubernetes Administrator",
"count": 659
},
{
"slug": "containerization-docker-kubernetes",
"name": "Containerization (Docker, Kubernetes)",
"count": 468
},
{
"slug": "kubernetes-architecture",
"name": "Kubernetes Architecture",
"count": 234
},
{
"slug": "tanzu-kubernetes-grid",
"name": "Tanzu Kubernetes Grid",
"count": 228
},
{
"slug": "tanzu-kubernetes",
"name": "Tanzu Kubernetes",
"count": 193
},
{
"slug": "kubernetes-cluster-management",
"name": "Kubernetes Cluster Management",
"count": 190
},
{
"slug": "kubernetes-management",
"name": "Kubernetes Management",
"count": 186
},
{
"slug": "service-azure-kubernetes-aks",
"name": "Service Azure Kubernetes (AKS)",
"count": 171
},
{
"slug": "oracle-kubernetes-engine",
"name": "Oracle Kubernetes Engine",
"count": 170
},
{
"slug": "kubernetes-eks-aks",
"name": "Kubernetes EKS/AKS",
"count": 116
},
{
"slug": "kubernetes-basics",
"name": "Kubernetes Basics",
"count": 105
},
{
"slug": "vmware-tanzu-kubernetes-grid",
"name": "VMware Tanzu Kubernetes Grid",
"count": 85
},
{
"slug": "kubernetes-container-orchestration",
"name": "Kubernetes Container Orchestration",
"count": 78
},
{
"slug": "kubernetes-admin",
"name": "Kubernetes Admin",
"count": 77
},
{
"slug": "kubernetes-controllers",
"name": "Kubernetes controllers",
"count": 74
},
{
"slug": "kubernetes-k3s",
"name": "Kubernetes/k3s",
"count": 68
},
{
"slug": "container-orchestration-kubernetes",
"name": "Container Orchestration / Kubernetes",
"count": 66
},
{
"slug": "container-kubernetes-security",
"name": "container/Kubernetes security",
"count": 63
},
{
"slug": "kubernetes-engine-gke",
"name": "Kubernetes Engine (GKE)",
"count": 60
},
{
"slug": "container-and-kubernetes-security",
"name": "Container and Kubernetes Security",
"count": 58
},
{
"slug": "ovn-kubernetes",
"name": "OVN Kubernetes",
"count": 57
},
{
"slug": "nutanix-kubernetes-platform",
"name": "Nutanix Kubernetes Platform",
"count": 54
},
{
"slug": "mirantis-kubernetes-engine",
"name": "Mirantis Kubernetes Engine",
"count": 48
},
{
"slug": "kubernetes-containerization",
"name": "Kubernetes/Containerization",
"count": 48
},
{
"slug": "kubernetes-operator-development",
"name": "Kubernetes Operator development",
"count": 47
},
{
"slug": "oracle-container-engine-for-kubernetes",
"name": "Oracle Container Engine for Kubernetes",
"count": 41
},
{
"slug": "kubernetes-gateway-api",
"name": "Kubernetes Gateway API",
"count": 40
},
{
"slug": "kubernetes-network-policies",
"name": "Kubernetes network policies",
"count": 37
},
{
"slug": "kubernetes-and-container-security",
"name": "Kubernetes and container security",
"count": 34
},
{
"slug": "oci-container-engine-for-kubernetes",
"name": "OCI (Container Engine for Kubernetes)",
"count": 34
},
{
"slug": "kubernetes-operations",
"name": "Kubernetes Operations",
"count": 34
},
{
"slug": "red-hat-advanced-cluster-security-for-kubernetes",
"name": "Red Hat Advanced Cluster Security for Kubernetes",
"count": 31
},
{
"slug": "basic-kubernetes",
"name": "Basic Kubernetes",
"count": 30
},
{
"slug": "spring-cloud-kubernetes",
"name": "Spring Cloud Kubernetes",
"count": 28
},
{
"slug": "vmware-tanzu-kubernetes",
"name": "VMware Tanzu Kubernetes",
"count": 28
},
{
"slug": "kubernetes-pods",
"name": "Kubernetes Pods",
"count": 28
},
{
"slug": "kubernetes-oke",
"name": "Kubernetes (OKE)",
"count": 28
},
{
"slug": "container-orchestration-with-kubernetes",
"name": "Container Orchestration with Kubernetes",
"count": 28
},
{
"slug": "red-hat-openshift-kubernetes",
"name": "Red Hat OpenShift/Kubernetes",
"count": 27
},
{
"slug": "vsphere-kubernetes-service",
"name": "vSphere Kubernetes Service",
"count": 26
},
{
"slug": "managed-kubernetes-service",
"name": "Managed Kubernetes Service",
"count": 25
},
{
"slug": "kubernetes-cluster-administration",
"name": "Kubernetes Cluster Administration",
"count": 25
},
{
"slug": "containerization-and-kubernetes",
"name": "Containerization and Kubernetes",
"count": 23
},
{
"slug": "aws-eks-kubernetes",
"name": "AWS EKS/Kubernetes",
"count": 23
},
{
"slug": "tanzu-kubernetes-grid-tkg",
"name": "Tanzu Kubernetes Grid (TKG)",
"count": 23
},
{
"slug": "kubernetes-platform-engineering",
"name": "Kubernetes Platform Engineering",
"count": 22
},
{
"slug": "oracle-kubernetes",
"name": "Oracle Kubernetes",
"count": 22
}
],
"total_count": 613
}
@@ -0,0 +1,37 @@
{
"id": "example-request-id",
"credits_used": 1,
"credits_remaining": 9000,
"matched_count": 2,
"results": [
{
"input": "kubernetes",
"technology": {
"id": 18,
"slug": "kubernetes",
"name": "Kubernetes",
"categories": []
}
},
{
"input": "python",
"technology": {
"id": 3,
"slug": "python",
"name": "Python",
"categories": [
{
"slug": "programming-languages-and-frameworks",
"name": "Programming Languages and Frameworks",
"name_short": "Languages"
},
{
"slug": "scripting-languages-and-shells",
"name": "Scripting Languages and Shells",
"name_short": "Scripting"
}
]
}
}
]
}
+1
View File
@@ -28,6 +28,7 @@
# NOTE: this block must stay ABOVE `rates_to_usd:` -- catalog_fx_update.py rewrites the file
# from the text before that key, so anything below it is discarded on the next FX refresh.
credit_rates_usd:
sumble: {usd: 0.01, basis: "Pro monthly subscription: $99 / 9900 credits = $0.01 per credit, before configured platform margin. Retain this list rate when negotiating a custom plan.", source: "https://sumble.com/pricing", checked: "2026-09-09"}
quickenrich: {usd: 0.004834, basis: "Starter monthly subscription: $29 / 6000 credits = $0.004833333 per credit, rounded up to 4834 micro-USD. treg base list rate before configured platform margin, assuming all monthly credits are used; unused credits increase effective cost. Free, Starter and Growth use subscription allowances. Unlimited-plan API status is not yet verified", source: "https://quickenrich.io/", checked: "2026-09-09"}
cloro: {usd: 0.0008, basis: "Lite $30/mo / 37,500 credits = $0.0008/credit — the cheapest paid tier, so the upper bound. Higher tiers are cheaper (Hobby $100/250,000 = $0.0004, Business $1,000/2,800,000 = $0.00036). The free plan grants 500 credits/month, which is not a sustainable rate. Credits are charged only on a successful extraction", source: "https://cloro.dev/pricing", checked: "2026-09-05"}
scrapecreators: {usd: 0.00188, basis: "Freelance pay-as-you-go $47 / 25,000 credits (Business tier is $497 / 500,000 = $0.000994)", source: "https://scrapecreators.com/", checked: "2026-07-28"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -289,7 +289,7 @@ def _show(resp: httpx.Response) -> None:
print(resp.text)
if resp.status_code < 400:
_show_charge_line(resp)
_show_review_line(resp)
_show_hint_line(resp)
if resp.status_code >= 400:
_show_failure_diagnostics(resp)
# 402 = the team balance can't cover a call on treg's key. The JSON above already carries the
@@ -326,13 +326,26 @@ def _show_charge_line(resp: httpx.Response) -> None:
print(line, file=sys.stderr)
def _show_review_line(resp: httpx.Response) -> None:
def _show_hint_line(resp: httpx.Response) -> None:
"""The server's optional invitation (`X-Treg-Hint: review|feedback`), one stderr line beside the
charge line. `X-Treg-Review: requested` is the older review-only header a pre-0.19 registry
still sends. stdout stays the exact body."""
headers = getattr(resp, "headers", {}) or {}
call_id = headers.get("X-Treg-Call-Id")
if headers.get("X-Treg-Review") == "requested" and call_id:
kind = headers.get("X-Treg-Hint")
if kind is None and headers.get("X-Treg-Review") == "requested":
kind = "review"
if not call_id:
return
if kind == "review":
print(f'treg: after using this result, run treg review {call_id} '
'<useful|partly|not_useful|not_sure> [--reason "..."]; '
'omit private data, then keep going with the task.', file=sys.stderr)
elif kind == "feedback":
print('treg: anything confusing or wrong about this call, even if it worked? '
'treg feedback submit <quality|pricing|friction|other> "what you saw" '
f'--call-id {call_id}; omit private data, then keep going with the task.',
file=sys.stderr)
def _show_failure_diagnostics(resp: httpx.Response) -> None:
@@ -2441,7 +2454,7 @@ def _show_call_response(response: httpx.Response) -> None:
_show_failure_diagnostics(response)
raise SystemExit(1)
_show_charge_line(response)
_show_review_line(response)
_show_hint_line(response)
return
_show(response)
@@ -4660,6 +4673,9 @@ def _cost_label(cost) -> str:
"""A price you can scan in a column: "$0.001/success", "free", "quota rows"."""
if not isinstance(cost, dict):
return "-"
if cost.get("display_unit") and cost.get("display_usd") is not None:
return (f"${cost['display_usd']:.3g}" + cost.get("display_suffix", "")
+ "/" + cost["display_unit"])
kind = (cost.get("type") or "").replace("_", " ")
value, currency = cost.get("value"), cost.get("currency") or ""
if value in (None, "") and isinstance(cost.get("table"), list):
@@ -4881,6 +4897,9 @@ def _cost_usd(cost: dict | None) -> str:
column, so USD stands alone here; `treg catalog get` carries the native amount alongside it."""
if not isinstance(cost, dict):
return "-"
if cost.get("display_unit") and cost.get("display_usd") is not None:
return (f"${cost['display_usd']:.3g}" + cost.get("display_suffix", "")
+ "/" + cost["display_unit"])
usd = cost.get("usd")
if usd is None:
# no rate for this unit (a provider that publishes no per-credit price): the native
+6
View File
@@ -48,6 +48,11 @@ class Settings(BaseSettings):
review_sample_rate: float = Field(default=0, ge=0, le=1)
feedback_hint_rate: float = Field(default=0, ge=0, le=1)
# Review invitations a team can receive per hour, whatever its call volume. Sampling decides
# WHICH calls qualify; this decides how many of them a team is actually asked about.
review_budget_per_hour: int = Field(default=5, ge=1)
# The shared key-value store (Redis protocol). Empty = an in-process fallback; see infra/kv.py.
kv_url: str = ""
# SQLite locally, Postgres on Render — same code path, just swap the URL.
database_url: str = "sqlite+aiosqlite:///./treg.db"
@@ -173,6 +178,7 @@ class Settings(BaseSettings):
platform_key_contactout: str = "" # raw API token; injected into the token header
platform_key_millionverifier: str = "" # raw key; injected as ?api=…
platform_key_hunter: str = ""
platform_key_sumble: str = "" # Bearer; Pro monthly credits, optional vendor auto-top-up
platform_key_quickenrich: str = "" # Bearer; monthly subscription credits, not auto-top-up
platform_key_leadmagic: str = ""
platform_key_lusha: str = ""
+335
View File
@@ -0,0 +1,335 @@
"""Arena task definitions and attributed results. No execution or money writes."""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any
from urllib.parse import urlsplit, urlunsplit
VERSION = "2"
MAX_RESULT_BYTES = 256_000
MAX_BATCH_RAW_BYTES = 2_000_000
RETENTION_DAYS = 30
MAX_ENTRIES = 50
DISCOVERY_LIMIT = 10
DISCOVERY_ENTRIES = 10
DISCOVERY_TASKS = frozenset({"people.search", "people.company.search", "companies.similar"})
TERMINAL = frozenset({"completed", "cancelled", "interrupted", "failed"})
REASONS = frozenset({"more_complete", "correct_identity", "more_current", "better_verification",
"wrong_identity", "missing_fields", "conflicting_data"})
@dataclass(frozen=True)
class Task:
capability: str
label: str
description: str
variants: tuple[tuple[str, ...], ...]
catalog_capability: str = ""
TASKS = {
t.capability: t for t in (
Task("people.search", "Find people", "Discover people by a search description, or by role and country.",
(("q",), ("title", "country"))),
Task("people.company.search", "People at a company", "Find people at a company, with an optional job-title filter.",
(("company_domain",), ("title", "company_domain")), "people.search"),
Task("companies.similar", "Find similar companies", "Discover companies similar to a seed company domain.",
(("domain",),)),
Task("people.email.find", "Find a work email", "Find a work email from a name or LinkedIn profile.",
(("full_name", "domain"), ("linkedin_url",))),
Task("people.enrich", "Enrich a person", "Compare the details each service knows about a person.",
(("linkedin_url",), ("email",), ("full_name", "domain"))),
Task("companies.enrich", "Enrich a company", "Compare company profiles, from the basics to the details.",
(("domain",), ("name",), ("linkedin_url",))),
Task("people.phone.find", "Find a phone number", "Find a phone number. A found number is not a verified live line.",
(("linkedin_url",), ("email",), ("full_name", "domain"))),
Task("people.phone.verify", "Verify phone number", "Validate number format and carrier details; this does not confirm a live line.",
(("phone",),)),
Task("people.email.verify", "Verify email", "Compare mailbox verdicts. Invalid is a useful answer, too.",
(("email",),)),
Task("people.identity.resolve", "Find a LinkedIn profile", "Find the LinkedIn profile associated with an email.",
(("email",),)),
)
}
# Public demo inputs only, never sampled customer queries or enrichment results.
# Contact sources: patrickcollison.com/about; hubspot.com/company-news/author/dharmesh-shah.
# Business phones: apple.com/contact; news.microsoft.com/ja-jp/cp/corpdata/.
_EXAMPLE_INPUTS = (
{"full_name":"Patrick Collison", "domain":"stripe.com", "company_domain":"stripe.com",
"name":"Stripe", "linkedin_url":"https://www.linkedin.com/in/patrickcollison",
"email":"patrick@collison.ie", "phone":"+18006927753",
"q":"Software engineers at Stripe in the United States", "title":"Software Engineer", "country":"US"},
{"full_name":"Dharmesh Shah", "domain":"hubspot.com", "company_domain":"hubspot.com",
"name":"HubSpot", "linkedin_url":"https://www.linkedin.com/in/dharmesh",
"email":"dshah@hubspot.com", "phone":"+14258828080",
"q":"Sales leaders at HubSpot in the United States", "title":"Sales Director", "country":"US"},
)
def example_inputs(task: Task) -> list[list[dict[str, str]]]:
rows = [dict(row) for row in _EXAMPLE_INPUTS]
if task.capability == "companies.enrich":
for row, slug in zip(rows, ("stripe", "hubspot")):
row["linkedin_url"] = "https://www.linkedin.com/company/" + slug
return [[{key: row[key] for key in variant} for row in rows] for variant in task.variants]
# A personal mailbox is a different task; bulk and asynchronous jobs are excluded by the planner.
VERIFICATION_TASKS = {"people.email.find": ("people.email.verify", "email"),
"people.phone.find": ("people.phone.verify", "phone")}
EXCLUDED = frozenset({"leadmagic.x.personal-email-finder"})
class ArenaError(Exception):
def __init__(self, message: str, status: int = 422):
super().__init__(message)
self.status = status
def catalog_capability(capability: str) -> str:
task = TASKS[capability]
return task.catalog_capability or task.capability
def supports_discovery(capability: str, identity: dict, accepted) -> bool:
# A company-only search must not silently drop a requested title (or vice versa).
required = {k for k, v in identity.items() if v not in (None, "") and k not in {"country", "limit"}}
return capability not in DISCOVERY_TASKS or required <= set(accepted)
def validate_identity(capability: str, identity: dict) -> dict[str, str]:
task = TASKS.get(capability)
if task is None:
raise ArenaError("Choose an Arena task.")
allowed = {k for variant in task.variants for k in variant}
if any(k not in allowed for k in identity):
raise ArenaError("This task does not accept those input fields.")
clean = {}
for k, v in identity.items():
if not isinstance(v, str) or len(v) > 500:
raise ArenaError("Use text inputs of at most 500 characters.")
if v.strip():
clean[k] = v.strip()
if not any(set(v) <= clean.keys() for v in task.variants):
raise ArenaError("Complete one of the task's input options.")
if "phone" in clean:
phone = re.sub(r"[\s().-]", "", clean["phone"])
if not re.fullmatch(r"\+[1-9][0-9]{6,14}", phone):
raise ArenaError("Use an international phone number with a + country calling code.")
clean["phone"] = phone
if "full_name" in clean:
parts = clean["full_name"].split()
if len(parts) < 2 or not all(any(c.isalpha() for c in p) for p in (parts[0], parts[-1])):
raise ArenaError("Enter both a first and last name for a name-based comparison, or use a LinkedIn URL.")
clean["full_name"] = " ".join(parts)
domain_key = "company_domain" if "company_domain" in clean else "domain"
if domain_key in clean:
url = _web_url(clean[domain_key])
if url is None:
raise ArenaError("Enter a company domain, such as example.com.")
clean[domain_key] = url.hostname.lower()
if "country" in clean:
if not re.fullmatch(r"[A-Za-z]{2}", clean["country"]):
raise ArenaError("Use a two-letter country code, such as US or GB.")
clean["country"] = clean["country"].upper()
if "email" in clean and not re.fullmatch(r"[^\s@]+@[^\s@./:?#\[\]]+(?:\.[^\s@./:?#\[\]]+)+", clean["email"]):
raise ArenaError("Enter an email address.")
if "linkedin_url" in clean:
u = _web_url(clean["linkedin_url"])
if (u is None or not clean["linkedin_url"].lower().startswith(("http://", "https://"))
or u.hostname not in {"linkedin.com", "www.linkedin.com"}):
raise ArenaError("Enter a full LinkedIn profile URL.")
prefix = "/company/" if capability == "companies.enrich" else "/in/"
if not u.path.startswith(prefix) or not u.path[len(prefix):].strip("/"):
raise ArenaError("Use a LinkedIn company URL." if prefix == "/company/" else "Use a LinkedIn person URL.")
clean["linkedin_url"] = "https://www.linkedin.com" + u.path.rstrip("/")
return clean
def validate_entries(capability: str, identity: dict | None, identities: list[dict] | None) -> list[dict]:
if (identity is None) == (identities is None):
raise ArenaError("Provide one query or a list of entries.")
entries = identities if identities is not None else [identity]
maximum = DISCOVERY_ENTRIES if capability in DISCOVERY_TASKS else MAX_ENTRIES
if not 1 <= len(entries) <= maximum:
raise ArenaError(f"Use between 1 and {maximum} entries.")
clean, seen = [], set()
for i, entry in enumerate(entries):
try:
value = validate_identity(capability, entry)
except ArenaError as exc:
raise ArenaError(f"Entry {i + 1}: {exc}") from exc
fingerprint = tuple(sorted((k, v.casefold()) for k, v in value.items()))
if fingerprint in seen:
raise ArenaError(f"Entry {i + 1} is a duplicate. Remove it before running.")
if clean and value.keys() != clean[0].keys():
raise ArenaError("Use the same input type for every entry.")
seen.add(fingerprint)
clean.append(value)
return clean
def required_credit(attempts: list[dict], mode: str) -> int:
if mode == "compare":
return sum(a["estimate_micro"] for a in attempts)
cheapest = {}
for a in attempts:
entry = a.get("entry_index", 0)
cheapest[entry] = min(cheapest.get(entry, a["estimate_micro"]), a["estimate_micro"])
return sum(cheapest.values())
def classify(contract, adapter, endpoint: dict, status: int, doc: Any) -> tuple[str, dict]:
"""Match the routed lookup's structural hit rule; retain verification qualifiers separately."""
miss_status = (endpoint.get("miss") or {}).get("status")
if status == miss_status and 400 <= status < 500:
return "miss", {}
if not 200 <= status < 300:
return "error", {}
if not isinstance(doc, (dict, list)):
return "error", {}
output = safe_output(adapter.from_upstream(doc), capability=contract.capability)
empty = any(output.get(k) in (None, "", [], {}) for k in contract.required_output)
return ("miss" if adapter.is_miss(doc) or empty else "hit"), output
def _web_url(value: str):
"""Parse a vendor URL without treating credentials or executable schemes as web addresses."""
try:
url = urlsplit(value if "://" in value else "https://" + value)
if (url.scheme not in {"http", "https"} or not url.hostname or "." not in url.hostname
or url.username is not None or url.password is not None
or any(c.isspace() for c in value)):
return None
_ = url.port # Malformed ports must not make historical results unreadable.
return url
except ValueError:
return None
def safe_output(output: dict, *, capability: str = "") -> dict:
"""Normalize Arena's scalar fields; preserve vendor snapshots separately.
Booleans in text fields are often vendor masking flags, not real data. Never turn them
into names, locations or URLs. Verification booleans and numeric metrics retain their types.
"""
result = {}
for key, value in output.items():
if capability in DISCOVERY_TASKS and key in {"people", "companies"}:
result[key] = search_rows(value, company=key == "companies")
continue
if capability in DISCOVERY_TASKS and key == "count":
continue # Provider totals are not the number of returned, usable rows.
if key in {"verified", "valid"}:
result[key] = value if isinstance(value, bool) else None
continue
if key in {"confidence", "score", "employees", "founded"}:
result[key] = (value[:4000] if isinstance(value, str) else value
if isinstance(value, (int, float)) and not isinstance(value, bool) else None)
continue
if not isinstance(value, str):
result[key] = None
continue
value = value.strip()[:4000]
if key == "linkedin_url":
prefix = "/company/" if capability == "companies.enrich" else "/in/"
if re.fullmatch(r"[\w-]+", value):
value = "https://www.linkedin.com" + prefix + value
url = _web_url(value)
value = ("https://www.linkedin.com" + url.path.rstrip("/")
if url and url.hostname in {"linkedin.com", "www.linkedin.com"}
and url.path.startswith(prefix) and url.path[len(prefix):].strip("/") else None)
elif key in {"domain", "company_domain", "website"}:
url = _web_url(value)
value = (urlunsplit(url) if key == "website" else url.hostname.lower()) if url else None
result[key] = value
if capability in DISCOVERY_TASKS:
result["count"] = len(result.get("companies" if capability == "companies.similar" else "people", []))
return result
def search_rows(value, *, company=False):
"""Bounded, scalar display projection; original vendor bodies remain separate."""
if not isinstance(value, list):
return []
rows = []
for item in value[:DISCOVERY_LIMIT]:
if not isinstance(item, dict):
continue
def pick(*paths):
for path in paths:
node = item
for part in path.split("."):
node = node.get(part) if isinstance(node, dict) else None
if isinstance(node, str) and node.strip():
return node.strip()[:1000]
return None
row = {"name": pick("name", "full_name", "fullName", "displayName", "basic_profile.name"),
"title": pick("title", "lastJobTitle", "headline", "position", "job_title", "jobTitle", "basic_profile.headline"),
"company": pick("company.name", "company", "company_name", "companyName", "lastCompanyName", "organization.name"),
"location": pick("location", "location.name", "location.address", "locality", "country", "address", "basic_profile.location"),
"email": pick("email", "work_email", "value"),
"linkedin_url": pick("linkedin_url", "linkedinUrl", "profile_url", "profileUrl", "socials.linkedin_url", "URLs.linkedin", "url", "basic_profile.linkedin_url"),
"domain": pick("domain", "website_url", "website", "company.domain"),
"description": pick("description", "industry")}
if not row["name"]:
row["name"] = " ".join(filter(None, [pick("first_name", "firstName", "firstname"), pick("last_name", "lastName", "lastname")])) or None
row = safe_output(row, capability="companies.enrich" if company else "people.enrich")
if any(row.get(k) for k in (("name", "domain") if company else ("name", "linkedin_url", "email"))):
rows.append({k: v for k, v in row.items() if v is not None})
return rows
def verification_rejected_email(attempt: dict, capability: str) -> bool:
verification = attempt.get("verification") or {}
output = verification.get("output") or {}
# All current email adapters map catch-all/unknown to valid=False too.
# Only an explicit negative mailbox verdict justifies an issue report.
return (capability == "people.email.find" and attempt.get("state") == "hit"
and verification.get("capability") == "people.email.verify"
and verification.get("state") == "hit"
and output.get("valid") is False
and str(output.get("status", "")).strip().lower() in {"invalid", "undeliverable"})
def present(payload: dict, *, mode: str, state: str, capability: str = "") -> dict:
attempts = payload.get("attempts", [])
ordered = sorted(attempts, key=lambda a: (a.get("entry_index", 0), a["order"] if mode == "waterfall" else a["display_order"]))
results = []
for a in ordered:
row = {"id": a["id"], "entry_index": a.get("entry_index", 0), "label": chr(65 + a["display_order"]), "state": a["state"],
"output": safe_output(a.get("output", {}), capability=capability)}
row.update({k: a.get(k) for k in ("provider", "endpoint_id", "tier", "estimate_micro",
"charged_micro", "duration_ms", "started_ms", "detail", "raw", "raw_omitted", "cached", "call_ref", "report", "rating", "manual")})
# Distinguish the vendor's 402 from our team's balance/admission errors, including history.
row["upstream_status"] = a.get("status") if not a.get("failure_kind") else None
row["can_try"] = state in TERMINAL | {"running"} and a["state"] in {"not_attempted", "skipped"} and not a.get("call_ref") and not a.get("manual")
v = a.get("verification")
row["can_verify"] = capability in VERIFICATION_TASKS and a["state"] == "hit" and not v and (not payload.get("auto_verify") or state in TERMINAL)
if v:
row["verification"] = {k:v.get(k) for k in ("id", "capability", "state", "provider", "endpoint_id", "charged_micro", "duration_ms", "detail", "raw", "call_ref", "tried", "served_by")}
row["verification"]["output"] = safe_output(v.get("output", {}), capability=v.get("capability", ""))
row["lookup_charged_micro"] = row["charged_micro"]
row["charged_micro"] = None if row["charged_micro"] is None or v.get("charged_micro") is None else row["charged_micro"] + v["charged_micro"]
results.append(row)
out = {"progress": sum(a["state"] not in {"queued", "running"} for a in attempts),
"total": len(attempts), "results": results}
out["charged_micro"] = sum((a.get("charged_micro") or 0) + ((a.get("verification") or {}).get("charged_micro") or 0) for a in attempts)
out["charge_pending"] = any((a.get("charged_micro") is None and a["state"] != "queued") or (a.get("verification") and a["verification"].get("charged_micro") is None) for a in attempts)
out["stop_reason"] = payload.get("stop_reason", "")
return out
def validate_vote(kind: str, selected: list[str], reasons: list[str], attempts: list[dict]) -> None:
eligible = {a["id"] for a in attempts if a["state"] == "hit"}
if len(set(selected)) != len(selected) or not set(selected) <= eligible:
raise ArenaError("Choose from the results shown in this comparison.")
expected = {"winner": len(selected) == 1, "tie": len(selected) >= 2,
"none": not selected, "cannot_judge": not selected, "skip": not selected}
if kind not in expected or not expected[kind]:
raise ArenaError("Choose a result, a tie, none useful, or cannot judge.")
if not set(reasons) <= REASONS:
raise ArenaError("Unknown feedback reason.")
+145
View File
@@ -0,0 +1,145 @@
"""Conservative classification for database-backed Arena observations. No I/O."""
import json
import re
from urllib.parse import parse_qs, urlsplit, unquote
NO_RESULT = re.compile(r'companynotfound|profile_not_found|(?:company|person|profile) (?:could not be |not )found|no (?:matching profile|linkedin profile|records|companies) (?:was |were )?found|does(?: not|n.t) exist in our database|does not resolve to a known person', re.I)
FUNDS = re.compile(r'insufficient[_ ](?:balance|credits|funds)|credits_exhausted|credits? (?:have been |is |are )?exhausted|used all of its credits|not enough credits|out of credits|credits? remaining.*(?:zero|\b0\b)', re.I)
RATE = re.compile(r'rate[_ -]?limit|too many requests|too_many_requests', re.I)
INVALID = re.compile(r'wrong_params|params_invalid|parameters misconfigured|validation error|invalid[_ ](?:parameter|params|email|url|name)|field is required|first name is required|missing required', re.I)
def parse_request(text):
text = text or ''
query, body = {}, {}
if text.startswith('?'):
q, sep, rest = text[1:].partition(' {')
query = {k: v[-1] for k, v in parse_qs(q, keep_blank_values=True).items()}
if sep:
try: body = json.loads('{' + rest)
except ValueError: pass
elif text.lstrip().startswith(('{', '[')):
try: body = json.loads(text)
except ValueError: pass
return query, body
def invalid_request(query, body):
# Values actually sent to a handle field must not be a URL, path, or @handle.
handle = query.get('linkedin_handle')
if isinstance(handle, str) and handle and (any(s in unquote(handle) for s in ('/', ':', '@'))):
return 'invalid_linkedin_handle'
return None
def response_doc(text):
text = re.sub(r'^\[[^\]]*\]\s*', '', text or '')
try: return json.loads(text)
except ValueError: return None
def body_failure(doc):
if not isinstance(doc, dict): return None
# Restrict inspection to explicit error/status fields; arbitrary profile prose is not evidence.
selected = {k: doc[k] for k in ('error', 'errors', 'error_details', 'code', 'status', 'message', 'detail') if k in doc}
s = json.dumps(selected)
if FUNDS.search(s): return 'excluded_upstream_balance'
if RATE.search(s): return 'excluded_rate_limit'
if INVALID.search(s): return 'excluded_invalid_request'
if doc.get('error') or doc.get('errors') or doc.get('success') is False or str(doc.get('status', '')).lower() in ('error', 'failed', 'failure'):
return 'unresolved_body_error'
return None
def error_outcome(row, policy, request=None):
if row.get('refused_by'):
return 'excluded_treg_' + row['refused_by'], 'explicit_refused_by'
query, body = request if request is not None else parse_request(row.get('error_request'))
invalid = invalid_request(query, body)
if invalid: return 'excluded_invalid_request', invalid
status = row['status_code']
# Evidence prefixes contain rate-limit headers on healthy responses too. A header named
# x-ratelimit-limit is not a throttling error; inspect the response body, not that prefix.
text = re.sub(r'^\[[^\]]*\]\s*', '', row.get('error_response') or '')
if FUNDS.search(text) or status == 402:
return 'excluded_upstream_balance', 'status_or_explicit_credit_error'
if RATE.search(text) or status == 429:
return 'excluded_rate_limit', 'status_or_explicit_rate_error'
if status in (401, 403):
return 'excluded_access', 'http_access_failure'
if status in (408, 425) or status >= 500:
return 'excluded_service_error', 'http_service_failure'
if INVALID.search(text) or status in (400, 405, 406, 409, 415, 422, 431):
return 'excluded_invalid_request', 'http_or_explicit_request_error'
if status == 451:
return 'excluded_policy_restriction', 'http_451'
if status == 404:
if NO_RESULT.search(text):
return 'miss', 'explicit_not_found_response'
bare = re.sub(r'^\[[^\]]*\]\s*', '', text).strip().lower()
path = urlsplit(row.get('path') or '').path.rstrip('/')
expected = (policy.get('path') or '').rstrip('/')
if row['endpoint_id'].startswith('aviato.') and bare == 'not found' and row.get('method') == policy.get('method') and path.endswith(expected) and (query or body):
return 'miss', 'documented_aviato_entity_404_on_correct_route'
return 'unresolved_404', 'insufficient_evidence_for_entity_miss'
if not 200 <= status < 300:
return 'unresolved_status', 'unclassified_http_status'
return None, None
RULES_VERSION = "1"
def input_label(endpoint, adapter, query, body, path=""):
from .catalog.routing import paths as P
if isinstance(body, list):
body = body[0] if body else {}
request = {"queryParams": query, "body": body if isinstance(body, dict) else {}}
values = {}
template = endpoint.get("path") or ""
parameters = re.findall(r"\{([^}]+)\}", template)
pattern = re.escape(template)
for parameter in parameters:
pattern = pattern.replace(re.escape("{" + parameter + "}"), "([^/]+)")
match = re.search(pattern + "/?$", urlsplit(path).path) if parameters else None
path_values = dict(zip(parameters, match.groups())) if match else {}
for field, target in adapter.in_map.items():
value = (query.get(target.split(".", 1)[1]) or path_values.get(target.split(".", 1)[1])) if target.startswith("pathParams.") else P.get_path(request, target)
if value not in (None, "", []):
values[field] = value
if endpoint["provider"] == "lusha" and isinstance(body, dict) and body.get("contacts"):
contact = body["contacts"][0]
if isinstance(contact, dict):
for native, canonical in [("linkedinUrl", "linkedin_url"), ("email", "email"), ("firstName", "first_name"), ("lastName", "last_name"), ("companyDomain", "domain")]:
if contact.get(native):
values[canonical] = contact[native]
linkedin = bool(values.get("linkedin_url") or values.get("linkedin_handle"))
if endpoint["capability"] == "companies.enrich":
flags = [(values.get("domain"), "domain"), (values.get("name"), "name"), (linkedin, "linkedin_url")]
else:
flags = [(values.get("email"), "email"), (linkedin, "linkedin_url"),
(values.get("domain") and (values.get("full_name") or values.get("first_name") and values.get("last_name")), "name_domain")]
labels = [label for value, label in flags if value]
return labels[0] if len(labels) == 1 else "unknown"
def classify_record(row, endpoint, adapter, contract, evidence=None):
from . import arena
# Explicitly exclude credentials/overflow/cache: these are not comparable platform observations.
if row["kind"] != "call" or row["cached"] or row["credential_tier"] not in ("platform", None):
return "unknown", "excluded_scope"
query, body = parse_request(row.get("error_request"))
if evidence:
query, body = evidence[0], evidence[1]
label = input_label(endpoint, adapter, query, body, row.get("path") or "")
category, _ = error_outcome(row, endpoint, (query, body))
if category:
return label, category
if row["status_code"] in (202, 222):
return label, "excluded_pending"
if row["credential_tier"] != "platform":
return label, "excluded_scope"
if not evidence or evidence[2] is None:
return label, "unresolved_archive"
if not row.get("params_hash"):
return label, "unresolved_identity"
problem = body_failure(evidence[2])
if problem:
return label, problem
verdict, _ = arena.classify(contract, adapter, endpoint, row["status_code"], evidence[2])
return label, verdict if verdict in ("hit", "miss") else "unresolved_body"
+14
View File
@@ -76,6 +76,19 @@ async def _seranking(c, key):
f"{sub.get('expire_at', '?')}); access via {reason}"}
async def _sumble(c, key):
r = await c.post("https://api.sumble.com/v9/technologies/find",
headers={"Authorization": f"Bearer {key}"},
json={"query": "treg-nonexistent-probe-20260909"})
r.raise_for_status()
doc = r.json()
remaining = doc.get("credits_remaining") if isinstance(doc, dict) else None
if type(remaining) is not int or remaining < 0:
remaining = None
return {"value": remaining, "unit": "credits",
"note": "Monthly allowance plus purchased credits; renewal date and auto-top-up state not reported."}
async def _quickenrich(c, key):
# Free discovery carries the remaining subscription allowance; no account endpoint exists.
r = await c.post("https://app.quickenrich.io/api/employees/contact-finder",
@@ -436,6 +449,7 @@ BALANCE_ROUTES = {
"seranking": _seranking,
"hunter": _hunter,
"quickenrich": _quickenrich,
"sumble": _sumble,
"trykitt": _trykitt,
"contactout": _contactout,
"millionverifier": _millionverifier,
+2
View File
@@ -32,6 +32,7 @@ _KNOWN: dict[str, tuple[str, str, str]] = {
"tomba": ("monthly_quota", "quota_reset", "api"),
"hunter": ("monthly_quota", "quota_reset", "api"),
"quickenrich": ("monthly_quota", "quota_reset", "api"),
"sumble": ("monthly_quota", "quota_reset", "api"),
"predictleads": ("monthly_quota", "quota_reset", "api"),
"companyenrich": ("credits", "manual", "api"),
"apollo": ("credits", "manual", "api"),
@@ -62,6 +63,7 @@ _QUOTAS: dict[str, dict] = {
"quickenrich": {"limit": None, "period": "billing", "resets_at_rule": "subscription renewal; no reset timestamp in API"},
}
_RATE_LIMITS: dict[str, dict] = {
"sumble": {"limit": 10, "window_s": 1, "source": "docs"},
"leadsforge": {"limit": 120, "window_s": 60, "source": "headers"},
"leadmagic": {"limit": 300, "window_s": 60, "source": "docs"},
"crustdata": {"limit": 30, "window_s": 60, "source": "headers"},
+3 -1
View File
@@ -37,6 +37,8 @@ _TABLE: list[tuple[str, int, str, str]] = [
("akta", 402, r"insufficient credits", "balance"),
("lusha", 400, r"reached your credit limit", "balance"),
("predictleads", 402, r"exceeded the monthly request limit", "quota"),
# PDL meters operations separately: person_identify can run out while enrich still works.
("pdl", 402, r"hit your account maximum for", "quota"),
("lusha", 429, r"daily", "quota"),
("hunter", 429, r"per billing period", "quota"),
("apollo", 429, r"per (day|month)|daily|monthly", "quota"),
@@ -70,7 +72,7 @@ _TABLE: list[tuple[str, int, str, str]] = [
# `test_every_recorded_phrase_arms_the_tripwire` keeps this list and the table in step.
CAPACITY_PHRASES = (
r"not enough credits", r"insufficient[ _]credits", r"nocreditsremaining", r"payment required",
r"reached your credit limit", r"exceeded the monthly request limit",
r"reached your credit limit", r"exceeded the monthly request limit", r"hit your account maximum for",
r"insufficient (?:credits?|balance|funds)", r"out of credits?", r"credits? (?:exhausted|remaining|left)",
r"(?:account |api |credit )?(?:balance|quota)(?: (?:has been|is|was))? (?:exceeded|reached|exhausted|limit)",
r"upgrade your plan", r"insufficient-quota", r"not have enough quota",
+20
View File
@@ -149,6 +149,16 @@ class Catalog:
usd = (round(value * rate / per, 9)
if isinstance(value, (int, float)) and rate is not None and per > 0 else None)
out = {**cost, "usd": usd}
# Optional presentation metadata describes the charge unit without changing billing.
display = cost.get("display") or {}
if usd is not None and display:
grouped = display.get("grouped", False)
out["display_usd"] = round(usd * per, 9) if grouped else usd
unit = display["unit"]
out["display_unit"] = f"{per:g} {unit}" if grouped else unit
if display.get("round_up"):
out["display_unit"] = "started " + out["display_unit"]
out["display_suffix"] = "+" if display.get("variable") else ""
# A table prices out as a RANGE: `usd` stays the validated ceiling (what reserve and
# eligibility read), `usd_min` is the cheapest row so a display never shows only the
# worst case as "the price" (an H3 video is $0.25 typical against a $1.96 ceiling).
@@ -1247,3 +1257,13 @@ def tool_examples(service: str) -> list[dict]:
note = f"{note} [capability: {ep['capability']}]"
out.append({"method": ep["method"], "path": ep["path"], "note": note})
return out
def headline_counts(cat: Catalog) -> tuple[str, int]:
"""The two numbers every agent-facing surface quotes, taken from the loaded catalog instead of
typed by hand: direct (non-routed) endpoints rounded DOWN to the hundred with a trailing "+",
and the providers behind them. Six hand-written copies once disagreed with each other and the
smallest undersold the catalog by six hundred endpoints; generated, the number cannot drift."""
direct = [e for e in cat.by_id.values() if e.get("kind") != "routed"]
providers = {e.get("provider") for e in direct if e.get("provider")}
return f"{len(direct) // 100 * 100:,}+", len(providers)
+3
View File
@@ -9,6 +9,8 @@ from sqlmodel import select
from ... import crypto
from ...models import (
AdConversion,
ArenaEvaluation,
ArenaRun,
AsyncResourceRecord,
AsyncTaskRecord,
Bundle,
@@ -130,6 +132,7 @@ async def list_user_orgs(
# Order matters: LedgerEntry references a CreditBlock, so it goes first; `IdempotentCall.membership_id`
# points at Membership, so Membership stays last and IdempotentCall sits above it.
ORG_SCOPED_MODELS = (
ArenaEvaluation, ArenaRun,
Tool, Secret, Bundle, PendingOAuth, CallRecord, RunRecord, Invite, DenyRule, Project,
CapabilityPin,
TagBudget,
+4 -5
View File
@@ -24,9 +24,8 @@ HINT = (
def review_hint(call_id: str) -> str:
return (
f"After you have used this result, rate it: review(call_id={call_id}, "
"usefulness=useful|partly|not_useful|not_sure, reason?). "
"Only this call needs a review: one per invitation. "
"Anything confusing or wrong: use feedback with this call_id in call_ids. "
"No private data. Then keep going with the task."
f"Rate this one call after using it: review(call_id={call_id}, "
"usefulness=useful|partly|not_useful|not_sure, reason?); one review per invitation. "
"Anything confusing or wrong: feedback with this call_id in call_ids. "
"No private data. Then continue."
)
+1
View File
@@ -56,6 +56,7 @@ BACKGROUND_CONSUMERS: dict[str, int] = {
"archive.prune_worker": 1, # holds one across a whole sweep
"archive.refresh_worker": 1,
"catalog observation refresh": 1, # singleflight, one task per process
"arena_insights.worker": 1, # bounded incremental aggregation; public snapshot reads use API
"admin evidence sweep": 1, # single-flighted in routers/admin.py
}
+130
View File
@@ -0,0 +1,130 @@
"""The shared key-value store: small, expiring counters that several workers must agree on.
Render Key Value (Redis protocol) in production, an in-process dictionary when `TREG_KV_URL` is
unset (local development, tests, self-hosters without one). The store holds nothing that must
survive: every key expires, and every caller treats "unavailable" as a safe answer. Reads and
writes are bounded by `_TIMEOUT_S` so a slow store can never hold a call open.
The first tenant is the review-invitation budget (`application/call/invite.py`). Nothing else may
depend on this module until it has proved itself there; the pattern for a new tenant is one more
narrow method here, not a generic get/set surface.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Protocol
from ..config import get_settings
_TIMEOUT_S = 0.1
_LOCAL_MAX_KEYS = 10_000
log = logging.getLogger("treg.kv")
class Store(Protocol):
async def take(self, key: str, limit: int, ttl_s: int) -> bool:
"""Count one use of `key` inside a window that starts at its first use and lasts `ttl_s`.
True while the window holds `limit` or fewer uses, False past it OR when the store cannot
answer: a budget that cannot be checked is spent, never free."""
async def ping(self) -> bool: ...
async def aclose(self) -> None: ...
class LocalStore:
"""Per-process fallback with the same contract. Bounded so a flood of keys cannot grow it."""
def __init__(self) -> None:
self._windows: dict[str, tuple[float, int]] = {}
async def take(self, key: str, limit: int, ttl_s: int) -> bool:
now = time.monotonic()
expires, count = self._windows.get(key, (0.0, 0))
if expires <= now:
if len(self._windows) >= _LOCAL_MAX_KEYS:
self._evict(now)
expires, count = now + ttl_s, 0
count += 1
self._windows[key] = (expires, count)
return count <= limit
def _evict(self, now: float) -> None:
live = {k: v for k, v in self._windows.items() if v[0] > now}
if len(live) >= _LOCAL_MAX_KEYS:
live = dict(sorted(live.items(), key=lambda kv: kv[1][0])[_LOCAL_MAX_KEYS // 2:])
self._windows = live
async def ping(self) -> bool:
return True
async def aclose(self) -> None:
self._windows.clear()
class RedisStore:
def __init__(self, url: str) -> None:
import redis.asyncio as redis # server extra; imported here so the light CLI never loads it
self._client = redis.Redis.from_url(
url, socket_timeout=_TIMEOUT_S, socket_connect_timeout=_TIMEOUT_S,
decode_responses=True,
)
async def take(self, key: str, limit: int, ttl_s: int) -> bool:
try:
async with asyncio.timeout(_TIMEOUT_S * 2):
async with self._client.pipeline(transaction=True) as pipe:
pipe.incr(key)
pipe.expire(key, ttl_s, nx=True) # the window starts at the first use
count, _ = await pipe.execute()
return int(count) <= limit
except Exception as exc: # noqa: BLE001 — a store fault is a spent budget, never a call fault
_note_fault(exc)
return False
async def ping(self) -> bool:
try:
async with asyncio.timeout(_TIMEOUT_S * 2):
return bool(await self._client.ping())
except Exception as exc: # noqa: BLE001
_note_fault(exc)
return False
async def aclose(self) -> None:
try:
await self._client.aclose()
except Exception: # noqa: BLE001
pass
def _note_fault(exc: BaseException) -> None:
from .. import analytics # lazy: analytics imports config, and this module is imported early
log.warning("kv unavailable: %s: %s", type(exc).__name__, exc)
analytics.capture_fault(exc, component="kv")
_store: Store | None = None
def store() -> Store:
"""The process-wide store, built on first use from `TREG_KV_URL`."""
global _store
if _store is None:
url = get_settings().kv_url
_store = RedisStore(url) if url else LocalStore()
return _store
def configured() -> bool:
return bool(get_settings().kv_url)
async def close() -> None:
global _store
if _store is not None:
await _store.aclose()
_store = None
+28 -29
View File
@@ -42,7 +42,6 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, TypedDict
from urllib.parse import parse_qsl, urlsplit
from uuid import uuid4
import httpx
from mcp.server import MCPServer
@@ -52,7 +51,7 @@ from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.types import METHOD_NOT_FOUND, ToolAnnotations
from . import analytics, audit, hints
from . import audit, hints
from .domain.catalog import store as catalog_store
from .config import PUBLIC_HOST_ALIASES, get_settings
from .feedback_contract import FeedbackCategory, FEEDBACK_DESCRIPTION, ReviewUsefulness, REVIEW_DESCRIPTION
@@ -146,23 +145,26 @@ class _StaticSurfaceCapabilities:
return result
# The catalog's size, quoted in the listing text a human reads in a connector directory. Generated,
# never typed: see `catalog_store.headline_counts`.
_ENDPOINTS, _PROVIDERS = catalog_store.headline_counts(catalog_store.load())
mcp = MCPServer(
name="treg",
title="treg — the tool catalog for your agent",
description=(
"Reach for this first for external or live data — ~2,600 curated endpoints across ~40 "
"providers (SEO, SERP, backlinks, social, people and company enrichment, ads, scraping), "
"plus your team's own tools."
f"Reach for this first for external or live data: {_ENDPOINTS} curated endpoints across "
f"{_PROVIDERS} providers (SEO, SERP, backlinks, social, people and company enrichment, ads, "
"scraping), plus your team's own tools."
),
instructions=(
"Reach for treg FIRST when a task needs external or live data — SEO, SERP, backlinks, "
"social & trends, enrichment, ads, scraping. ~2,600 endpoints across ~40 providers, plus "
"your team's own tools. Flow: catalog_search (say what you want to DO, not a vendor name) → "
"catalog_get (params) → call. Multiple providers for one job? catalog_get ranks them by "
"measured success, speed and price — you pick."
" When a call result carries a review invitation, use the result first, then call "
"review(call_id, usefulness, reason?) and keep going with the task. Only the invited call "
"needs a review: one per invitation."
"Reach for treg first when a task needs external or live data: SEO and SERP, backlinks, "
"social and trends, people and company enrichment, ads, scraping, plus your team's own "
"tools. Flow: catalog_search (say what you want to do, not a vendor name), then "
"catalog_get (parameters, price, measured reliability), then call. When several providers "
"cover one job, catalog_get ranks them by measured success, speed and price; you pick. "
"If a call result invites a review, rate that one call with review(call_id, usefulness, "
"reason?) after using it, then continue."
),
middleware=[_StaticSurfaceCapabilities()],
)
@@ -576,7 +578,7 @@ async def _whose_grant(client: httpx.AsyncClient, slug: str | None, *, oauth: bo
@mcp.tool(
description=(
"Search ~2,600 API endpoints by WHAT YOU WANT TO DO, not by vendor. Use plain task words: "
f"Search {_ENDPOINTS} API endpoints by WHAT YOU WANT TO DO, not by vendor. Use plain task words: "
"'work email', 'backlinks for a domain', 'tiktok comments', 'keyword search volume'. "
"Returns each endpoint's id, provider, price per call, and whether treg can serve it "
"without you owning an API key. Call this FIRST when a task needs data or an API you have "
@@ -1042,17 +1044,13 @@ async def _call_impl(endpoint_id: str, params: dict | list | None = None,
f"at its real price because treg's {provider} account is out; cost_usd is "
f"what the relay billed, not the catalog's direct price")
if 200 <= r.status_code < 300 and not out.get("hint") and not out.get("replayed"):
kind = None
if r.headers.get("X-Treg-Review") == "requested" and out.get("call_id"):
# /call/ decides whether to invite (application/call/invite.py) and records that it did;
# this surface only renders the header into the single hint slot.
kind = r.headers.get("X-Treg-Hint")
if kind == "review" and out.get("call_id"):
out["hint"] = hints.review_hint(out["call_id"])
kind = "review"
elif hints.sampled("feedback", out.get("call_id") or uuid4().hex):
elif kind == "feedback":
out["hint"] = hints.HINT
kind = "feedback"
if kind:
analytics.capture(analytics.SERVER_DISTINCT_ID, "mcp_hint_attached", {
"call_id": out.get("call_id"), "surface": surface.client_name, "kind": kind,
})
if r.status_code == 402:
# States the fact and stops. No link, and `topup_url` is stripped from the relayed body, so
# nothing on this path points a user at a payment page.
@@ -1179,12 +1177,13 @@ directory_mcp = MCPServer(
"information available before a call."
),
instructions=(
"This connector exposes Treg catalog endpoints only. catalog_search finds endpoint ids; "
"catalog_get returns parameters, provider documentation, price and reliability; "
"catalog_call_read and catalog_call_write execute the selected endpoint."
" When a call result carries a review invitation, use the result first, then call "
"review(call_id, usefulness, reason?) and keep going with the task. Only the invited call "
"needs a review: one per invitation."
"This connector exposes treg's catalog only. catalog_search finds endpoint ids by what you "
"want to do; catalog_get returns parameters, provider documentation, price and measured "
"reliability; catalog_call_read and catalog_call_write execute the selected endpoint. When "
"several providers cover one job, catalog_get ranks them by measured success, speed and "
"price; you pick. "
"If a call result invites a review, rate that one call with review(call_id, usefulness, "
"reason?) after using it, then continue."
),
middleware=[_StaticSurfaceCapabilities()],
)
+63
View File
@@ -1453,6 +1453,37 @@ class ArchiveSnapshot(SQLModel, table=True):
class ArenaRun(SQLModel, table=True):
"""Private, bounded Arena run. Encrypted payload owns the frozen plan and result snapshots."""
__table_args__ = (UniqueConstraint("org_id", "user_id", "request_key", name="uq_arena_request"),)
id: str = Field(primary_key=True)
org_id: int = Field(foreign_key="org.id", index=True)
user_id: int = Field(index=True) # provenance; survives membership removal without granting access
request_key: str
fingerprint: str
capability: str
mode: str
state: str = "running"
payload: str
created_at: datetime = Field(default_factory=_now)
deadline_at: datetime
expires_at: datetime = Field(index=True)
cancel_requested: bool = False
revealed_at: datetime | None = None
class ArenaEvaluation(SQLModel, table=True):
"""One immutable preference for a run's creator, including its exposure context."""
__table_args__ = (UniqueConstraint("run_id", name="uq_arena_evaluation"),)
id: str = Field(primary_key=True)
org_id: int = Field(foreign_key="org.id", index=True)
run_id: str = Field(foreign_key="arenarun.id", index=True)
user_id: int
kind: str
payload: str # encrypted selection, exposure snapshot, reasons and optional comment
created_at: datetime = Field(default_factory=_now)
class ArchiveEndpointStat(SQLModel, table=True):
"""The archive report's running totals, one row per endpoint — maintained by the recorder in
the SAME transaction as each snapshot, so the panel reads 50 tiny rows instead of walking
@@ -1470,3 +1501,35 @@ class ArchiveEndpointStat(SQLModel, table=True):
bodies_kept: int = Field(default=0) # versions whose bytes were kept
kept_bytes: int = Field(default=0, sa_column=Column(BigInteger, nullable=False, server_default="0")) # already past int32 on prod
newest_fetch: datetime | None = Field(default=None)
class ArenaObservation(SQLModel, table=True):
"""Derived, non-content call evidence; written only by application.arena_insights."""
__table_args__ = (Index("ix_arenaobservation_window", "version", "created_at"),
Index("ix_arenaobservation_request", "version", "endpoint", "input", "request_hash", "created_at"),)
id: int = Field(primary_key=True) # audit ID, deliberately no FK into lossy audit retention
version: str
endpoint: str
task: str
input: str
request_hash: str
category: str
duration_ms: int | None = None
created_at: datetime
class ArenaInsightState(SQLModel, table=True):
"""Persistent collection cursor and public aggregate, never raw request/response content."""
id: str = Field(primary_key=True)
cursor: int = 0
scan_until: datetime
updated_at: datetime | None = None
payload: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
class ArenaVerificationSnapshot(SQLModel, table=True):
"""Published aggregate only; private contact evidence never enters this table."""
id: str = Field(primary_key=True)
source_digest: str
published_at: datetime = Field(index=True)
payload: dict = Field(default_factory=dict, sa_column=Column(JSON))
+19 -1
View File
@@ -1256,6 +1256,24 @@ HUNTER = OAuthProvider(
probe_path="/account", # free — consumes no search/verification/enrichment credits
)
SUMBLE = OAuthProvider(
service="sumble", display_name="Sumble", auth_kind="key",
token_label="API key", token_placeholder="your Sumble API key",
token_header="Authorization", token_format="Bearer {secret}",
setup_url="https://sumble.com/account/api-keys",
setup_action_label="Get your Sumble API key",
setup_steps=("Sign in to Sumble and open Account → API keys.",
"Create an API key and copy it before closing the dialog."),
setup_note="Connect your own key for the full API, including workspace lists, signals and asynchronous people requests. Connection verification uses a free technology-search miss.",
auth_uri="", token_uri="", scopes={}, client_id_setting="", client_secret_setting="",
category="Enrichment",
summary="Find organizations, people, jobs and teams, and explore company technologies and signals.",
base_url="https://api.sumble.com/v9", docs_url="https://docs.sumble.com/api/api",
probe_path="/technologies/find", probe_method="POST",
probe_json={"query": "treg-nonexistent-probe-20260909"},
# Live 2026-09-09: bogus Bearer 401; valid key 200 with credits_used=0.
)
QUICKENRICH = OAuthProvider(
service="quickenrich", display_name="QuickEnrich", auth_kind="key",
token_label="API key", token_placeholder="your QuickEnrich API key",
@@ -2795,7 +2813,7 @@ REGISTRY: dict[str, OAuthProvider] = {
GOOGLE_ADS, YOUTUBE,
LINKEDIN, SLACK, X, TIKTOK, FACEBOOK, INSTAGRAM, META_ADS,
# API-key providers
APOLLO, PDL, AKTA, HUNTER, QUICKENRICH, TRYKITT, CONTACTOUT, MILLIONVERIFIER, CRUNCHBASE, MINIMAX, OPENROUTER, REPLICATE,
APOLLO, PDL, AKTA, HUNTER, SUMBLE, QUICKENRICH, TRYKITT, CONTACTOUT, MILLIONVERIFIER, CRUNCHBASE, MINIMAX, OPENROUTER, REPLICATE,
TIKHUB, BRIGHTDATA, SEMRUSH, JUSTONEAPI,
SCRAPECREATORS,
# SEO API-key providers
+2
View File
@@ -134,6 +134,8 @@ CATALOG: list[dict] = [
{"provider": "Ahrefs", "tokens": ["AHREFS"], "base_url": "https://api.ahrefs.com/v3", "auth": {"shape": "bearer"}},
{"provider": "Apify", "tokens": ["APIFY"], "base_url": "https://api.apify.com/v2", "auth": {"shape": "bearer"}, "probe": "users/me"},
{"provider": "ScrapeCreators", "tokens": ["SCRAPECREATORS"], "base_url": "https://api.scrapecreators.com", "auth": {"shape": "api_key_header", "header": "x-api-key"}},
{"provider": "Sumble", "tokens": ["SUMBLE"], "base_url": "https://api.sumble.com/v9",
"auth": {"shape": "bearer"}},
{"provider": "Crustdata", "tokens": ["CRUSTDATA"], "base_url": "https://api.crustdata.com",
"auth": {"shape": "bearer"}, "probe": "account/credits",
"required_headers": {"x-api-version": "2025-11-01"}},
+9
View File
@@ -17,6 +17,7 @@ from sqlmodel import select
from .. import reconcile
from ..config import get_settings
from ..infra import kv
from ..infra.db import background_session_maker, get_admin_session
from ..domain import money
from ..models import ArchiveEndpointStat, ArchiveKey, ArchiveSnapshot, Bundle, CallRecord, LedgerEntry, Membership, Org, Referral, Secret, Tool, User
@@ -298,6 +299,14 @@ async def admin_health(_: str = Depends(require_superadmin), db: AsyncSession =
return out
@app.get("/admin/kv", include_in_schema=False)
async def admin_kv(_: str = Depends(require_superadmin)) -> dict:
"""Is the shared key-value store reachable? `configured` false means the in-process fallback
(no `TREG_KV_URL`); `reachable` false with it configured means every budgeted invitation is
currently withheld (infra/kv.py fails closed)."""
return {"configured": kv.configured(), "reachable": await kv.store().ping()}
# Rebind app so the second block keeps its decorator text and remains a separate attach point.
app = APIRouter()
reports_router = app
+171
View File
@@ -0,0 +1,171 @@
"""Standalone Arena page and authenticated application adapters."""
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from ..application import arena, arena_insights
from ..domain.arena import ArenaError
from ..domain.identity.access import Caller, require_member
from .auth import _client_ip
from .auth_helpers import _same_origin
router = APIRouter()
_WEB = Path(__file__).parent.parent / "web"
@router.get("/enrich-arena/people-search-bench", include_in_schema=False)
@router.get("/enrich-arena/leaderboard", include_in_schema=False)
@router.get("/enrich-arena", include_in_schema=False)
async def enrich_arena_page():
return FileResponse(_WEB / "enrich-arena.html", headers={"Cache-Control": "no-cache"})
@router.get("/enrich-arena/{asset}", include_in_schema=False)
async def enrich_arena_asset(asset: str):
if asset not in {"arena.css", "arena.js", "bench.js"}:
raise HTTPException(404)
return FileResponse(_WEB / "enrich-arena" / asset, headers={"Cache-Control": "no-cache"})
@router.get("/arena/tasks", include_in_schema=False)
async def arena_tasks():
return arena.public_tasks()
@router.get("/arena/insights", include_in_schema=False)
async def arena_insights_data():
return JSONResponse(await arena_insights.public_snapshot(), headers={"Cache-Control": "no-store"})
class PlanIn(BaseModel):
model_config = ConfigDict(extra="forbid")
capability: str = Field(max_length=80)
identity: dict[str, str] | None = Field(default=None, max_length=8)
identities: list[dict[str, str]] | None = Field(default=None, min_length=1, max_length=50)
auto_verify: bool = False
mode: str = Field(default="compare", max_length=20)
providers: list[str] | None = Field(default=None, max_length=20)
max_cost_micro: int = Field(default=1_000_000, ge=0, le=10_000_000, strict=True)
class VoteIn(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: str = Field(max_length=30)
selected: list[str] = Field(default_factory=list, max_length=20)
reasons: list[str] = Field(default_factory=list, max_length=7)
comment: str = Field(default="", max_length=1000)
def _guard(request):
if not _same_origin(request):
raise HTTPException(403, "Cross-origin Arena request rejected.")
async def _answer(awaitable):
try:
return JSONResponse(await awaitable, headers={"Cache-Control": "private, no-store"})
except ArenaError as exc:
raise HTTPException(exc.status, str(exc)) from exc
except arena.CallFailure as exc:
raise HTTPException(exc.status_code, exc.detail) from exc
@router.post("/arena/plans", include_in_schema=False)
async def arena_plan(request: Request, body: PlanIn, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.quote(caller, **body.model_dump()))
@router.post("/arena/runs/{run_id}/start", include_in_schema=False)
async def arena_start(run_id: str, request: Request, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.start(caller, run_id, request.app.state.http, _client_ip(request)))
@router.get("/arena/runs", include_in_schema=False)
async def arena_history(caller: Caller = Depends(require_member),
before: str | None = Query(default=None, max_length=32),
limit: int = Query(default=30, ge=1, le=100)):
return await _answer(arena.history(caller, before=before, limit=limit))
@router.get("/arena/runs/{run_id}", include_in_schema=False)
async def arena_run(run_id: str, caller: Caller = Depends(require_member)):
return await _answer(arena.get_run(caller, run_id))
@router.post("/arena/runs/{run_id}/cancel", include_in_schema=False)
async def arena_cancel(run_id: str, request: Request, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.cancel(caller, run_id))
@router.post("/arena/runs/{run_id}/evaluations", include_in_schema=False)
async def arena_evaluate(run_id: str, request: Request, body: VoteIn, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.evaluate(caller, run_id, **body.model_dump()))
@router.post("/arena/runs/{run_id}/reveal", include_in_schema=False)
async def arena_reveal(run_id: str, request: Request, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.evaluate(caller, run_id, kind="skip", selected=[], reasons=[], comment=""))
class ReportIn(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str = Field(default="", max_length=40)
comment: str = Field(default="", max_length=1000)
class ManualStartIn(BaseModel):
model_config = ConfigDict(extra="forbid")
quote_id: str = Field(max_length=80)
class RatingIn(BaseModel):
model_config = ConfigDict(extra="forbid")
value: str = Field(max_length=4)
@router.post("/arena/runs/{run_id}/attempts/{attempt_id}/rating", include_in_schema=False)
async def arena_rate(run_id: str, attempt_id: str, request: Request, body: RatingIn,
caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.rate_result(caller, run_id, attempt_id, **body.model_dump()))
@router.post("/arena/runs/{run_id}/attempts/{attempt_id}/report", include_in_schema=False)
async def arena_report(run_id: str, attempt_id: str, request: Request, body: ReportIn,
caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.report_result(caller, run_id, attempt_id, **body.model_dump()))
@router.post("/arena/runs/{run_id}/attempts/{attempt_id}/plan", include_in_schema=False)
async def arena_manual_plan(run_id: str, attempt_id: str, request: Request,
caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.manual_plan(caller, run_id, attempt_id))
@router.post("/arena/runs/{run_id}/attempts/{attempt_id}/start", include_in_schema=False)
async def arena_manual_start(run_id: str, attempt_id: str, request: Request, body: ManualStartIn,
caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.start_manual(caller, run_id, attempt_id, body.quote_id,
request.app.state.http, _client_ip(request)))
@router.post("/arena/runs/{run_id}/attempts/{attempt_id}/verification/plan", include_in_schema=False)
async def arena_verification_plan(run_id: str, attempt_id: str, request: Request, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.verification_plan(caller, run_id, attempt_id))
@router.post("/arena/runs/{run_id}/attempts/{attempt_id}/verification/start", include_in_schema=False)
async def arena_verification_start(run_id: str, attempt_id: str, request: Request, body: ManualStartIn, caller: Caller = Depends(require_member)):
_guard(request)
return await _answer(arena.start_verification(caller, run_id, attempt_id, body.quote_id, request.app.state.http, _client_ip(request)))
+47 -4
View File
@@ -5,12 +5,15 @@ from __future__ import annotations
import hashlib
import hmac
import re
from urllib.parse import parse_qsl, urlencode, urlsplit
from cryptography.fernet import InvalidToken
from fastapi import APIRouter, Cookie, Depends, Form, Header, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from .. import crypto
from ..application import auth as auth_use_cases
from ..application import signup
from ..application.auth import (
@@ -93,7 +96,8 @@ async def auth_email_verify(
cookie. The CLI reads the token from the body; the dashboard just reloads into session mode
(same path as GitHub login) — one endpoint serves both clients."""
try:
verified = await auth_use_cases.verify_email_login(body.email, body.code)
verified = await auth_use_cases.verify_email_login(body.email, body.code,
entry_surface=request.cookies.get("treg_entry_surface", ""))
except auth_use_cases.EmailAuthError as exc:
raise _email_http_error(exc) from exc
resp = JSONResponse({"token": verified.token, "email": verified.email})
@@ -206,7 +210,7 @@ def _social_http_error(exc: auth_use_cases.SocialLoginError) -> HTTPException:
# ---- human login via GitHub OAuth (dashboard sessions) ------------------------------------
@app.get("/auth/github")
async def auth_github(request: Request, cli: str = ""):
async def auth_github(request: Request, cli: str = "", return_to: str = ""):
try:
started = auth_use_cases.start_github_login(cli, lambda: _login_callback_base(request))
except auth_use_cases.SocialLoginError as exc:
@@ -214,6 +218,7 @@ async def auth_github(request: Request, cli: str = ""):
resp = RedirectResponse(started.url, status_code=302)
resp.set_cookie("treg_oauth_state", started.state, httponly=True, max_age=600,
samesite="lax", secure=_is_https(request))
_arena_login_return(resp, request, return_to if not cli else "")
return resp
@@ -228,17 +233,52 @@ def _auth_page(headline: str, sub: str = "", *, ok: bool = True, status: int = 2
return HTMLResponse(html, status_code=status)
def _arena_return_target(target: str) -> str:
"""Keep OAuth returns within Arena, including its selected task or saved run."""
if len(target) > 2048 or any(ord(c) < 32 or ord(c) == 127 or c == "\\" for c in target):
return ""
try:
url = urlsplit(target)
except ValueError:
return ""
if url.scheme or url.netloc or url.fragment or url.path not in {
"/enrich-arena", "/enrich-arena/leaderboard", "/enrich-arena/people-search-bench",
}:
return ""
params = parse_qsl(url.query, keep_blank_values=True)
if any(k not in {"run", "team", "capability", "variant", "mode"} for k, _ in params):
return ""
if len({k for k, _ in params}) != len(params):
return ""
return url.path + ("?" + urlencode(params) if params else "")
def _arena_login_return(resp, request: Request, target: str) -> None:
target = _arena_return_target(target)
if target:
resp.set_cookie("treg_arena_return", crypto.encrypt(target), httponly=True, max_age=600,
samesite="lax", secure=_is_https(request))
else:
resp.delete_cookie("treg_arena_return")
def _finish_oauth_login(request: Request, user: User, st: tuple | None) -> RedirectResponse:
"""After a GitHub/Google callback proves an identity: set the browser session cookie, then either
land on the dashboard (a plain browser login) or bounce to /login?cli=<id> so a `treg login`
handshake goes through the SAME team picker as the other doors (instead of completing blind — which
would leave the CLI guessing the org). The picker's POST /auth/cli/approve reads this same cookie."""
login_id = st[0] if st is not None else None
dest = f"/login?cli={login_id}" if login_id else "/app"
try:
target = crypto.decrypt(request.cookies.get("treg_arena_return", ""))
except (InvalidToken, ValueError, UnicodeError):
target = ""
browser_dest = _arena_return_target(target) or "/app"
dest = f"/login?cli={login_id}" if login_id else browser_dest
resp = RedirectResponse(dest, status_code=302)
resp.set_cookie(sess.COOKIE, sess.make_session(user.id, token_version=user.token_version), httponly=True,
samesite="lax", secure=_is_https(request), max_age=sess.TTL_SECONDS)
resp.delete_cookie("treg_oauth_state")
resp.delete_cookie("treg_arena_return")
return resp
@@ -258,6 +298,7 @@ async def auth_github_callback(
proof = await auth_use_cases.complete_github_login(
lambda: request.app.state.http, code, state, treg_oauth_state,
lambda: _login_callback_base(request),
entry_surface=request.cookies.get("treg_entry_surface", ""),
)
except auth_use_cases.SocialLoginError as exc:
return _social_login_failure(exc)
@@ -265,7 +306,7 @@ async def auth_github_callback(
@app.get("/auth/google")
async def auth_google(request: Request, cli: str = ""):
async def auth_google(request: Request, cli: str = "", return_to: str = ""):
"""Human login via Google OAuth — a parallel door to GitHub, same session/CLI-handshake plumbing."""
try:
started = auth_use_cases.start_google_login(cli, lambda: _login_callback_base(request))
@@ -274,6 +315,7 @@ async def auth_google(request: Request, cli: str = ""):
resp = RedirectResponse(started.url, status_code=302)
resp.set_cookie("treg_oauth_state", started.state, httponly=True, max_age=600,
samesite="lax", secure=_is_https(request))
_arena_login_return(resp, request, return_to if not cli else "")
return resp
@@ -286,6 +328,7 @@ async def auth_google_callback(
proof = await auth_use_cases.complete_google_login(
lambda: request.app.state.http, code, state, treg_oauth_state,
lambda: _login_callback_base(request),
entry_surface=request.cookies.get("treg_entry_surface", ""),
)
except auth_use_cases.SocialLoginError as exc:
return _social_login_failure(exc)
+3 -1
View File
@@ -97,6 +97,7 @@ app = APIRouter()
class TopupIn(BaseModel):
amount_usd: float | None = None
checkout_source: str = ""
class AutoTopupIn(BaseModel):
@@ -155,7 +156,8 @@ async def billing_topup(
org = _billing_org(caller)
try:
return await billing.start_topup(
org.id, body.amount_usd, return_base=_return_base(request), email=caller.email)
org.id, body.amount_usd, return_base=_return_base(request), email=caller.email,
entry_surface=request.cookies.get("treg_entry_surface", ""), checkout_source=body.checkout_source)
except billing.BillingJourneyError as e:
raise _translate_billing_error(e) from e
+22 -9
View File
@@ -12,7 +12,7 @@ from sqlalchemy.exc import TimeoutError as PoolTimeoutError
from starlette.background import BackgroundTask
from sqlalchemy.ext.asyncio import AsyncSession
from .. import analytics, hints
from .. import analytics
from .. import audit
from .. import sandbox as demo_sandbox
from ..application.call.access import catalog_endpoint_access as get_catalog_endpoint_access
@@ -29,6 +29,7 @@ from ..application.call.service import (
create_call_context,
execute_call,
)
from ..application.call.invite import invitation
from ..application.call.intake import (
META_HEADER,
CallMeta,
@@ -265,6 +266,17 @@ async def catalog_endpoint_access(
except CallFailure as exc:
raise _translate_call_failure(exc) from exc
def _capture_hint(request: Request, context, kind: str) -> None:
"""One event per invitation actually sent, on every surface: the response-rate denominator.
Attachment is not display; the surface's `X-Treg-Client` says who was asked."""
marketplace = context.marketplace
slug = context.input.caller.org.slug
analytics.capture(analytics.SERVER_DISTINCT_ID, "hint_attached", {
"kind": kind, "call_id": context.call_ref, "client": _client_of(request),
"endpoint_id": marketplace.endpoint_id if marketplace is not None else None,
}, groups={"team": slug} if slug else None)
@app.api_route(
"/call/{rest:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
@@ -311,15 +323,16 @@ async def call_tool(
_attach_async_descriptor(upstream, context, rest)
response = _http_upstream_response(upstream)
try:
# Phase 1 invites only direct catalog calls served on treg's platform key.
if (context.marketplace is not None and context.marketplace.tier == "platform"
and 200 <= response.status_code < 300
and not response.headers.get("X-Treg-Idempotent-Replay")
and not context.cached
and hints.sampled("review", context.call_ref)):
response.headers["X-Treg-Review"] = "requested"
# Optional invitation, decided before the body streams (application/call/invite.py).
kind = await invitation(context, response.status_code,
replayed=bool(response.headers.get("X-Treg-Idempotent-Replay")))
if kind is not None:
response.headers["X-Treg-Hint"] = kind
if kind == "review":
response.headers["X-Treg-Review"] = "requested" # read by CLI <= 0.18
_capture_hint(request, context, kind)
except Exception:
pass # Optional invitation: a fault can only lose the header.
pass # A fault here can only lose the header; the answer is already built.
return response
except CallFailure as exc:
raise _translate_call_failure(exc) from exc
+4 -1
View File
@@ -131,7 +131,10 @@ async def catalog_platform(slug: str, include_hidden: int = 0) -> dict:
# every row — an expanded endpoint needs them and shouldn't cost a second request.
"providers": {
service: {"service": service, "display_name": _provider_display(service),
**cat.provider_meta.get(service, {})}
**cat.provider_meta.get(service, {}),
"auth_kind": getattr(oauth_providers.get(service), "auth_kind", None),
"metered": bool(getattr(oauth_providers.get(service), "platform_billed", False)
and service in get_settings().oauth_billed_set)}
for service in sorted({ep["provider"] for ep in eps})
},
}
+95 -25
View File
@@ -82,6 +82,9 @@ def _price_label(cost: dict | None) -> str:
whole CLI in for one string (see `_treg_version`)."""
if not isinstance(cost, dict):
return ""
if cost.get("display_unit") and cost.get("display_usd") is not None:
return (_usd_short(cost["display_usd"]) + cost.get("display_suffix", "")
+ "/" + cost["display_unit"])
usd = cost.get("usd")
if usd is None:
return "own account" # no rate published — never invent a dollar figure
@@ -1984,11 +1987,13 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
all_eps = cat.for_provider(service)
if not all_eps:
raise HTTPException(status_code=404, detail=f"unknown provider {service!r}")
# Full provider inventory includes helpers and account management; the browse census
# deliberately excludes them. Access labels below distinguish platform offers from BYOK.
# Fresh name on purpose: from here on the page prints the CATALOG's spelling of the provider,
# never the request's. (Same idiom as the use-case pages; it is also what reads as a taint
# kill to CodeQL, which cannot see _esc_html as a sanitizer.)
svc = all_eps[0]["provider"]
eps = [e for e in all_eps if _pub(e)] or [e for e in all_eps if e.get("kind") != "routed"]
eps = [e for e in all_eps if e.get("kind") != "routed" and not e.get("status")]
if not eps:
# The first-party "treg" pseudo-provider is nothing but routed meta-rows. Without this a
# self-referential /tools/treg page rendered (and reached the sitemap) — the fallback above
@@ -2002,11 +2007,14 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
blurb = (getattr(reg, "summary", "") or "") if reg else ""
base_api = (getattr(reg, "base_url", "") or "") if reg else ""
docs_url = (getattr(reg, "docs_url", "") or "") if reg else ""
prices = [c for e in eps if (c := cat.cost_view(e.get("cost"), e.get("provider"))) and c["usd"]]
# Own-account vs metered is read off the INVENTORY, not the credential registry: nearly every
# provider is in oauth_providers (that is how a team registers its own key), but only a
# provider with no priced endpoint at all is genuinely connect-your-own-account.
is_oauth = not prices
platform_eps = [e for e in eps if cat.platform_eligible(e)]
byok_only = len(eps) - len(platform_eps)
key_auth = bool(reg and reg.auth_kind == "key")
oauth_metered = bool(reg and reg.platform_billed and svc in get_settings().oauth_billed_set)
mixed = bool(platform_eps and byok_only and key_auth)
prices = [c for e in platform_eps if (c := cat.cost_view(e.get("cost"), e.get("provider"))) and c["usd"]]
# Published prices do not remove the OAuth account-connection requirement.
is_oauth = bool(reg and reg.auth_kind == "oauth") or not platform_eps
cheapest = _price_label(min(prices, key=lambda c: c["usd"])) if prices else ""
verified = len([e for e in eps if e["verified"]])
plat_label = {sl: pl["label"] for sl, pl in cat.platforms.items()}
@@ -2029,9 +2037,9 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
task_lines.append(d[0].lower() + d[1:])
if len(task_lines) == 3:
break
sample_eps = sorted([e for e in eps if e["verified"]], key=lambda e: len(e["id"])) or eps
sample_eps = sorted([e for e in (platform_eps or eps) if e["verified"]], key=lambda e: len(e["id"])) or platform_eps or eps
sample_id = sample_eps[0]["id"]
badge = "YOUR ACCOUNT" if is_oauth else "NO SIGNUP"
badge = "PLATFORM + BYOK" if mixed else ("YOUR ACCOUNT" if is_oauth else "NO SIGNUP")
# The measured line: what treg.to has actually observed calling this provider. It is the one
# thing a vendor's own pricing page cannot print, and it goes above the fold for that reason.
obs = await _observed_or_empty(observations, [e["id"] for e in eps])
@@ -2074,6 +2082,16 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
h1_text = (f"{esc_d}: connect your own account" if is_oauth
else (f"{esc_d}: {len(eps)} tools from {_esc_html(cheapest)}" if cheapest
else f"{esc_d}: {len(eps)} tools"))
if mixed:
kicker = f"{len(eps)} tools · {len(platform_eps)} platform + BYOK · {byok_only} BYOK only"
h1_text = f"{esc_d}: {len(eps)} tools, platform or your own key"
lede = (f"{_esc_html(blurb)} {len(platform_eps)} tools support treg's platform key. "
f"All {len(eps)} support your own {esc_d} key; {byok_only} require it. "
"Your own key always wins and treg does not meter those calls.")
if oauth_metered:
kicker = f"{len(eps)} tools · OAuth connection · metered"
lede = (f"{_esc_html(blurb)} Connect your own {esc_d} account. "
"Calls through treg's OAuth app are metered under this server's billing policy.")
hero = (
'<div class="hero"><div class="wrap">'
'<div class="trust" style="margin:0 0 18px"><a href="/">treg.to</a> / '
@@ -2085,7 +2103,7 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
f'<a class="ghostbtn" href="#tools">See all {len(eps)} tools</a>'
+ (f'<a class="ghostbtn" href="{_esc_html(docs_url)}" target="_blank" rel="noopener">API docs ↗</a>'
if docs_url else "") + "</div>"
'<div class="trust">$1.00 of free credit on every new team · no provider signup · no card</div>'
'<div class="trust">$1.00 of free credit on every new team · no card · platform tools need no provider signup</div>'
+ (f'<div class="subline">{verified} of {len(eps)} tools on this page are live-verified '
"against the provider.</div>" if verified else "")
+ f'<div class="provstrip"><div class="pl">works in</div><div class="ptiles">{_agent_ptiles()}</div></div>'
@@ -2116,7 +2134,7 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
f'<b>{esc_d}</b><span class="bdg">{badge}</span></div>'
f'<div class="sm">{_esc_html(blurb) or esc_d + " through one treg.to token."}</div>'
f'<div class="ct">⚒ {len(eps)} TOOLS'
+ ("" if is_oauth else " · metered per call") + "</div></div>"
+ (" · platform + BYOK" if mixed else ("" if is_oauth else " · metered per call")) + "</div></div>"
"</div>"
+ (f'<p style="font-size:12.5px;color:var(--muted);margin-top:12px">{_esc_html(category)}'
f"{' · ' if category and base_api else ''}<code>{_esc_html(base_api)}</code></p>"
@@ -2159,8 +2177,7 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
'<h3>Run one directly</h3>'
'<div class="sample"><div class="sbar">'
+ ("a live-verified call" if sample_eps[0]["verified"] else "a call") + "</div>"
"<pre>curl -H \"Authorization: Bearer $TREG_TOKEN\" \\\n"
f" \"{_esc_html(base)}/call/{_esc_html(sample_id)}\"</pre></div>"
f"<pre>{_esc_html(catalog_store.call_template(sample_eps[0]).replace('https://treg.to', base))}</pre></div>"
"</div></section>")
alt_names = sorted({e["provider"] for e in cat.endpoints
@@ -2176,30 +2193,50 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
"metered per call from a prepaid balance.</p></div>")
+ '<div class="card"><h4>Price before the call</h4><p>The provider&#x27;s own rate, $0.000 '
'markup. <a href="/pricing">How billing works</a>.</p></div>'
'<div class="card"><h4>No subscription, no seats</h4><p>Charged per call. $1.00 free per '
"new team, no card to start.</p></div>"
f'<div class="card"><h4>Your own {esc_d} key is free</h4><p>Register it and those calls are '
"never metered. Your key always wins.</p></div>"
'<div class="card"><h4>Switch by changing a word</h4><p>Another provider is a different '
+ ('<div class="card"><h4>Account billing</h4><p>'
+ ("Calls through treg's OAuth app are metered under this server's billing policy."
if oauth_metered else "Calls through your own connection are not metered by treg.")
+ '</p></div>' if is_oauth else
'<div class="card"><h4>No subscription, no seats</h4><p>Platform calls are charged per call. '
'$1.00 free per new team, no card to start.</p></div>')
+ (f'<div class="card"><h4>Your own {esc_d} key is free</h4><p>Register it and those calls are '
"never metered. Your key always wins.</p></div>" if key_auth else
'<div class="card"><h4>Your account connection</h4><p>'
+ ("Calls through treg's OAuth app are metered under this server's billing policy."
if oauth_metered else "Connect your account with the provider's supported authentication method.")
+ '</p></div>')
+ '<div class="card"><h4>Switch by changing a word</h4><p>Another provider is a different '
"word in the prompt, not a new integration.</p></div>"
'<div class="card"><h4>One key, the whole catalog</h4><p>The same token calls '
+ (_esc_html(", ".join(_provider_display(a) for a in alt_names[:3])) if alt_names
else "every provider in the catalog")
+ f" and {_catalog_census()[0] - len(eps):,} other tools.</p></div>"
+ f" and {_catalog_census()[0] - sum(_pub(e) for e in eps):,} other tools.</p></div>"
"</div></div></section>")
tool_blocks = []
max_shown = 8
max_shown = len(eps) if len(eps) <= 50 else 8
for i, (slug, items) in enumerate(sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0]))):
lis = []
shown = sorted(items, key=lambda e: (not e.get("verified"), e["id"]))[:max_shown]
for e in shown:
price = _price_label(cat.cost_view(e.get("cost"), e.get("provider")))
bits = [b for b in ("live-verified" if e.get("verified") else "", _esc_html(price)) if b]
eligible = cat.platform_eligible(e)
if reg and reg.auth_kind == "oauth":
access = "OAuth connection · metered" if oauth_metered else "OAuth connection"
elif key_auth:
access = "Platform + BYOK" if eligible else "BYOK only"
if not eligible:
price = "No treg charge; upstream " + price
else:
access = "Platform access" if eligible else "Own connection required"
kind = "account management" if e.get("kind") == "account" else ("helper" if e.get("kind") == "utility" else "")
bits = [b for b in (access, kind, "live-verified" if e.get("verified") else "", _esc_html(price)) if b]
lis.append(f"<li><b>{_esc_html(e['name'])}</b>"
+ (f" · <small>{' · '.join(bits)}</small>" if bits else "")
+ f"<br/><small>{_esc_html(e.get('summary') or '')} "
f"<code>{_esc_html(e['id'])}</code></small></li>")
f"<code>{_esc_html(e['id'])}</code></small>"
+ (f"<br/><small>{_esc_html(e['cost']['note'])}</small>"
if (e.get("cost") or {}).get("note") else "") + "</li>")
if len(items) > max_shown:
lis.append(f'<li class="more"><a href="/catalog/{_esc_html(slug)}">See all {len(items)} '
f'{_esc_html(plat_label.get(slug, slug))} tools on the catalog →</a></li>')
@@ -2211,7 +2248,7 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
'<section id="tools"><div class="wrap"><div class="seclab">The shelf</div>'
f"<h2>All {len(eps)} {esc_d} tools</h2>{''.join(tool_blocks)}"
'<p style="font-size:12.5px;color:var(--muted)">Reliability badges come from live traffic '
"through treg.to, not a controlled benchmark.</p></div></section>")
"through treg.to. Live-verified means a successful provider check; it does not verify every request variant.</p></div></section>")
alt_sec = ""
if alt_names:
@@ -2247,6 +2284,14 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
f"from a prepaid balance. It is not {display}'s subscription pricing, which is on "
"their own site. New teams start with $1.00 of free credit."),
]
if oauth_metered:
faq_items[0] = (f"Do I need a {display} account?",
f"Yes. Connect your {display} account through OAuth. Calls through treg's "
"OAuth app are metered under this server's billing policy.")
if mixed:
faq_items[0] = (f"Do I need a {display} account?",
f"For {len(platform_eps)} platform tools, no. The other {byok_only} require your own "
f"{display} key. All {len(eps)} accept your own key, and treg never meters BYOK calls.")
faq_items += [
(f"How do I add {display} to Claude Code?",
f"Run: claude mcp add --transport http treg {base}/mcp. One MCP server carries "
@@ -2304,6 +2349,17 @@ async def tools_provider(service: str, db: AsyncSession = Depends(get_session),
f"{'from ' + cheapest + ' ' if cheapest else ''}through one treg.to key or MCP server"
f"{', ' + measured if measured else ''}. Use it from Claude Code, ChatGPT or any agent.")
if mixed:
title = f"{display} API pricing: {cheapest}, platform + BYOK | treg.to"
if len(title) > _TITLE_MAX:
title = f"{display} API pricing: {cheapest} | treg.to"
if len(title) > _TITLE_MAX:
title = f"{display} API pricing: platform + BYOK | treg.to"
desc = (f"{display} on treg: {len(platform_eps)} tools with platform or your own key, "
f"{byok_only} BYOK only. Compare access, billing units and live verification for every tool.")
if oauth_metered:
desc = (f"Use {display} through your OAuth connection. Calls through treg's OAuth app "
"are metered under this server's billing policy.")
ld = [
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [
{"@type": "ListItem", "position": 1, "name": "treg.to", "item": base + "/"},
@@ -2683,7 +2739,7 @@ async def llms_txt():
if not f.exists():
raise HTTPException(status_code=404, detail="llms.txt not bundled")
base = get_settings().public_url.rstrip("/")
return PlainTextResponse(_strip_routed(f.read_text(encoding="utf-8")).replace("{BASE}", base),
return PlainTextResponse(_fill_headline(_strip_routed(f.read_text(encoding="utf-8"))).replace("{BASE}", base),
media_type="text/plain; charset=utf-8")
@@ -2864,6 +2920,14 @@ def _strip_routed(text: str) -> str:
return re.sub(r"<!--routed-->.*?<!--/routed-->\n?", "", text, flags=re.S)
def _fill_headline(text: str) -> str:
"""`{ENDPOINTS}` and `{PROVIDERS}` in a served document come from the loaded catalog, like
`{BASE}` comes from settings: the front-door files quote the catalog's size and a typed number
was always stale (see `catalog_store.headline_counts`)."""
endpoints, providers = catalog_store.headline_counts(catalog_store.load())
return text.replace("{ENDPOINTS}", endpoints).replace("{PROVIDERS}", str(providers))
def _serve_md(name: str) -> PlainTextResponse:
"""Serve a bundled markdown file as inline text (so "open in new tab" shows it, not a download),
with the serving domain templated in. Backs the 'copy markdown' buttons on the docs pages."""
@@ -2871,7 +2935,7 @@ def _serve_md(name: str) -> PlainTextResponse:
if not f.exists():
raise HTTPException(status_code=404, detail=f"{name} not bundled")
base = get_settings().public_url.rstrip("/")
return PlainTextResponse(_strip_routed(f.read_text(encoding="utf-8")).replace("{BASE}", base),
return PlainTextResponse(_fill_headline(_strip_routed(f.read_text(encoding="utf-8"))).replace("{BASE}", base),
media_type="text/plain; charset=utf-8")
@@ -3004,6 +3068,12 @@ async def claude_connector_page():
return _legal_page("claude-connector.html")
@app.get("/agent-setup.js", include_in_schema=False)
async def agent_setup_js():
return FileResponse(_WEB_DIR / "agent-setup.js", media_type="application/javascript",
headers={"Cache-Control": "no-cache"})
@app.get("/adtrack.js", include_in_schema=False)
async def adtrack_js():
"""First-party ad-click capture (see the file itself): sets the `treg_ad` cookie that
@@ -3140,7 +3210,7 @@ def _skill_frontmatter() -> dict[str, str]:
f = _WEB_DIR / "skill.md"
if not f.exists():
raise HTTPException(status_code=404, detail="skill.md not bundled")
text = f.read_text(encoding="utf-8")
text = _fill_headline(f.read_text(encoding="utf-8"))
if not text.startswith("---"):
raise HTTPException(status_code=404, detail="skill.md has no frontmatter")
out: dict[str, str] = {}
+83
View File
@@ -0,0 +1,83 @@
/* Shared by the dashboard welcome flow and Enrich Arena. No credentials are stored here. */
(function(global){
const agents=[
{id:'openclaw',name:'OpenClaw',icon:'openclaw-color'},
{id:'grokbot',name:'Grok Bot',icon:'/logos/agents/grokbot.png',plugin:'https://x.ai/bot/plugin/55647425'},
{id:'hermes',name:'Hermes Agent',icon:'hermesagent'},
{id:'claudeai',name:'Claude.ai',icon:'claude-color'},
{id:'claude-code',name:'Claude Code',icon:'claudecode-color'},
{id:'codex',name:'Codex',icon:'codex-color'},
];
const moreAgents=[
{id:'opencode',name:'opencode',icon:'opencode'},
{id:'pi',name:'pi',icon:'pi'},
{id:'cursor',name:'Cursor',icon:'cursor'},
{id:'gemini-cli',name:'Gemini CLI',icon:'gemini-color'},
{id:'other',name:'Other',icon:null},
];
const iconUrl=(icon,theme='light')=>icon?.startsWith('/')?icon:icon?'https://unpkg.com/@lobehub/icons-static-png@latest/'+theme+'/'+icon+'.png':'';
const command=base=>'set up treg — '+base.replace(/\/$/,'')+'/llms.txt';
function setupText(command,team,token,masked=false){
if(!team&&!token)return command;
const value=token?(masked?token.slice(0,14)+'••••••••••••••••':token):'<YOUR_TOKEN>';
return command+'\n\nwith team '+(team||'<team-slug>')+' token: '+value;
}
const AgentPicker={
props:{modelValue:String,icon:{type:Function,default:iconUrl}},emits:['update:modelValue'],data:()=>({moreOpen:false}),
computed:{agents:()=>agents,moreAgents:()=>moreAgents,moreSelected(){return moreAgents.find(a=>a.id===this.modelValue);}},
methods:{pick(id){this.$emit('update:modelValue',id);this.moreOpen=false;}},
template:`<div><div class="agent-grid">
<button v-for="a in agents" :key="a.id" type="button" class="agent-card" :class="{on:modelValue===a.id}" :aria-pressed="modelValue===a.id" @click="pick(a.id)"><img :src="icon(a.icon)" alt="" @error="$event.target.style.visibility='hidden'"><span>{{a.name}}</span></button>
<button type="button" class="agent-card" :class="{on:moreOpen||moreSelected}" :aria-expanded="moreOpen" @click="moreOpen=!moreOpen"><span aria-hidden="true">☰</span><span>{{moreSelected&&!moreOpen?moreSelected.name:'More'}}</span><span aria-hidden="true" style="margin-left:auto">▾</span></button>
</div><div v-if="moreOpen" class="agent-grid" style="margin-top:10px">
<button v-for="a in moreAgents" :key="a.id" type="button" class="agent-card" :class="{on:modelValue===a.id}" :aria-pressed="modelValue===a.id" @click="pick(a.id)"><img v-if="a.icon" :src="icon(a.icon)" alt="" @error="$event.target.style.visibility='hidden'"><span v-else aria-hidden="true">✳</span><span>{{a.name}}</span></button>
</div></div>`
};
const SetupInstructions={
props:{agent:Object,icon:{type:Function,default:iconUrl},command:String,team:String,token:String,showToken:Boolean,copied:Boolean,copyDisabled:Boolean},
emits:['copy','toggle-token','plugin'],
computed:{full(){return setupText(this.command,this.team,this.token);},masked(){return setupText(this.command,this.team,this.token,!this.showToken);}},
template:`<div class="agent-setup-instructions">
<p class="sub" style="display:flex;align-items:center;gap:8px;margin:0 0 16px"><img v-if="agent.icon" :src="icon(agent.icon)" alt="" style="width:18px;height:18px" @error="$event.target.style.visibility='hidden'">Setting up treg for <b style="color:var(--ink)">{{agent.name}}</b></p>
<template v-if="agent.plugin"><h2 style="margin:0 0 10px;font-size:19px">1. Install the treg plugin</h2><a class="btn" :href="agent.plugin" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:8px;margin-bottom:18px" @click="$emit('plugin')"><img :src="icon(agent.icon)" alt="" style="width:16px;height:16px">Install plugin in {{agent.name}} ↗</a></template>
<div class="lc-codewrap" style="padding:16px"><div class="setup-prompt-header" style="display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px"><h2 style="margin:0;font-size:14px;font-weight:500;line-height:1.5;color:inherit">{{agent.plugin ? "2. In your Bot's chat, send:" : "In your agent's chat, send:"}}</h2><button class="lc-cp" style="position:static;flex-shrink:0" type="button" :disabled="copyDisabled" @click="$emit('copy',full)">{{copied?'✓ copied':'Copy'}}</button></div><pre style="white-space:pre-wrap;overflow-wrap:anywhere">{{masked}}</pre></div>
<button v-if="token" class="text-button" type="button" style="font-size:12px;margin-top:6px" @click="$emit('toggle-token')">{{showToken?'Hide':'Show'}} key</button>
<p class="sub" style="font-size:12px;margin:12px 0 0">{{team&&token ? (agent.plugin?'Your Bot reads that file and signs in with your team & token — then it can call every tool in the catalog.':'Your agent reads that file and signs in with your team & token — it installs the CLI and starts calling tools.') : 'Your agent reads that file, installs the CLI and guides you through signing in.'}}</p>
</div>`
};
const examples=[
{k:'trend',cat:'Trending videos pattern', logo:'tiktok', prompt:'Use treg to pull today\'s trending TikTok videos (video links included)'},
{k:'enr', cat:'Get contact emails', logo:'people', avatar:'https://pbs.twimg.com/profile_images/1131851609774985216/OcsssQ9J_400x400.png', prompt:'Use treg to find the work email of Peter Steinberger'},
{k:'serp', cat:'Keyword volume', logo:'google', prompt:'Use treg to pull real monthly search volume and top related keywords worth targeting for my business'},
{k:'soc', cat:'Scrape linkedin', logo:'linkedin',prompt:'Use treg to look up linkedin.com/in/jasonzhoudesign'},
];
const oauthGroups=[
{label:'Post on social', items:[{s:'x',n:'X (Twitter)'},{s:'youtube',n:'YouTube'},{s:'tiktok',n:'TikTok'},{s:'linkedin',n:'LinkedIn'},{s:'facebook',n:'Facebook Pages'},{s:'instagram',n:'Instagram'}],
soon:[]},
{label:'Manage ad campaigns', items:[{s:'google-ads',n:'Google Ads'},{s:'meta-ads',n:'Meta Ads'}], soon:[]},
{label:'SEO on your own site',items:[{s:'google-analytics',n:'Google Analytics'},{s:'google-search-console',n:'Search Console'},{s:'google-business-profile',n:'Business Profile'}], soon:[]},
];
const TryItOut={
props:{copied:String,headingId:String},emits:['example','provider'],
computed:{examples:()=>examples,oauthGroups:()=>oauthGroups},
template:`<div>
<h2 :id="headingId" style="margin:0 0 6px;font-size:19px">Try it out</h2>
<div class="wc-wait" style="margin:0 0 16px"><span class="wc-waitdot"></span>Waiting for your agent — copy an example below and send it to get your first result.</div>
<div class="try-grid">
<button v-for="ex in examples" :key="ex.k" type="button" class="try-card" @click="$emit('example',ex)">
<span class="try-cat"><span style="display:inline-flex;align-items:center;gap:7px"><img class="try-ico" :src="'/logos/platforms/'+ex.logo+'.svg'" alt=""/><img v-if="ex.avatar" class="try-ico avatar" :src="ex.avatar" alt=""/>{{ex.cat}}</span><span class="try-copy" :class="{done:copied===ex.k}">{{copied===ex.k ? '✓ copied' : '⧉ copy'}}</span></span>
<span class="try-txt">{{ex.prompt}}</span>
</button>
</div>
<div class="oauth-div"><span>also connect OAuth to unlock new agent capabilities</span></div>
<div v-for="g in oauthGroups" :key="g.label" style="margin-top:12px">
<div class="oauth-grp">{{g.label}}</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button v-for="p in g.items" :key="p.s" type="button" class="prov-chip" @click="$emit('provider',p.s,g.label)"><img :src="'/logos/'+p.s+'.svg'" alt=""/>{{p.n}}</button>
<span v-if="g.soon.length" class="soon-note" :data-tip="g.soon.map(p=>p.n).join(', ')">{{g.soon.length}} coming soon</span>
</div>
</div>
</div>`
};
global.TregAgentSetup={agents,moreAgents,iconUrl,command,setupText,AgentPicker,SetupInstructions,examples,oauthGroups,TryItOut};
})(globalThis);
+384
View File
@@ -0,0 +1,384 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Enrich Arena — compare the answers</title>
<meta name="description" content="One enrichment query. Independent answers. Compare enrichment results, vendors, cost and speed, and vote for the best answer.">
<link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/enrich-arena/arena.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&amp;family=Geist+Pixel&amp;family=Google+Sans+Flex:opsz,wght@6..144,400..700&amp;display=swap">
<script src="/adtrack.js"></script>
<script src="/vendor/vue-3.5.41.global.prod.js" defer></script>
<script src="/agent-setup.js" defer></script>
<script src="/enrich-arena/bench.js" defer></script>
<script src="/enrich-arena/arena.js" defer></script>
</head>
<body>
<div id="arena" v-cloak class="ph-no-capture ph-hide" :class="{'is-leaderboard':leaderboard,'is-benchmark':benchmark}">
<header class="masthead">
<a href="/" class="brand" aria-label="treg home"><svg class="brandmark" viewBox="0 0 512 512" aria-hidden="true"><rect width="512" height="512" rx="112" fill="#101010"/><rect x="111" y="111" width="140.5" height="140.5" rx="20" fill="#fff"/><rect x="260.5" y="260.5" width="140.5" height="140.5" rx="20" fill="#fff"/></svg><span class="brand-name">treg</span><span class="brand-divider">/</span><span class="brand-sub">Enrich Arena</span></a>
<nav class="arena-page-tabs" aria-label="Arena pages">
<a :href="sectionUrl(false)" :aria-current="!leaderboard&&!benchmark?'page':null"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 3 13 13M3 3h4L17 13v4h-4L3 7V3Zm10 0h4v4l-5 5M7 12l-4 4m-1-3 5 5m6-1 4-4"/></svg>Arena</a>
<a :href="sectionUrl(true)" :aria-current="leaderboard?'page':null" @click="saveDraft(false)"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17H3V9h4m0 8h6V3H7v14Zm6-5h4v5h-4"/></svg>Leaderboard</a>
<a href="/enrich-arena/people-search-bench" :aria-current="benchmark?'page':null" @click="saveDraft(false)"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M5 2h10v16H5zM8 6h4M8 10h4M8 14h2"/></svg>Benchmark</a>
<a href="https://treg.to" @click="saveDraft(false)">Treg <span aria-hidden="true">↗</span></a>
</nav>
<nav aria-label="Arena account">
<span class="arena-community" role="group" aria-label="Community links">
<a href="https://github.com/superdesigndev/treg" target="_blank" rel="noopener" aria-label="Open source on GitHub" title="Open source on GitHub"><svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg><span>Open source</span></a>
<a href="https://discord.gg/6mQYYfFMAn" target="_blank" rel="noopener" aria-label="treg on Discord" title="treg on Discord"><svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.058a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418Z"/></svg></a>
<a href="https://x.com/treg_ai" target="_blank" rel="noopener" aria-label="treg on X (Twitter)" title="treg on X (Twitter)"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.451-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117l11.966 15.644Z"/></svg></a>
</span>
<template v-if="user">
<label v-if="teams.length > 1" class="team-switcher"><span>Team</span><select aria-label="Team paying for runs" v-model="team" @change="changeTeam" :disabled="running || busy"><option v-for="t in teams" :value="t.slug">{{t.name}}</option></select></label>
<button class="balance" v-if="balance !== null" type="button" @click="topUp" :aria-label="'Top up credits · balance '+usd(balance)" title="Top up credits">{{usd(balance)}} <span>credits</span></button>
<button class="text-button" @click="logout" :disabled="running || busy">Sign out</button>
</template>
<button v-else class="sign-in-button" @click="openLogin(false)">Sign up <span aria-hidden="true">↗</span></button>
</nav>
</header>
<div class="arena-layout" :class="{'has-history':!leaderboard && !benchmark && user && history.length}">
<aside v-if="!leaderboard && !benchmark && user && history.length" class="session-sidebar" aria-label="Query sessions">
<button class="new-session" @click="newSession" :disabled="busy || running"><span aria-hidden="true">+</span> New query</button>
<div class="session-list" role="navigation" aria-label="Previous queries">
<button v-for="h in history" :key="h.id" class="session-item" :class="{active:run?.id===h.id}" :aria-current="run?.id===h.id?'page':null" :title="Object.values(h.identity).join(' · ')" :disabled="busy || running" @click="loadHistory(h.id)">
<span class="session-title">{{h.entry_count>1?h.entry_count+' entries · ':''}}{{Object.values(h.identity).join(' · ')}}</span>
<span class="session-meta">{{taskLabel(h.capability)}} · {{h.mode==='compare'?'Battle':'Waterfall'}}</span>
<span class="session-date">{{historyDate(h.created_at)}}<span v-if="h.state==='running'"> · Running</span></span>
</button>
<button v-if="historyHasMore" class="history-load-more" type="button" @click="loadMoreHistory" :disabled="historyLoading || busy || running">{{historyLoading?'Loading…':'Load more'}}</button>
</div>
</aside>
<main>
<section v-if="!leaderboard && !benchmark" class="intro">
<span class="intro-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor" shape-rendering="crispEdges"><path d="M4 3h3v3h3v3h3v3h-3v-2H7V7H4zM17 3h3v4h-3v3h-3v2h-3V9h3V6h3zM5 13h3v3h3v3H8v-2H5v3H2v-3h3zM16 13h3v4h3v3h-3v-3h-3v2h-3v-3h3z"/></svg></span>
<div class="intro-title"><h1>{{leaderboard?'Enrichment Leaderboard':'Enrich Arena'}}</h1><div class="powered-by"><span>Powered by</span><svg viewBox="0 0 512 512" aria-hidden="true"><rect width="512" height="512" rx="112" fill="#101010"/><rect x="111" y="111" width="140.5" height="140.5" rx="20" fill="#fff"/><rect x="260.5" y="260.5" width="140.5" height="140.5" rx="20" fill="#fff"/></svg><span class="powered-by-name">Treg</span></div></div>
<button class="setup-trigger" type="button" @click="openSetup" aria-label="Setup treg in Claude Code, Codex, OpenClaw, Hermes and more agents"><span>Setup treg in</span><span class="setup-logos" aria-hidden="true"><img v-for="agent in setupFeatured" :key="agent.id" :src="setupIcon(agent.icon)" :title="agent.name" alt=""><span>+{{setupOtherCount}}</span></span></button>
</section>
<section v-if="benchmark" class="people-bench" aria-label="People Search Bench">
<div class="leaderboard-title bench-title"><div><h1>People Search Bench</h1><p>How well do agents find the right people?</p></div><button class="setup-trigger" type="button" @click="openSetup">Setup treg <span aria-hidden="true">↗</span></button></div>
<p class="bench-source">Published results from <a href="https://treg.to/people-search#bench" target="_blank" rel="noopener">treg’s people-search benchmark ↗</a></p>
<div v-if="benchLoading" class="bench-message" role="status">Loading benchmark results…</div>
<div v-else-if="benchError" class="bench-message" role="alert"><p>{{benchError}}</p><button class="text-button" @click="loadBenchmark">Try again</button> · <a href="https://treg.to/people-search#bench" target="_blank" rel="noopener">Open source ↗</a></div>
<template v-else-if="benchSelected">
<div class="bench-category-tabs" role="group" aria-label="Benchmark use case"><button v-for="category in benchCategories" :key="category.id" :aria-pressed="category.id===benchCategory" @click="benchCategory=category.id">{{category.name}}</button></div>
<div class="bench-chart" role="group" :aria-label="benchSelected.name+' benchmark scores'">
<div class="bench-chart-heading"><div><h2>{{benchSelected.name}}</h2><p>Published score · higher is better</p></div><span>0–100 scale</span></div>
<div class="bench-axis" aria-hidden="true"><span>0</span><span>25</span><span>50</span><span>75</span><span>100</span></div>
<div class="bench-bar-row" v-for="row in benchRows" :key="row.id" :style="{'--bench-color':row.color}"><span class="bench-system"><img :src="row.icon" alt="" width="24" height="24">{{row.label}}</span><div class="bench-bar-track"><span :style="{width:row.score+'%'}"></span></div><strong>{{row.score.toFixed(1)}}</strong></div>
<p class="bench-chart-note">Scores compare complete search systems across a use case. They do not measure an individual enrichment vendor’s hit rate or email-verification accuracy.</p>
</div>
<div class="bench-overview-heading"><h2>Across every use case</h2><p>The same scale, side by side.</p></div>
<div class="bench-overview"><article class="bench-mini" v-for="category in benchCategories" :key="category.id"><h3>{{category.name}}</h3><div class="bench-mini-row" v-for="row in category.rows" :key="row.id" :style="{'--bench-color':row.color}"><span><img :src="row.icon" alt="" width="18" height="18">{{row.label}}</span><div class="bench-bar-track"><span :style="{width:row.score+'%'}"></span></div><strong>{{row.score.toFixed(1)}}</strong></div></article></div>
<details class="bench-methodology"><summary>About these results and sources</summary><p>These charts reproduce the category scores published on treg’s people-search landing page. They are a published snapshot, not a new benchmark run. No overall score is calculated here.</p><p>The original People Search Bench evaluates relevance, coverage and information utility. Its current GitHub results differ from the figures on the landing page; use the linked methodology for those definitions and results. A score is not a guarantee that a returned email can receive mail.</p><p><a href="https://treg.to/people-search#bench" target="_blank" rel="noopener">Published treg results ↗</a><a href="https://github.com/LessieAI/people-search-bench" target="_blank" rel="noopener">Benchmark & methodology ↗</a></p></details>
</template>
</section>
<section v-if="leaderboard" class="leaderboard-controls" aria-label="Leaderboard use case">
<div class="leaderboard-title"><div><h1>{{taskLabel(taskId)}}</h1><p>Which vendors return the data you need?</p></div><button class="setup-trigger" type="button" @click="openSetup" aria-label="Setup treg in Claude Code, Codex, OpenClaw, Hermes and more agents"><span>Setup treg in</span><span class="setup-logos" aria-hidden="true"><img v-for="agent in setupFeatured" :key="agent.id" :src="setupIcon(agent.icon)" :title="agent.name" alt=""><span>+{{setupOtherCount}}</span></span></button></div>
<arena-task-tabs :groups="jobGroups" :value="taskId" :disabled="!tasks.length" @change="chooseTask"></arena-task-tabs>
</section>
<section v-if="!leaderboard && !benchmark" id="composer" class="workspace" aria-label="Enrichment query">
<form @submit.prevent="submit" novalidate>
<arena-task-tabs :groups="jobGroups" :value="taskId" :disabled="busy || running || !tasks.length" @change="chooseTask"></arena-task-tabs>
<div class="compose-main" id="task-inputs" role="tabpanel" :aria-labelledby="'arena-task-'+taskId">
<div class="entry-inputs" :class="{'multiple-entries':inputRows.length>1}" :style="{'--input-columns':inputKeys.length}">
<div v-if="inputRows.length>1" class="entry-labels" aria-hidden="true"><span></span><span v-for="key in inputKeys" :key="key" class="entry-column-label">{{fieldLabel(key)}}</span><span></span></div>
<div class="input-row" v-for="(entry,index) in visibleInputs" :key="index"><span v-if="inputRows.length>1" class="entry-number" aria-hidden="true">{{index+1}}</span><label class="query-field" v-for="key in inputKeys" :key="key"><span :class="{'sr-only':inputRows.length>1}">{{fieldLabel(key)}}<span class="sr-only"> · Entry {{index+1}}</span></span><input v-model="entry[key]" :type="key==='email'?'email':key==='phone'?'tel':'text'" :inputmode="key==='email'?'email':key==='phone'?'tel':['domain','company_domain','linkedin_url'].includes(key)?'url':'text'" :spellcheck="false" :placeholder="placeholder(key)" :maxlength="500" :disabled="busy || running" required autocomplete="off" @input="quote=null" @paste="pasteEntries($event,index)"></label><button v-if="inputRows.length>1" type="button" class="remove-entry" :aria-label="'Remove entry '+(index+1)" :disabled="busy || running" @click="removeEntry(index)">×</button>
</div>
</div>
<div class="entry-tools">
<div class="entry-actions">
<button class="text-button" type="button" @click="addEntry" :disabled="busy || running || inputRows.length>=maxEntries">+ Add entry</button>
<label class="input-method" v-if="currentTask.variants.length>1">Using <select aria-label="Input type" :value="variant" @change="chooseVariant(Number($event.target.value))" :disabled="busy || running"><option v-for="(v,i) in currentTask.variants" :value="i">{{variantLabel(v)}}</option></select></label>
</div>
<div v-if="verificationTask" class="auto-verify-control" :class="{'hint-dismissed':verificationHintHidden}" @mouseenter="verificationHintHidden=false" @focusin="verificationHintHidden=false" @keydown.esc="verificationHintHidden=true">
<label class="verification-switch" :class="{enabled:autoVerify,disabled:busy || running || verificationPreview===null}">
<input type="checkbox" role="switch" v-model="autoVerify" aria-describedby="verification-cost-hint" :disabled="busy || running || verificationPreview===null">
<span class="verification-switch-track" aria-hidden="true"><span></span></span>
<span>{{taskId==='people.phone.find'?'Phone verification':'Email verification'}}</span>
<svg class="verification-info" viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M10 9v5M10 6v1"/></svg>
</label>
<span id="verification-cost-hint" role="tooltip" class="verification-cost-hint"><strong>{{verificationPreview===null?'Verification unavailable':readyQuote?.auto_verify?'Up to '+usd(readyQuote.auto_verify.estimate_micro)+' extra per found result':'From '+usd(verificationPreview)+' extra per found result'}}</strong><span>{{taskId==='people.phone.find'?'Automatically check each phone number found.':'Check each email through a verification waterfall; unavailable providers fall back within the quoted limit.'}} {{readyQuote?.auto_verify?'Already included in your run estimate.':'The cost is included in your run estimate when enabled.'}}</span></span>
</div>
<button v-if="inputRows.length>3" class="text-button" type="button" @click="showAllEntries=!showAllEntries">{{showAllEntries?'Show fewer':'Show all '+inputRows.length+' entries'}}</button>
<span v-if="inputRows.length>1">{{inputRows.length}} / {{maxEntries}} entries</span>
<div class="entry-submit"><span class="spreadsheet-hint">or paste rows from a spreadsheet</span><button class="run-button" type="submit" :disabled="busy || running || pricing || !tasks.length">{{runButtonLabel}} <span aria-hidden="true">→</span></button></div>
</div>
</div>
<p v-if="discovery" class="discovery-hint">Up to {{currentTask.result_limit}} matches per vendor and query · {{maxEntries}} queries per batch</p>
<div v-if="readyQuote" class="query-options">
<span class="price-hint">{{inputRows.length>1?(mode==='waterfall'?'From = cheapest first step for every entry. ':'Estimate covers all entries. '):''}}Estimated cost; final charges may vary.</span>
</div>
</form>
</section>
<div v-if="error" role="alert" class="notice error-notice"><span>{{error}}</span><button @click="error=''" aria-label="Dismiss error">×</button></div>
<section v-if="!benchmark && (leaderboard || showProviderPreview) && tasks.length" class="results-section provider-preview" :class="{'leaderboard-results':leaderboard}" :id="leaderboard?'task-inputs':null" :role="leaderboard?'tabpanel':null" :aria-labelledby="leaderboard?'arena-task-'+taskId:null" aria-label="Vendors and pricing">
<arena-fighters requestable @request-vendor="openVendorRequest" v-if="!leaderboard && !benchmark" :entries="availableProviders" :mode="mode" selectable choosable :enabled-providers="enabledProviders" @toggle-provider="toggleProvider" :disabled="busy || running" @change-mode="setMode"></arena-fighters>
<aside v-if="leaderboard" class="leaderboard-filters" aria-label="Chart filters">
<p class="filter-label">Compare by</p>
<div class="stats-views" role="group" aria-label="Vendor stats view"><button v-for="view in chartViews" :key="view.id" type="button" :aria-pressed="statsView===view.id" :class="{active:statsView===view.id}" @click="statsView=view.id;chartFocus=null"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path v-if="view.id==='rate'||view.id==='verified'" d="M4 16V9m6 7V4m6 12V7"/><template v-else-if="view.id==='speed'"><circle cx="10" cy="11" r="6"/><path d="M10 7v4l3 2M7 2h6"/></template><template v-else-if="view.id==='price'"><circle cx="10" cy="10" r="7"/><path d="M10 5v10m3-8H9a2 2 0 0 0 0 4h2a2 2 0 0 1 0 4H7"/></template><template v-else><path d="M3 3v14h14"/><circle cx="7" cy="11" r="1"/><circle cx="11" cy="7" r="1"/><circle cx="15" cy="5" r="1"/></template></svg>{{view.label}}</button></div>
<fieldset class="leaderboard-inputs"><legend class="filter-label">Input type</legend><label v-for="(v,i) in currentTask.variants" :key="i" :class="{active:variant===i}"><input type="radio" name="leaderboard-input" :value="i" :checked="variant===i" @change="chooseVariant(i)"><span>{{variantLabel(v)}}</span></label></fieldset>
<p class="leaderboard-source">{{statsView==='verified'?'Published verification pilot':'From real enrichment calls'}}<span>{{statsView==='verified'?verificationWindow:insightWindow}}</span></p>
</aside>
<div v-if="leaderboard && !discovery" class="vendor-chart" :class="{'price-chart':statsView==='price'}" aria-label="Vendor performance chart">
<div class="chart-heading"><div><h3>{{chartViews.find(v=>v.id===statsView)?.label}} <span>{{variantLabel(inputKeys)}}</span></h3><p>{{statsView==='verified'?(taskId==='people.email.find'?'Valid among sampled emails · higher rate to the left':'Reachability and ownership checks unavailable'):statsView==='price'?'Estimated price per entry · cheapest first':statsView==='speed'?'Median response time · lower is faster':statsView==='value'?'Lower price ← · ↑ higher '+(insightVerification?'verdict rate':'hit rate'):(insightVerification?'Returned verdicts':'Returned data')+' · higher rate to the left'}}</p></div><span class="chart-source">{{statsView==='verified'?'Verification pilot':statsView==='price'?'Catalog estimates':'Observed calls'}}</span></div>
<p v-if="statsView==='verified'" class="verified-explanation">{{verifiedExplanation}}</p>
<template v-if="statsView==='price'">
<div v-if="chartBars.length" class="price-comparison" role="group" aria-label="Vendor price comparison">
<button v-for="p in chartBars" :key="p.endpoint_id" type="button" class="price-comparison-row" :class="{picked:chartFocus===p.endpoint_id}" :aria-label="chartLabel(p)" @mouseenter="chartFocus=p.endpoint_id" @focus="chartFocus=p.endpoint_id" @click="chartFocus=p.endpoint_id">
<span class="price-vendor"><img :src="'/logos/'+p.provider+'.svg?v=arena-real-1'" alt="" width="22" height="22" @error="$event.target.style.visibility='hidden'"><span>{{providerName(p.provider)}}</span></span>
<span class="price-track" aria-hidden="true"><span :style="{width:(100*p.value/chartMax)+'%',background:p.color}"></span></span>
<span class="price-amount">{{usd(p.value)}}</span>
</button>
<p class="price-scale-note">Bar length shows relative price · USD per entry</p>
</div>
<p v-else class="chart-empty" role="status">No vendor prices available for this selection yet.</p>
</template>
<template v-else-if="statsView!=='value'">
<div v-if="chartBars.length" class="bar-chart-scroll" tabindex="0" role="region" :aria-label="statsView==='price'?'Vendor price bars':statsView==='speed'?'Vendor response time bars':'Vendor rate bars'">
<div class="bar-chart" :style="{'--chart-columns':chartBars.length}">
<div class="bar-grid" aria-hidden="true"><div v-for="fraction in [1,.75,.5,.25,0]" :style="{bottom:(fraction*100)+'%'}"><span>{{chartTick(chartMax*fraction)}}</span></div></div>
<div class="bar-columns">
<button v-for="p in chartBars" :key="p.endpoint_id" type="button" class="bar-column" :class="{picked:chartFocus===p.endpoint_id}" :style="{'--vendor-color':p.color}" :aria-label="chartLabel(p)" @mouseenter="chartFocus=p.endpoint_id" @focus="chartFocus=p.endpoint_id" @click="chartFocus=p.endpoint_id">
<span class="bar-track"><span class="bar-fill" :style="{height:(100*p.value/chartMax)+'%'}"><span class="bar-value">{{statsView==='verified'?verifiedRate(p):statsView==='price'?usd(p.estimate_micro):statsView==='speed'?insightResponseTime(p):insightRate(p)}}</span></span></span>
<span class="bar-vendor"><img :src="'/logos/'+p.provider+'.svg?v=arena-real-1'" alt="" @error="$event.target.style.visibility='hidden'"><span><template v-for="(part,index) in providerLabelParts(p.provider)" :key="index"><wbr v-if="index">{{part}}</template></span></span>
</button>
</div>
</div>
</div>
<p v-else class="chart-empty" role="status">{{statsView==='verified'?(taskId==='people.phone.find'?'Phone reachability and ownership have not been verified.':'No comparable verification samples for this selection yet.'):statsView==='price'?'No vendor prices available for this selection yet.':insightsState==='loading'?'Loading historical stats…':insights?.status==='warming'?'Processing stored call history…':'No comparable samples for this selection yet.'}} <a :href="sectionUrl(false)">View vendor prices in Arena →</a></p>
</template>
<template v-else>
<div v-if="chartPoints.length" class="scatter-chart">
<div class="scatter-plot">
<div class="scatter-grid" aria-hidden="true"><div v-for="tick in [0,25,50,75,100]" :style="{bottom:tick+'%'}"><span>{{tick}}%</span></div></div>
<div class="scatter-x" aria-hidden="true"><span v-for="fraction in [0,.25,.5,.75,1]" :style="{left:(fraction*100)+'%'}">{{usd(chartPriceMax*fraction)}}</span></div>
<button v-for="p in chartPoints" :key="p.endpoint_id" class="scatter-dot" type="button" :class="{picked:chartFocus===p.endpoint_id}" :style="{left:(100*p.estimate_micro/chartPriceMax)+'%',bottom:p.rate+'%','--vendor-color':p.color}" :aria-label="chartLabel(p)" @mouseenter="chartFocus=p.endpoint_id" @focus="chartFocus=p.endpoint_id" @click="chartFocus=p.endpoint_id"><span class="scatter-logo-fallback" aria-hidden="true">{{providerName(p.provider).slice(0,1)}}</span><img :src="'/logos/'+p.provider+'.svg?v=arena-real-1'" alt="" width="24" height="24" @error="$event.target.style.display='none'"></button>
</div>
<div class="scatter-axis-title">Estimated price{{inputRows.length>1?' · '+inputRows.length+' entries':' per entry'}} →</div>
<div class="chart-legend" role="group" aria-label="Chart vendors"><button v-for="p in chartPoints" :key="p.endpoint_id" type="button" :aria-pressed="chartFocus===p.endpoint_id" @mouseenter="chartFocus=p.endpoint_id" @focus="chartFocus=p.endpoint_id" @click="chartFocus=p.endpoint_id"><i :style="{background:p.color}" aria-hidden="true"></i>{{providerName(p.provider)}}</button></div>
</div>
<p v-else class="chart-empty" role="status">No vendors have both a rate and price for this selection yet.</p>
</template>
<p v-if="statsView==='verified'&&chartDetail" class="verified-explanation">{{verifiedRateNote(chartDetail)}}</p>
<div class="chart-detail" aria-live="polite"><template v-if="chartDetail"><strong>{{providerName(chartDetail.provider)}}</strong><span>{{insightVerification?'Verdict rate':'Hit rate'}} <b>{{insightRate(chartDetail)}}</b></span><span v-if="supportsVerifiedRate" :title="verifiedRateNote(chartDetail)">{{verifiedRateLabel}} <b>{{verifiedRate(chartDetail)}}</b></span><span>Response <b>{{insightResponseTime(chartDetail)}}</b></span><span>Price <b>{{chartDetail.estimate_micro==null?'—':usd(chartDetail.estimate_micro)}}</b></span></template><span v-else>Hover or select a vendor to compare its rate, speed and price.</span></div>
<p v-if="statsView==='price'" class="chart-caveat">Current catalog estimates per entry. Billing may be per request or per successful result; final pricing depends on your team and query.</p><p v-else class="chart-caveat">Different vendors received different inputs. These are observed results, not a controlled benchmark{{insightVerification?' or verification accuracy':''}}.</p>
</div>
<div v-else class="results-table-wrap" tabindex="0" role="region" aria-label="Vendor stats and pricing table" @scroll.passive="metricTooltip=null">
<table class="results-table pricing-table" :class="{'discovery-pricing':discovery}">
<caption class="sr-only">{{discovery?'Vendor estimates':'Vendor hit rates, response times and estimated prices'}} for {{currentTask.label}} using {{variantLabel(inputKeys)}}</caption>
<thead><tr><th scope="col">Vendor</th><th v-if="!discovery" scope="col" class="number-cell">{{insightVerification?'Verdict rate':'Hit rate'}}</th><th v-if="supportsVerifiedRate" scope="col" class="number-cell metric-help-header" @mouseenter="showMetricTooltip" @mouseleave="metricTooltip=null" @focusin="showMetricTooltip" @focusout="metricTooltip=null" @keydown.esc="metricTooltip=null"><button type="button" class="metric-help-trigger" :aria-describedby="metricTooltip?'email-validity-help':null" @click="showMetricTooltip">{{verifiedRateLabel}}</button></th><th v-if="!discovery" scope="col" class="number-cell">Response time</th><th scope="col" class="number-cell">Price <span v-if="inputRows.length>1">· {{inputRows.length}} entries</span></th></tr></thead>
<tbody><tr v-for="p in chartRows" :key="p.endpoint_id">
<th scope="row"><div class="vendor-name"><span class="vendor-logo"><span aria-hidden="true">{{providerName(p.provider).slice(0,1)}}</span><img :src="'/logos/'+p.provider+'.svg?v=arena-real-1'" alt="" width="24" height="24" @error="$event.target.style.display='none'"></span>{{providerName(p.provider)}}</div></th>
<td v-if="!discovery" class="number-cell historical-rate"><div class="inline-metric"><span class="metric-track" :class="{missing:p.rate===null}" aria-hidden="true"><span v-if="p.rate!==null" :style="{width:p.rate+'%'}"></span></span><span class="metric-value">{{insightRate(p)}}</span></div><small v-if="insightSampleLabel(p)">{{insightSampleLabel(p)}}</small></td>
<td v-if="supportsVerifiedRate" class="number-cell historical-rate" :title="verifiedRateNote(p)" tabindex="0" :aria-label="verifiedRateLabel+' '+verifiedRate(p)+'. '+verifiedRateNote(p)"><div class="inline-metric"><span class="metric-track verified-track" :class="{missing:p.verifiedRate===null}" aria-hidden="true"><span v-if="p.verifiedRate!==null" :style="{width:p.verifiedRate+'%'}"></span></span><span class="metric-value">{{verifiedRate(p)}}</span></div><small v-if="taskId==='people.phone.find'">Not verified</small></td>
<td v-if="!discovery" class="number-cell"><div class="inline-metric"><span class="metric-track time-track" :class="{missing:p.ms===null}" aria-hidden="true"><span v-if="p.ms!==null" :style="{width:(100*p.ms/chartTimeMax)+'%'}"></span></span><span class="metric-value">{{insightResponseTime(p)}}</span></div><small v-if="insightResponseTime(p)!=='—'">median · shorter is faster</small></td>
<td class="number-cell">{{p.estimate_micro==null?'Price unavailable':usd(p.estimate_micro)}}<small>{{billingLabel(p)}}</small></td>
</tr><tr v-if="!previewProviders.length"><td :colspan="supportsVerifiedRate?5:discovery?2:4" class="result-empty">{{customServices?'Click a vendor avatar to include it.':'No services support this input type.'}}</td></tr></tbody>
</table>
</div>
<teleport to="body"><div v-if="metricTooltip" id="email-validity-help" role="tooltip" class="metric-help-tooltip" :style="{left:metricTooltip.left+'px',top:metricTooltip.top+'px',width:metricTooltip.width+'px'}">{{verifiedExplanation}}</div></teleport>
<p class="pricing-note">{{readyQuote?(!leaderboard&&mode==='waterfall'?'Quoted order · stops at the first hit.':'Estimates for your team.'):'Catalog estimates · access and final pricing depend on your team and query.'}}</p>
<p v-if="!discovery && (insightsState==='loading' || insightsState==='idle')" class="pricing-note" role="status">Loading historical stats…</p>
<p v-else-if="!discovery && insightsState==='error'" class="pricing-note" role="status">Stats refresh unavailable; any displayed values are from the last update. <button type="button" class="text-button" @click="loadInsights">Retry</button></p>
<details v-else-if="!discovery" class="stats-methodology">
<summary>About these stats <span>{{insightWindow}}</span></summary>
<p v-if="insightVerification">Verdict rate is the share of unique evaluable requests that returned a verdict, including valid, invalid and unknown answers. It does not measure verification accuracy.</p>
<p v-else>Hit rate is the share of unique evaluable lookups that returned the task’s required fields. We keep the latest evaluable response per identical request for each vendor, so repeat calls do not dominate. Under 20 unique lookups shows no rate; under 100 is a small sample.</p>
<p>Known bad requests, rate limits, balance/credit failures, access and service failures, refusals and pending responses are excluded. Genuine no-result responses count as misses; ambiguous or missing evidence is omitted. Response time is the median of successful calls, including repeats, with at least 20 returned results.</p>
<p v-if="supportsVerifiedRate">{{verifiedExplanation}} Verifier details and check dates are available on hover. Samples with fewer than 20 completed checks are withheld. Risky, unknown and conflicting judgments are not counted as valid; unfinished checks are excluded.</p>
<p v-if="insights?.status==='warming'">Processing stored call history. Stats will appear when the first collection finishes.</p>
<p>These stats use calls with treg’s credentials over a rolling 30-day window. <span v-if="insightReviewedOn">Updated {{insightReviewedOn}}.</span> Vendors received different inputs and waterfall positions, so these figures are not a controlled ranking or verified accuracy. Prices are current estimates for your selection.</p>
<a href="/arena/insights" download="enrich-arena-insights.json">Download aggregate data ↗</a>
</details>
</section>
<section v-if="!leaderboard && run" class="results-section" aria-label="Enrichment results" aria-live="polite">
<arena-fighters requestable @request-vendor="openVendorRequest" :key="run.id" :entries="resultFighters" :choosable="!showProviderPreview && !running" :enabled-providers="enabledProviders" @toggle-provider="toggleProvider" :mode="run.mode" :active="running" :selected="winnerIds" :awards="battleAwards" :selectable="!showProviderPreview" :disabled="busy || running" @change-mode="setMode"><template #actions><button v-if="running" class="battle-stop text-button" @click="cancelRun" :disabled="busy">Stop</button></template></arena-fighters>
<p v-if="run.state==='interrupted' || run.state==='failed'" class="notice">This run did not finish. Completed answers are kept; uncertain calls will not be retried automatically.</p>
<template v-if="isBatch">
<div class="results-table-wrap batch-scoreboard" tabindex="0" role="region" aria-label="Batch vendor scoreboard">
<table v-sticky-header class="results-table">
<caption class="sr-only">Coverage and costs across {{runEntries.length}} entries</caption>
<thead><tr><th>Vendor</th><th>{{discoveryRun()?'Queries with matches':run.mode==='waterfall'?'Additional results':'Found'}}</th><th v-if="hasBatchFeedback">Feedback</th><th class="number-cell">{{discoveryRun()?'Cost / kept query':'Cost / kept'}}</th><th>Median time</th></tr></thead>
<tbody><template v-for="v in batchVendors" :key="v.provider">
<tr class="vendor-summary" :class="{clickable:v.attempted,picked:selectedVendor===v.provider}" :tabindex="v.attempted?0:null" :aria-expanded="v.attempted?selectedVendor===v.provider:null" :aria-controls="v.attempted?'vendor-detail-'+v.provider:null" @click="toggleVendorRow($event,v)" @keydown.enter.self.prevent="selectVendor(v)" @keydown.space.self.prevent="selectVendor(v)">
<th scope="row"><div class="vendor-name"><span class="vendor-logo"><span aria-hidden="true">{{providerName(v.provider).slice(0,1)}}</span><img :src="'/logos/'+v.provider+'.svg?v=arena-real-1'" alt="" width="24" height="24" @error="$event.target.style.display='none'"></span><button v-if="v.attempted" class="matrix-entry" :aria-label="'Results from '+providerName(v.provider)" :aria-expanded="selectedVendor===v.provider" :aria-controls="'vendor-detail-'+v.provider" @click="selectVendor(v)"><span class="entry-chevron" aria-hidden="true">{{selectedVendor===v.provider?'−':'+'}}</span>{{providerName(v.provider)}}</button><span v-else>{{providerName(v.provider)}}</span></div></th>
<td>{{run.mode==='compare'?Math.round(v.found/v.total*100)+'% · ':''}}{{v.found}} / {{run.mode==='compare'?v.total:v.attempted}}<small v-if="run.mode==='waterfall'"> attempted</small></td><td v-if="hasBatchFeedback"><span v-if="v.up">{{v.up}} 👍</span><span v-if="v.up && v.down"> · </span><span v-if="v.down">{{v.down}} 👎</span></td><td class="number-cell">{{usd(v.costPerKept)}}<small>{{usd(v.cost)}} total</small></td><td class="time-cell">{{v.median==null?'—':seconds(v.median)}}</td>
</tr>
<tr v-if="selectedVendor===v.provider" :id="'vendor-detail-'+v.provider" class="vendor-expansion"><td :colspan="hasBatchFeedback?5:4"><div class="vendor-detail-panel"><arena-result-table :rows="vendorResults" entry-view></arena-result-table></div></td></tr>
</template></tbody>
</table>
</div>
<p class="pricing-note">{{run.mode==='waterfall'?'Later vendors receive only unresolved entries; their hit rates are not directly comparable.':'Coverage crowns use results kept after thumbs-down rejections.'}} {{discoveryRun()?'Coverage counts queries with matches, not the number or relevance of contacts.':'Found is not verified accuracy.'}} Timing excludes queue time.</p>
<div class="batch-controls"><span>{{runEntries.length}} entries <span v-if="running">· {{run.progress}} / {{run.total}} outcomes</span></span><label class="input-method"><span class="sr-only">Filter entries</span><select v-model="resultFilter"><option value="all">All entries</option><option value="found">Found</option><option value="unresolved">Unresolved</option><option value="disagreement">Different answers</option></select></label></div>
<div class="results-table-wrap batch-results-wrap" tabindex="0" role="region" aria-label="Entry by vendor results">
<table v-sticky-header class="results-table batch-matrix" :style="{'--vendor-count':batchVendors.length}">
<caption class="sr-only">Select an entry to expand its vendor details in place</caption>
<thead><tr>
<th scope="col">Entry</th>
<th scope="col" v-for="v in batchVendors" :key="v.provider"><div class="vendor-name"><img :src="'/logos/'+v.provider+'.svg?v=arena-real-1'" alt="" width="20" height="20">{{providerName(v.provider)}}</div></th>
</tr></thead>
<tbody>
<template v-for="entry in matrixEntries" :key="entry.index">
<tr class="matrix-overview" :class="{picked:selectedEntry===entry.index}" tabindex="0" :aria-expanded="selectedEntry===entry.index" :aria-controls="'entry-detail-'+entry.index" @click="toggleEntryRow($event,entry.index)" @keydown.enter.self.prevent="selectEntry(entry.index)" @keydown.space.self.prevent="selectEntry(entry.index)">
<th scope="row"><button class="matrix-entry" @click="selectEntry(entry.index)" :aria-expanded="selectedEntry===entry.index" :aria-controls="'entry-detail-'+entry.index"><span class="entry-chevron" aria-hidden="true">{{selectedEntry===entry.index?'−':'+'}}</span>{{entry.index+1}}. {{entryLabel(entry.identity)}}</button></th>
<td v-for="v in batchVendors" :key="v.provider" :data-vendor="providerName(v.provider)" :class="{unattempted:['not_attempted','skipped'].includes(entryResult(entry,v.provider)?.state)}">
<template v-for="r in [entryResult(entry,v.provider)]" :key="r?.id">
<button v-if="r && hasAttempt(r)" class="matrix-result" @click="selectEntry(entry.index,r)" :aria-expanded="selectedEntry===entry.index" :aria-controls="'entry-detail-'+entry.index" :aria-label="'Details for entry '+(entry.index+1)+', '+providerName(r.provider)">
<span class="outcome" :class="outcomeClass(r)">{{outcomeLabel(r)}}</span>
<span v-if="summaryFields(r).length" class="matrix-value">{{displayValue(r.output[summaryFields(r)[0]])}}</span>
<span v-else class="matrix-value">{{emptyLabel(r)}}</span>
</button>
</template>
</td>
</tr>
<tr v-if="selectedEntry===entry.index" :id="'entry-detail-'+entry.index" class="entry-expansion">
<td :colspan="batchVendors.length+1">
<div class="entry-detail-panel"><arena-result-table :rows="entry.results"></arena-result-table></div>
</td>
</tr>
</template>
<tr v-if="!matrixEntries.length"><td :colspan="batchVendors.length+1" class="result-empty">No entries match this filter.</td></tr>
</tbody>
</table>
</div>
</template>
<arena-result-table v-if="!isBatch" :rows="run.results"></arena-result-table>
<p class="waterfall-stop" v-if="run.mode==='waterfall' && !running">{{run.stop_reason}}</p>
<div class="after-actions" v-if="!running"><button class="text-button" v-if="run.mode==='compare'" @click="tryWaterfall">Try as waterfall ↗</button><button class="text-button" @click="exportResult">Export results</button></div>
</section>
</main>
</div>
<footer><span>Built with <a href="/">treg</a> — tools for the work ahead.</span><span>{{benchmark?'Published benchmark results':leaderboard?'Aggregate call stats':'Private results · Retained for 30 days'}}</span><a href="/privacy">Privacy</a></footer>
<dialog ref="setupDialog" class="setup-dialog" :class="{'setup-try-dialog':setupStep===3}" aria-labelledby="setup-title" @cancel.prevent="closeSetup" @click.self="closeSetup">
<button class="dialog-close" type="button" aria-label="Close setup" :disabled="setupStep===0&&setupLoading" @click="closeSetup">×</button>
<template v-if="setupStep===0">
<p class="eyebrow">▚ treg</p><h2 id="setup-title">Name your team.</h2>
<p class="setup-subtitle">Create a team to get your API key and connect your agent. You can invite others later.</p>
<form @submit.prevent="createSetupTeam">
<label>Team name<input v-model="setupTeamName" required maxlength="80" placeholder="Your team" :disabled="setupLoading"></label>
<div class="setup-footer"><button class="text-button" type="button" @click="closeSetup" :disabled="setupLoading">Cancel</button><button class="primary" type="submit" :disabled="setupLoading">{{setupLoading?'Creating…':'Create team & continue →'}}</button></div>
</form>
</template>
<template v-else-if="setupStep===1">
<p class="eyebrow">▚ treg</p><h2 id="setup-title">Which agent are you using?</h2><p class="setup-subtitle">Choose your agent for the best setup instructions.</p>
<treg-agent-picker v-model="setupAgentId"></treg-agent-picker>
<div class="setup-footer"><button class="text-button" type="button" @click="closeSetup">Cancel</button><button class="primary" type="button" @click="prepareSetup" :disabled="setupLoading">{{setupLoading?'Loading…':'Next →'}}</button></div>
</template>
<template v-else-if="setupStep===2">
<h2 id="setup-title" class="sr-only">Set up treg in {{setupAgent.name}}</h2>
<treg-setup-instructions :agent="setupAgent" :command="setupCommand" :team="setupToken?team:''" :token="setupToken" :show-token="setupShowToken" :copied="setupCopied" @copy="copySetup" @toggle-token="setupShowToken=!setupShowToken"></treg-setup-instructions>
<div class="setup-footer"><button class="text-button" type="button" @click="setupStep=1;setupShowToken=false">← Back</button><button class="primary" type="button" @click="showSetupExamples">Next →</button></div>
</template>
<template v-else>
<treg-try-it-out heading-id="setup-title" :copied="setupExampleCopied" @example="copySetup($event.prompt,$event.k)" @provider="openSetupCatalog"></treg-try-it-out>
<div class="setup-footer"><button class="text-button" type="button" @click="closeSetup">Skip</button><button class="primary" type="button" @click="openSetupCatalog()">Browse all catalog →</button></div>
</template>
<p v-if="setupError" role="alert" class="auth-error">{{setupError}}</p>
</dialog>
<dialog ref="vendorRequestDialog" class="vendor-request-dialog" aria-labelledby="vendor-request-title" @cancel.prevent="closeVendorRequest" @click.self="closeVendorRequest">
<button type="button" class="dialog-close" aria-label="Close vendor request" :disabled="requestBusy" @click="closeVendorRequest">×</button>
<h2 id="vendor-request-title">{{requestDone?'Request sent':'Request a vendor'}}</h2>
<form v-if="!requestDone" @submit.prevent="submitVendorRequest">
<p class="request-intro">Tell us which vendor or capability you’d like in Arena.</p>
<label>What's missing<input v-model="requestForm.capability" maxlength="200" placeholder="e.g. a vendor for finding work emails" required autofocus :disabled="requestBusy"></label>
<label><span>Details <span class="request-optional">— optional</span></span><textarea v-model="requestForm.note" maxlength="2000" rows="3" placeholder="What you'd use it for, or a link to the provider's docs" :disabled="requestBusy"></textarea></label>
<label v-if="!user"><span>Contact <span class="request-optional">— optional, to hear when it lands</span></span><input v-model="requestForm.contact" maxlength="200" placeholder="you@work.com" :disabled="requestBusy"></label>
<p v-if="requestError" role="alert" class="auth-error">{{requestError}}</p>
<div class="request-footer"><button type="submit" class="primary" :disabled="requestBusy">{{requestBusy?'Sending…':'Send request'}}</button><span>Takes ten seconds — no signup needed.</span></div>
</form>
<div v-else class="vendor-request-success" role="status"><span aria-hidden="true">✓</span><p>Thanks for the suggestion. The catalog team will review your request.</p></div>
<section class="vendor-selfserve" aria-labelledby="vendor-selfserve-title">
<h3 id="vendor-selfserve-title">Are you a vendor?</h3>
<p>Add your API by opening a PR. Paste this prompt into your coding agent to get started.</p>
<textarea class="vendor-listing-prompt" :value="vendorListingPrompt" readonly rows="3" aria-label="Vendor listing prompt" spellcheck="false"></textarea>
<div class="vendor-selfserve-actions"><button type="button" class="vendor-copy-prompt" @click="copyVendorListingPrompt">{{vendorPromptCopied?'✓ Copied':'Copy prompt'}}</button><button v-if="requestDone" type="button" class="text-button" @click="closeVendorRequest">Done</button><span class="sr-only" role="status">{{vendorPromptCopied?'Prompt copied to clipboard.':''}}</span></div>
<p v-if="vendorPromptError" class="auth-error" role="alert">{{vendorPromptError}}</p>
</section>
</dialog>
<dialog ref="loginDialog" @cancel="closeLogin"><button class="dialog-close" aria-label="Close signup" @click="closeLogin">×</button><h2>Sign up to continue.</h2><p>{{linkedRunPending?'Sign in with the account that owns this saved result. Opening it will not rerun any lookups.':'Your query is saved. Create an account, then set up treg in your agent.'}}</p><div class="social-buttons"><button v-if="meta.google" @click="socialLogin('google')">Continue with Google ↗</button><button v-if="meta.github" @click="socialLogin('github')">Continue with GitHub ↗</button></div><form @submit.prevent="emailStep==='code'?verifyEmail():sendCode()"><label>Email<input v-model="email" type="email" autocomplete="email" required :disabled="emailStep==='code'"></label><label v-if="emailStep==='code'">Verification code<input v-model="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></label><p v-if="devCode" class="notice">Local development code: {{devCode}}</p><p v-if="authError" role="alert" class="auth-error">{{authError}}</p><button class="primary" :disabled="authBusy">{{authBusy?'One moment…':emailStep==='code'?'Verify & continue':'Email me a code'}}</button></form><p class="fine">By continuing, you agree to the <a href="/terms">Terms</a> and <a href="/privacy">Privacy Policy</a>.</p></dialog>
<dialog ref="teamDialog"><p class="eyebrow">A HOME FOR YOUR CREDITS</p><h2>Name your team.</h2><p>Arena uses your team balance. Create a team to continue.</p><form @submit.prevent="createTeam"><label>Team name<input v-model="newTeamName" required maxlength="80" placeholder="Your team"></label><p v-if="authError" class="auth-error">{{authError}}</p><button class="primary" :disabled="authBusy">Create team & continue ↗</button></form></dialog>
</div>
<template id="arena-result-table-template">
<div class="results-table-wrap result-detail-table" tabindex="0" role="region" :aria-label="entryView?'Entries attempted by '+arena.providerName(rows[0]?.provider):arena.run.mode==='waterfall'?'Waterfall results table':'Comparison results table'">
<table v-sticky-header class="results-table" :class="{'waterfall-table':arena.run.mode==='waterfall'}">
<caption class="sr-only">{{entryView?'Attempted entries and results':arena.run.mode==='waterfall'?'Services in waterfall order':'Compare enrichment services and vote for the best answer'}}</caption>
<thead><tr><th scope="col">{{entryView?'Entry':arena.run.mode==='waterfall'?'Step / vendor':'Vendor'}}</th><th scope="col">Status</th><th scope="col">Result</th><th scope="col" class="number-cell">Cost</th><th scope="col" class="time-cell">{{arena.run.mode==='waterfall'?'Timeline':'Time'}}</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
<tbody>
<template v-for="(r,i) in rows" :key="r.id">
<tr class="vendor-row" :class="{picked:arena.winnerIds.includes(r.id),unattempted:['not_attempted','skipped'].includes(r.state)}" tabindex="0" :aria-expanded="arena.expandedResults.includes(r.id)" :aria-controls="'detail-'+r.id" @click="arena.toggleResultRow($event,r.id)" @keydown.enter.self.prevent="arena.toggleDetails(r.id)" @keydown.space.self.prevent="arena.toggleDetails(r.id)">
<th scope="row"><span v-if="entryView" class="result-entry-name">{{(r.entry_index||0)+1}}. {{arena.entryLabel(arena.runEntries[r.entry_index||0])}}</span><div v-else class="vendor-name"><span v-if="arena.run.mode==='waterfall'" class="step-number">{{i+1}}</span><span class="vendor-logo"><span aria-hidden="true">{{arena.providerName(r.provider).slice(0,1)}}</span><img :src="'/logos/'+r.provider+'.svg?v=arena-real-1'" alt="" width="24" height="24" @error="$event.target.style.display='none'"></span><span>{{arena.providerName(r.provider)}}<span v-if="arena.battleAwards[r.id]" class="result-awards"><span v-for="badge in arena.battleAwards[r.id]" :key="badge" class="award-badge" :class="badge.toLowerCase()" :title="arena.awardTitle(badge)">{{badge}}</span></span></span></div></th>
<td><span class="outcome" :class="arena.outcomeClass(r)">{{arena.outcomeLabel(r)}}</span></td>
<td class="result-cell"><template v-if="arena.summaryFields(r).length"><div class="result-value"><span v-if="arena.discoveryRun()">Matches returned: </span><span v-if="typeof r.output[arena.summaryFields(r)[0]]==='boolean'">{{arena.fieldLabel(arena.summaryFields(r)[0])}}: </span>{{arena.displayValue(r.output[arena.summaryFields(r)[0]])}}</div><div v-if="arena.summaryFields(r).length>1" class="result-meta"><span v-for="key in arena.summaryFields(r).slice(1)">{{arena.fieldLabel(key)}}: {{arena.displayValue(r.output[key])}}</span></div></template><span v-else class="result-empty">{{arena.emptyLabel(r)}}</span><div v-if="r.verification" class="verification-result"><strong :class="arena.verificationClass(r)" :title="r.verification.output?.status">{{arena.verificationVerdict(r)}}</strong><span>{{arena.providerName(r.verification.provider)}}<template v-if="r.verification.output?.line_type"> · {{r.verification.output.line_type}}</template></span></div><div v-if="arena.domainMismatch(r)" class="result-note">{{arena.domainMismatch(r)}}</div><div v-if="arena.identityWarning(r)" class="result-note">{{arena.identityWarning(r)}}</div><div v-if="r.report && (arena.ratingValue(r)==='down' || r.report.feedback_context==='automated_verification')" class="reported-note" :title="r.report.comment"><svg aria-hidden="true" viewBox="0 0 20 20"><path d="M14 3H6a2 2 0 0 0-2 1.6L2.6 10a2 2 0 0 0 2 2.5H8v4a1.5 1.5 0 0 0 2.6 1L14 12V3Z M14 3h3v9h-3"/></svg> {{r.report.feedback_context==='automated_verification'?'Invalid email · automatically reported':'Issue reported'}}</div></td>
<td class="number-cell"><span>{{r.charged_micro===null?'Pending':arena.usd(r.charged_micro)}}</span><small v-if="r.tier!=='platform'">Your key</small><small v-if="r.cached">Cached</small><small v-if="r.manual">Additional call</small><small v-if="r.verification" class="verification-cost"><span>Incl. verification</span><span class="verification-cost-amount">{{r.verification.charged_micro==null?'Pending':arena.usd(r.verification.charged_micro)}}</span></small></td>
<td class="time-cell"><span class="duration">{{r.duration_ms==null?(r.state==='running'?'Running…':'—'):arena.seconds(r.duration_ms)}}</span><div class="timing-track" v-if="arena.run.mode==='waterfall' && r.duration_ms" :title="'Started '+arena.seconds(r.started_ms||0)+' after run start'"><div :style="{marginLeft:arena.timeLeft(r)+'%',width:arena.timeWidth(r)+'%'}"></div></div></td>
<td class="row-actions"><div><button v-if="r.can_verify" class="try-vendor" :disabled="arena.verificationPending[r.id]" @click="arena.verifyResult(r)">{{arena.verificationLabel(r)}}</button><button v-if="r.can_try" class="try-vendor" :disabled="arena.busy || arena.manualPending[r.id]" :aria-label="arena.manualLabel(r)+' with '+resultLabel(r)" @click="arena.tryVendor(r)">{{arena.manualLabel(r)}}</button><template v-if="['hit','miss','error','timeout'].includes(r.state)"><button v-for="value in ['up','down']" :key="value" class="rating-button" :class="'thumb-'+value" :disabled="arena.busy || arena.running" :aria-pressed="arena.ratingValue(r)===value" :title="value==='up'?'Thumbs up':'Thumbs down · optional details'" :aria-label="'Thumbs '+value+': '+resultLabel(r)" @click="arena.rateResult(r,value)"><svg aria-hidden="true" viewBox="0 0 20 20"><path d="M14 3H6a2 2 0 0 0-2 1.6L2.6 10a2 2 0 0 0 2 2.5H8v4a1.5 1.5 0 0 0 2.6 1L14 12V3Z M14 3h3v9h-3"/></svg></button></template><button class="details-button" :aria-label="'Details: '+resultLabel(r)" :aria-expanded="arena.expandedResults.includes(r.id)" :aria-controls="'detail-'+r.id" @click="arena.toggleDetails(r.id)"><span aria-hidden="true">{{arena.expandedResults.includes(r.id)?'−':'+'}}</span></button></div></td>
</tr>
<tr v-if="arena.reporting===r.id" class="detail-row"><td colspan="6"><form class="report-form" @submit.prevent="arena.sendReport(r)"><strong role="status">{{arena.busy?'Saving feedback…':'Thumbs down saved'}} · {{arena.providerName(r.provider)}}</strong><label>Reason (optional)<select v-model="arena.reportDrafts[r.id].reason" :disabled="arena.busy || !!r.report"><option value="">Choose a reason</option><option value="incorrect_data">Incorrect data</option><option value="wrong_person">Wrong person</option><option value="wrong_company">Wrong company</option><option value="outdated_data">Outdated data</option><option value="other">Something else</option></select></label><label>Details (optional)<textarea v-model="arena.reportDrafts[r.id].comment" rows="2" maxlength="1000" :disabled="arena.busy || !!r.report" placeholder="What should we know about this result?"></textarea></label><div><button v-if="!r.report" class="primary" :disabled="arena.busy || arena.running || (!arena.reportDrafts[r.id].reason && !arena.reportDrafts[r.id].comment.trim())">Save details</button><span v-else>Details saved</span><button type="button" class="text-button" @click="arena.reporting=''">Done</button></div></form></td></tr>
<tr v-if="arena.expandedResults.includes(r.id)" :id="'detail-'+r.id" class="detail-row"><td colspan="6"><div class="row-detail"><p v-if="r.detail" class="step-reason">{{r.detail}}</p><div v-if="arena.searchMatches(r).length" class="search-results"><p class="quality-note">{{arena.searchMatches(r).length}} {{arena.searchMatches(r).length===1?'match':'matches'}} returned · Review relevance and contact details before use.</p><div class="search-match" v-for="(match,index) in arena.searchMatches(r)" :key="index"><span class="search-match-index">{{index+1}}</span><div><strong>{{match.name||match.domain||match.email||'Profile'}}</strong><p>{{[match.title,match.company,match.location].filter(Boolean).join(' · ')}}</p><p v-if="match.description">{{match.description}}</p><div class="search-match-links"><a v-if="match.linkedin_url" :href="match.linkedin_url" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a><span v-if="match.domain">{{match.domain}}</span><span v-if="match.email">{{match.email}}</span></div></div></div></div><dl v-else-if="Object.keys(r.output).length" class="field-list"><div v-for="key in arena.run.fields" :class="{missing:r.output[key]==null || r.output[key]===''}"><dt>{{arena.fieldLabel(key)}}</dt><dd>{{arena.displayValue(r.output[key])}}</dd></div></dl><p class="quality-note" v-if="!r.verification && arena.run.capability==='people.email.find' && r.state==='hit'">{{r.output.verified===true?'Service reports this email as verified.':'Found, not confirmed deliverable. Verify before outreach.'}}</p><p class="quality-note" v-if="arena.run.capability==='people.phone.find' && r.state==='hit'">Found, not confirmed to be a live line.</p><details v-if="r.verification"><summary>Verification details</summary><p>{{arena.verificationVerdict(r)}} · {{arena.providerName(r.verification.provider)}}</p><p v-if="r.verification.capability==='people.phone.verify'">Phone validity does not confirm a live line or ownership.</p><pre>{{JSON.stringify(r.verification.output,null,2)}}</pre><p v-if="r.verification.detail">{{r.verification.detail}}</p></details><details v-if="r.raw"><summary>Original response</summary><p class="variant-name">{{r.endpoint_id}}</p><pre>{{JSON.stringify(r.raw,null,2)}}</pre></details></div></td></tr>
</template>
</tbody>
</table>
</div>
<p v-if="rows.some(r=>r.raw_omitted && arena.expandedResults.includes(r.id))" class="quality-note">An original response was too large to retain in this batch. Its extracted result fields are still shown.</p>
</template>
<template id="arena-fighters-template">
<div class="pixel-arena" :class="[mode,{'is-live':active}]" role="group" aria-label="Vendor fighters">
<div class="battle-toolbar">
<div v-if="selectable" class="battle-modes" role="group" aria-label="Execution mode">
<button type="button" :aria-pressed="mode==='waterfall'" :class="{active:mode==='waterfall'}" :disabled="disabled || active" @click="chooseMode('waterfall')"><svg aria-hidden="true" viewBox="0 0 24 18"><path d="M6 3h6v5M14 10h6v4"/><rect x="1" y="1" width="5" height="4"/><rect x="9" y="7" width="5" height="4"/><rect x="18" y="14" width="5" height="3"/></svg>Waterfall</button>
<button type="button" :aria-pressed="mode==='compare'" :class="{active:mode==='compare'}" :disabled="disabled || active" @click="chooseMode('compare')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m14.5 17.5 3-3L5 2H2v3L14.5 17.5ZM13 19l6-6M16 16l4 4M19 21l2-2M14.5 6.5 4.5 16.5M17.5 9.5 22 5V2h-3l-4.5 4.5M5 13l6 6M8 16l-4 4M3 19l2 2"/></svg>Battle</button>
</div>
<span v-else class="battle-mode-label">{{mode==='compare'?'Battle':'Waterfall'}}</span>
<span class="battle-explainer">{{mode==='compare'?'Run & compare results from all vendors':'Running from cheapest vendor, until get a valid result'}}</span>
<slot name="actions"></slot>
</div>
<div class="fighter-roster" tabindex="0" role="region" aria-label="Vendor fighter lineup">
<component :is="choosable?'button':'div'" v-for="(entry,i) in entries" :key="entry.provider" class="fighter" :class="[state(entry),{'fighter-toggle':choosable}]" :style="{'--beat':(i%4)*-0.17+'s'}" :type="choosable?'button':null" :role="choosable?null:'img'" :disabled="choosable && (disabled || active)" :aria-pressed="choosable?enabled(entry):null" :aria-label="choosable?name(entry.provider):name(entry.provider)+': '+[label(entry),...(awards[entry.id]||[])].join(' · ')" :aria-description="choosable?[label(entry),...(awards[entry.id]||[])].join(' · '):null" @click="chooseProvider(entry)">
<span v-if="choosable" class="fighter-check" aria-hidden="true">{{enabled(entry)?'✓':'+'}}</span>
<div class="fighter-scene" aria-hidden="true">
<svg class="fighter-celebration" viewBox="0 0 72 20"><path class="victory-stars" d="M10 3h4v4h4v4h-4v4h-4v-4H6V7h4z M58 0h3v3h3v3h-3v3h-3V6h-3V3h3z"/><path class="vote-crown" d="M25 6h4v4h4V3h6v7h4V6h4v13H25z"/></svg>
<span class="fighter-alert">!</span>
<div class="fighter-facing"><div class="fighter-sprite">
<svg class="fighter-body" viewBox="0 0 64 72" fill="currentColor" shape-rendering="crispEdges">
<g class="fighter-leg back"><path d="M34 47h8v14h4v5H34z"/></g><g class="fighter-leg front"><path d="M22 47h8v19H18v-5h4z"/></g>
<path d="M22 30h20v20H22z"/><path class="fighter-shirt" d="M26 31h12v11H26z"/><path class="fighter-belt" d="M22 44h20v4H22z"/>
<g class="fighter-arm back"><path d="M14 32h12v8H10V28h8v8h-4z"/><path class="fighter-glove" d="M8 25h12v10H8z"/></g>
<g class="fighter-arm front"><path d="M40 32h14v8H40z"/><path class="fighter-glove" d="M50 26h12v12H50z"/></g>
</svg>
<span class="fighter-head"><span>{{name(entry.provider).slice(0,1)}}</span><img :src="'/logos/'+entry.provider+'.svg?v=arena-real-1'" alt="" width="26" height="26" @error="$event.target.style.display='none'"></span>
<span v-if="awards[entry.id]" class="fighter-awards" :class="{'double-belt':awards[entry.id].length>1}" aria-hidden="true"><span v-for="badge in awards[entry.id]" :key="badge" class="award-badge" :class="badge.toLowerCase()">{{badge}}</span></span>
</div><svg class="fighter-impact" viewBox="0 0 24 24" shape-rendering="crispEdges"><path d="M10 0h4v7h-4z M10 17h4v7h-4z M0 10h7v4H0z M17 10h7v4h-7z M8 8h8v8H8z"/></svg></div>
<span class="fighter-shadow"></span>
</div>
<span v-if="i<entries.length-1" class="fighter-link" aria-hidden="true"><span v-if="mode==='compare'">VS</span><svg v-else viewBox="0 0 24 14"><path d="M1 3h9v8h12M18 7l4 4-4 3"/></svg></span>
<span class="fighter-name" aria-hidden="true">{{name(entry.provider)}}</span><span class="fighter-verdict" aria-hidden="true">{{label(entry)}}</span>
</component>
<button v-if="requestable" type="button" class="fighter-request" aria-label="Request a vendor" title="Request a vendor" @click="$emit('request-vendor')"><span class="fighter-request-plus" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor" shape-rendering="crispEdges"><path d="M10 4h4v6h6v4h-6v6h-4v-6H4v-4h6z"/></svg></span><span class="sr-only">Request vendor</span></button>
</div>
<p v-if="choosable" class="fighter-hint">Click a fighter to include or exclude</p>
</div>
</template>
<noscript><p class="loader-fallback">Enrich Arena needs JavaScript to compare results. Enable JavaScript and reload.</p></noscript>
<script>setTimeout(function(){var a=document.getElementById('arena');if(a&&a.hasAttribute('v-cloak')){var p=document.createElement('p');p.className='loader-fallback';p.textContent='Arena could not load. Please reload this page.';a.after(p);}},5000);</script>
<script src="/sitetrack.js" data-private-page="true"></script>
</body>
</html>
+881
View File
@@ -0,0 +1,881 @@
/* Arena adopts the treg redesign reference: pixel type, neutral canvas, quiet white cards. */
:root {
--bg:#F8F8F8; --surface:#fff; --card:#F7F7F7; --ink:#1A1A1A;
--muted:#737373; --subtle:#909090; --line:rgba(37,37,34,.1);
--teal:#54B9A6; --teal-ink:#2E7A6B; --teal-soft:#EEF7F4;
--display:"Geist Pixel","DM Mono",ui-monospace,monospace;
--text:"Google Sans Flex","Segoe UI",system-ui,sans-serif;
--mono:"DM Mono",ui-monospace,"SF Mono",Menlo,monospace;
--shadow:none;
}
* { box-sizing:border-box; }
html { scroll-behavior:smooth; }
body { margin:0; background:var(--bg); color:var(--ink); font:14px/1.55 var(--text); -webkit-font-smoothing:antialiased; }
body::before { content:""; position:fixed; inset:64px 0 0; pointer-events:none; background-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20fill%3D%22%23C6C6C6%22%20fill-opacity%3D%22.36%22%3E%3Crect%20x%3D%224%22%20y%3D%2212%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2218%22%20y%3D%224%22%20width%3D%223%22%20height%3D%223%22/%3E%3Crect%20x%3D%2240%22%20y%3D%2218%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2264%22%20y%3D%2210%22%20width%3D%223%22%20height%3D%223%22/%3E%3Crect%20x%3D%2284%22%20y%3D%2230%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2212%22%20y%3D%2242%22%20width%3D%223%22%20height%3D%223%22/%3E%3Crect%20x%3D%2232%22%20y%3D%2238%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2254%22%20y%3D%2250%22%20width%3D%223%22%20height%3D%223%22/%3E%3Crect%20x%3D%2272%22%20y%3D%2262%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%226%22%20y%3D%2276%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2224%22%20y%3D%2268%22%20width%3D%223%22%20height%3D%223%22/%3E%3Crect%20x%3D%2242%22%20y%3D%2288%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2288%22%20y%3D%2286%22%20width%3D%223%22%20height%3D%223%22/%3E%3Crect%20x%3D%2258%22%20y%3D%2278%22%20width%3D%222%22%20height%3D%222%22/%3E%3Crect%20x%3D%2280%22%20y%3D%224%22%20width%3D%222%22%20height%3D%222%22/%3E%3C/g%3E%3C/svg%3E"); mask-image:radial-gradient(ellipse at 0% 35%,#000,transparent 55%),radial-gradient(ellipse at 100% 10%,#000,transparent 50%); }
::selection { background:#54B9A647; }
[v-cloak] { display:none!important; }
#arena { position:relative; min-height:100svh; display:flex; flex-direction:column; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
button,input,select { font:inherit; color:inherit; }
button,a,input,select,summary { outline-offset:4px; }
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible { outline:2px solid var(--teal); }
button { cursor:pointer; border:1px solid var(--line); background:var(--surface); border-radius:999px; padding:10px 18px; font-weight:500; transition:background .16s,border-color .16s,box-shadow .16s; }
button:hover:not(:disabled) { background:var(--card); border-color:#D7D4CE; }
button:disabled { opacity:.45; cursor:not-allowed; }
a { color:inherit; text-underline-offset:4px; }
input,select { min-width:0; border:1px solid var(--line); border-radius:10px; background:var(--surface); padding:12px 14px; }
input::placeholder { color:var(--subtle); }
h1,h2,h3,p { margin:0; }
h1,h2,h3 { font-family:var(--display); font-weight:400; }
h1 { font-size:28px; line-height:1.25; letter-spacing:0; text-wrap:balance; }
h2 { font-size:32px; line-height:1.12; letter-spacing:0; }
h3 { font-size:19px; letter-spacing:0; }
.masthead { min-height:64px; padding:12px 24px; display:flex; align-items:center; justify-content:space-between; gap:20px; }
.brand { display:flex; align-items:center; gap:10px; color:var(--ink); text-decoration:none; font:400 16px var(--display); white-space:nowrap; }
.brandmark { width:26px; height:26px; flex-shrink:0; }
.brand-divider { color:#BDB9B1; margin:0 4px; font-weight:400; }
.brand-sub { font:500 14px var(--text); color:var(--muted); }
.masthead nav { display:flex; align-items:center; gap:20px; font-size:13px; }
.team-switcher { display:flex; align-items:center; gap:4px; min-width:0; overflow:clip; padding:4px; }
.team-switcher>span { color:var(--muted); }
.masthead select { max-width:150px; background:transparent; border:0; padding:6px; }
.text-button { padding:6px 0; border:0; border-radius:0; background:transparent; font-size:13px; }
.text-button:hover:not(:disabled) { background:transparent; color:var(--teal-ink); }
.masthead .sign-in-button { background:var(--ink); color:#fff; border:0; padding:10px 22px; font-size:13px; }
.masthead .sign-in-button:hover { background:#2B2B2A; }
.balance { display:flex; align-items:center; gap:7px; padding:9px 13px; border:1px solid var(--line); border-radius:999px; font:12px var(--mono); white-space:nowrap; }
.balance span { font:10px var(--text); letter-spacing:.03em; color:var(--muted); }
main { width:100%; max-width:1080px; margin:0 auto; padding:0 28px; flex:1; }
.intro { display:flex; align-items:center; gap:12px; padding:34px 0 24px; text-align:left; }
.intro-icon { display:grid; place-items:center; width:38px; height:38px; border-radius:50%; background:var(--surface); color:var(--muted); }
.intro-icon svg { width:18px; height:18px; }
.intro-title { display:flex; flex-direction:column; gap:6px; }
.powered-by { display:flex; align-items:center; gap:5px; color:var(--muted); font:400 11px/1.4 var(--text); }
.powered-by svg { width:14px; height:14px; margin-left:2px; }
.powered-by-name { color:var(--ink); font-weight:500; }
.eyebrow { font:500 11px/1.5 var(--text); letter-spacing:.06em; color:var(--muted); margin-bottom:12px; }
.workspace { width:100%; margin:0 auto 24px; border:.5px solid var(--line); border-radius:32px; background:var(--surface); box-shadow:var(--shadow); overflow:hidden; scroll-margin-top:24px; }
.compose-main { padding:16px 18px 8px; }
.input-row { display:flex; align-items:stretch; gap:12px; padding:8px 16px; border-radius:22px; background:var(--card); }
.query-field { display:flex; flex:1; min-width:0; flex-direction:column; gap:6px; }
.query-field>span { font-size:12px; color:var(--muted); }
.query-field input { width:100%; height:42px; padding:7px 6px; border:0; background:transparent; font-size:18px; }
.query-field+.query-field { border-left:1px solid var(--line); padding-left:18px; }
.entry-inputs { display:grid; gap:6px; }
.multiple-entries .input-row { border-radius:14px; padding:4px 12px; gap:8px; }
.multiple-entries .query-field input { font-size:15px; height:36px; }
.entry-number { align-self:center; min-width:16px; color:var(--subtle); font:10px var(--mono); }
.remove-entry { align-self:center; flex-shrink:0; padding:0; width:28px; height:28px; border:0; background:transparent; color:var(--muted); font-size:19px; }
.entry-tools { display:flex; align-items:center; flex-wrap:wrap; gap:16px; margin:6px 4px 0; }
.entry-tools button { font-size:11px; }
.entry-actions { display:flex; align-items:center; gap:16px; min-width:0; max-width:100%; }
.entry-actions>.text-button { flex-shrink:0; white-space:nowrap; }
.entry-actions .input-method { min-width:0; }
.entry-actions .input-method select { min-width:0; text-overflow:ellipsis; }
.entry-tools>span { color:var(--subtle); font-size:10px; margin-left:auto; }
.batch-scoreboard { margin-top:12px; }
.vendor-summary.clickable { cursor:pointer; }
.vendor-summary.clickable:hover { background:var(--card); }
.vendor-summary.picked>* { background:var(--teal-soft); }
.vendor-summary:focus-visible { outline:2px solid var(--teal); outline-offset:-2px; }
.batch-scoreboard .vendor-expansion>td { padding:8px 14px 16px; background:var(--teal-soft); }
.vendor-detail-panel .result-detail-table { border-radius:12px; border-color:#D9E8E3; }
.result-entry-name { overflow-wrap:anywhere; }
.batch-controls { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; margin:16px 4px 8px; color:var(--muted); font-size:12px; }
.batch-matrix { table-layout:fixed; min-width:calc(200px + var(--vendor-count)*180px); }
.batch-results-wrap { overflow-x:auto; container-type:inline-size; }
.batch-matrix>thead>tr>th:first-child { width:200px; }
.batch-matrix>thead>tr>th:first-child,.batch-matrix>tbody>.matrix-overview>th { position:sticky; left:0; z-index:2; background:var(--surface); box-shadow:1px 0 var(--line); }
.batch-matrix>thead>tr>th:first-child { background:#FBFBFB; }
.batch-matrix>thead .vendor-name { white-space:normal; overflow-wrap:normal; flex-wrap:wrap; }
.batch-matrix>thead .vendor-name img { flex-shrink:0; }
.batch-matrix>tbody>.matrix-overview.picked>* { background:var(--teal-soft); }
.batch-matrix>tbody>.matrix-overview>td.unattempted { background:#FAFAFA; }
.matrix-overview { cursor:pointer; }
.matrix-overview.picked,.vendor-summary.picked { position:relative; z-index:2; transform:translateY(var(--entry-offset,0px)); }
.matrix-overview:hover { background:var(--card); }
.matrix-overview:focus-visible { outline:2px solid var(--teal); outline-offset:-2px; }
.entry-chevron { display:inline-block; width:16px; color:var(--muted); }
.batch-matrix>tbody>.entry-expansion>td { padding:8px 14px 16px; background:var(--teal-soft); }
/* Only the full-width detail panel stays in view as overview columns scroll. */
.entry-detail-panel { position:sticky; left:14px; width:calc(100cqw - 28px); }
.entry-expansion .result-detail-table { border-radius:12px; border-color:#D9E8E3; }
.matrix-entry,.matrix-result { display:block; width:100%; padding:0; border:0; border-radius:4px; background:transparent; text-align:left; font-weight:400; overflow-wrap:anywhere; }
.matrix-entry { font-weight:500; font-size:12px; }
.matrix-result:hover:not(:disabled),.matrix-entry:hover:not(:disabled) { background:transparent; color:var(--teal-ink); }
.matrix-value { display:block; font-size:12px; margin-top:6px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.award-badge.coverage { color:#85681B; background:#FFF8DB; }
@media(max-width:600px) {
.multiple-entries .query-field input { font-size:13px; }
.entry-tools { gap:10px; }
.batch-matrix,.batch-matrix>tbody { display:block; min-width:0; }
.batch-matrix>thead { display:none; }
.batch-matrix .matrix-overview { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border-bottom:1px solid var(--line); }
.batch-matrix .matrix-overview>th { grid-column:1 / -1; border:0; }
.batch-matrix .matrix-overview>td { display:none; border:0; }
.batch-matrix .matrix-overview>td:has(.matrix-result) { display:block; }
.batch-matrix .matrix-overview>td::before { content:attr(data-vendor); display:block; margin-bottom:8px; color:var(--muted); font-size:11px; }
.batch-matrix>tbody>.entry-expansion,.batch-matrix>tbody>.entry-expansion>td { display:block; width:100%; }
}
.run-button { width:auto; height:32px; flex:0 0 auto; min-width:110px; margin:0; background:var(--ink); color:#fff; border-color:var(--ink); border-radius:999px; padding:0 15px; display:flex; align-items:center; justify-content:center; gap:8px; font-size:11px; line-height:1; white-space:nowrap; }
.run-button:hover:not(:disabled) { background:#2B2B2A; border-color:#2B2B2A; box-shadow:0 3px 8px #00000012; }
.muted { color:var(--muted); }
footer { width:calc(100% - 56px); max-width:1024px; margin:0 auto; padding:25px 0; border-top:1px solid var(--line); display:flex; gap:25px; justify-content:space-between; color:var(--muted); font-size:11px; }
footer a { text-decoration:none; }
.notice { background:var(--card); border:1px solid var(--line); padding:14px 18px; border-radius:10px; font-size:13px; margin:20px 0; }
.error-notice { background:#FFF5F1; border-color:#ECD9D0; display:flex; justify-content:space-between; gap:20px; }
.error-notice button { padding:0 5px; border:0; background:transparent; }
.results-section { margin:24px 0 44px; }
.outcome { font:500 10px var(--text); padding:4px 9px; border-radius:999px; background:var(--card); color:var(--muted); }
.outcome.hit { color:#14663D; background:#DDF5E6; }
.outcome.miss { color:#B42332; background:#FDE8EB; }
.outcome.hit::before { content:"✓ "; font-weight:700; }
.outcome.miss::before { content:"× "; font-weight:700; }
.outcome.error,.outcome.timeout { color:#9A5A43; background:#FFF1E9; }
.variant-name { font:10px var(--mono); color:var(--subtle); overflow-wrap:anywhere; margin-top:6px; }
.field-list { margin:20px 0; }
.field-list>div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:15px; border-bottom:1px solid var(--line); padding:10px 0; font-size:13px; }
.field-list dt { color:var(--muted); font-size:11px; }
.field-list dd { margin:0; overflow-wrap:anywhere; }
.field-list .missing dd { color:var(--subtle); }
.quality-note { font-size:11px; line-height:1.65; color:var(--muted); border-left:2px solid #B6DAD1; padding-left:10px; margin:15px 0; }
details { font-size:12px; margin-top:16px; color:var(--muted); }
summary { cursor:pointer; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:350px; overflow:auto; background:var(--card); border-radius:8px; padding:14px; font:11px/1.6 var(--mono); }
.primary { background:var(--ink); border-color:var(--ink); color:#fff; font-size:13px; }
.primary:hover:not(:disabled) { background:#2B2B2A; border-color:#2B2B2A; color:#fff; }
.after-actions { display:flex; align-items:center; gap:22px; margin-top:14px; flex-wrap:wrap; }
.after-actions button { font-size:12px; }
.after-actions span { font-size:11px; color:var(--muted); margin-left:auto; }
.timing-track { height:5px; background:var(--card); border-radius:4px; overflow:hidden; }
.timing-track>div { height:100%; background:var(--teal); min-width:2px; }
.step-reason { color:var(--muted); font-size:12px; }
/* One compact row per vendor; full fields and source responses open on demand. */
.results-table-wrap { overflow-x:auto; border:.5px solid var(--line); border-radius:24px; background:var(--surface); }
.results-table-wrap:focus-visible { outline:2px solid var(--teal); outline-offset:4px; }
.results-table { width:100%; border-collapse:collapse; text-align:left; font-size:12px; }
.results-table th,.results-table td { padding:14px 16px; border-bottom:1px solid var(--line); vertical-align:middle; }
.results-table thead th { padding-top:11px; padding-bottom:11px; background:#FBFBFB; color:var(--muted); font-size:11px; font-weight:400; white-space:nowrap; }
.results-table thead { position:relative; z-index:3; transform:translateY(var(--header-offset,0px)); }
.results-table tbody th { font-weight:500; }
.results-table tbody tr:last-child>* { border-bottom:0; }
.vendor-row.picked { background:var(--teal-soft); }
.vendor-row.unattempted { background:#FAFAFA; color:var(--muted); }
.vendor-row.unattempted .vendor-logo { opacity:.85; }
.results-table .vendor-row.unattempted .row-actions { background:transparent; }
.vendor-name { display:flex; align-items:center; gap:9px; white-space:nowrap; }
.vendor-logo { position:relative; display:grid; place-items:center; width:24px; height:24px; flex:0 0 24px; border-radius:6px; background:var(--card); color:var(--muted); font-size:11px; }
.vendor-logo img { position:absolute; inset:0; width:24px; height:24px; object-fit:contain; border-radius:6px; background:var(--surface); }
.step-number { min-width:14px; color:var(--subtle); font:11px var(--mono); }
.result-cell { width:100%; min-width:230px; max-width:420px; overflow-wrap:anywhere; }
.result-value { font-size:13px; line-height:1.45; }
.result-meta { display:flex; flex-wrap:wrap; gap:2px 12px; font-size:11px; color:var(--muted); margin-top:3px; }
.result-empty { color:var(--muted); font-size:12px; }
.result-note { color:#956247; font-size:10px; line-height:1.5; margin-top:4px; }
.results-table .outcome { white-space:nowrap; }
.number-cell { text-align:right; white-space:nowrap; font:11px var(--mono); }
.number-cell small { display:block; color:var(--muted); font:10px var(--text); margin-top:3px; }
.time-cell { min-width:80px; white-space:nowrap; }
.duration { font:11px var(--mono); }
.waterfall-table .time-cell { min-width:135px; }
.time-cell .timing-track { height:4px; margin-top:7px; }
.row-actions>div { display:flex; align-items:center; justify-content:flex-end; gap:9px; }
.details-button { width:26px; height:26px; flex:0 0 26px; display:grid; place-items:center; padding:0; border:0; background:transparent; color:var(--muted); font-size:17px; }
.detail-row>td { padding:0; background:var(--bg); }
.row-detail { padding:18px 24px; }
.row-detail .field-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 30px; margin:8px 0; }
.row-detail .field-list>div { padding:7px 0; }
.row-detail .quality-note { margin:10px 0; }
.row-detail details { margin-top:12px; }
.row-detail pre { max-width:900px; }
/* Keep expanded tables inside the visible matrix, even with many vendors. */
.result-detail-table { container-type:inline-size; }
.result-detail-table>.results-table { table-layout:fixed; }
.result-detail-table th,.result-detail-table td { min-width:0; overflow-wrap:anywhere; }
.result-detail-table thead th:nth-child(1) { width:18%; }
.result-detail-table thead th:nth-child(2) { width:12%; }
.result-detail-table thead th:nth-child(3) { width:28%; }
.result-detail-table thead th:nth-child(4) { width:12%; }
.result-detail-table thead th:nth-child(5) { width:12%; }
.result-detail-table thead th:nth-child(6) { width:18%; }
.result-detail-table .number-cell { padding-left:12px; padding-right:12px; }
.result-detail-table .number-cell small { white-space:normal; overflow-wrap:anywhere; line-height:1.4; }
.verification-cost>span { display:block; }
.verification-cost .verification-cost-amount { font-family:var(--mono); white-space:nowrap; }
.result-detail-table .vendor-name { white-space:normal; gap:6px; }
.result-detail-table .row-actions>div { flex-wrap:wrap; gap:4px; }
@container(max-width:700px) {
.result-detail-table>.results-table,.result-detail-table tbody { display:block; }
.result-detail-table thead { display:none; }
.result-detail-table .vendor-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); padding:10px; border-bottom:1px solid var(--line); }
.result-detail-table .vendor-row>* { width:auto; max-width:none; border:0; padding:6px; }
.result-detail-table .vendor-row>.result-cell { grid-column:1 / -1; }
.result-detail-table .vendor-row>.number-cell { text-align:left; }
.result-detail-table .vendor-row>.number-cell::before { content:"Cost "; color:var(--muted); font:11px var(--text); }
.result-detail-table .vendor-row>.time-cell::before { content:"Time "; color:var(--muted); font:11px var(--text); }
.result-detail-table .vendor-row>.row-actions { grid-column:2; grid-row:1; }
.result-detail-table .detail-row,.result-detail-table .detail-row>td { display:block; width:100%; }
.result-detail-table .row-detail { padding:14px; }
}
.waterfall-stop { margin:12px 0 0; color:var(--muted); font-size:11px; }
@media(max-width:800px) {
.results-table th,.results-table td { padding-left:12px; padding-right:12px; }
.results-table .detail-row>td { padding:0; }
.row-detail .field-list { grid-template-columns:1fr; }
}
dialog { border:1px solid var(--line); border-radius:22px; background:var(--bg); color:var(--ink); padding:38px; width:min(94vw,470px); max-height:calc(100dvh - 24px); overscroll-behavior:contain; overflow:auto; box-shadow:0 30px 90px -20px #14120C40; }
dialog::backdrop { background:#10101066; backdrop-filter:blur(4px); }
dialog h2 { font-size:36px; margin-bottom:18px; }
dialog>p:not(.eyebrow) { color:var(--muted); font-size:14px; margin-bottom:22px; }
.dialog-close { position:absolute; right:15px; top:10px; border:0; background:transparent; font-size:23px; padding:0 6px; color:var(--muted); }
dialog label { display:flex; flex-direction:column; gap:6px; font-size:12px; margin:16px 0; }
dialog form>.primary { width:100%; margin-top:5px; }
.social-buttons { display:flex; gap:10px; flex-wrap:wrap; }
.social-buttons button { width:100%; font-size:13px; }
.fine { font-size:11px!important; color:var(--muted); line-height:1.7; margin-top:17px; }
.auth-error { color:#9A5A43; font-size:12px; margin:12px 0; }
.loader-fallback { text-align:center; padding:80px 20px; }
.input-method { display:flex; align-items:center; gap:7px; margin:0; color:var(--muted); font-size:12px; }
.input-method select { border:0; background:transparent; padding:3px 20px 3px 0; font-size:12px; color:var(--muted); }
/* Task selection below the input; quiet mode links beside the priced action. */
.query-options { display:flex; align-items:flex-start; justify-content:flex-end; gap:16px; padding:0 22px 12px; }
.price-hint { font-size:10px; color:var(--muted); margin-top:1px; text-align:right; }
@media(max-width:1080px) { footer { margin:0 28px; } }
@media(max-width:800px) {
.masthead { min-height:68px; padding:14px 22px; }
.masthead nav { gap:12px; }
.balance span { display:none; }
main { padding:0 22px; }
.intro { padding:22px 0 24px; }
.compose-main { padding:16px 18px 8px; }
.input-row { flex-wrap:wrap; }
footer { width:calc(100% - 44px); margin:0 22px; }
}
@media(max-width:540px) {
.masthead { padding:14px 16px; gap:12px; flex-wrap:wrap; }
.brand { font-size:15px; gap:8px; }
.brand-sub { font-size:12px; }
.brand-divider { margin:0 1px; }
.brandmark { width:24px; height:24px; }
.masthead nav { gap:10px; font-size:11px; }
.masthead select { max-width:100px; font-size:12px; }
.masthead .text-button { font-size:12px; }
.masthead .sign-in-button { padding:8px 15px; }
.balance { display:none; }
main { padding:0 16px; }
h1 { font-size:24px; }
.intro { padding:18px 0 22px; }
.workspace { border-radius:24px; }
.compose-main { padding:14px 12px 8px; }
.query-field { flex:1 1 100%; }
.run-button { min-width:0; height:32px; padding:0 10px; font-size:11px; }
.query-field+.query-field { border-left:0; border-top:1px solid var(--line); padding-left:0; }
.query-options { padding:0 16px 10px; gap:10px; }
footer { width:calc(100% - 32px); margin:0 16px; flex-wrap:wrap; gap:10px; justify-content:flex-start; font-size:10px; }
footer span:first-child { width:100%; }
dialog { padding:30px 23px; }
.after-actions span { margin-left:0; width:100%; }
}
@media(prefers-reduced-motion:reduce) {
*,*::before,*::after { scroll-behavior:auto!important; animation:none!important; transition:none!important; }
}
.pricing-note { color:var(--muted); font-size:11px; margin-top:10px; }
/* Tiny logo-headed fighters: the animation follows real attempt states. */
.pixel-arena { margin:0 0 12px; background:#FFFFFFD9; border:.5px solid var(--line); border-radius:24px; overflow:hidden; }
.fighter-roster { position:relative; display:flex; justify-content:safe center; gap:30px; padding:4px 18px 10px; overflow-x:auto; scrollbar-width:thin; }
.fighter-roster:focus-visible { outline-offset:-3px; }
.fighter { position:relative; flex:0 0 92px; text-align:center; color:var(--ink); }
.fighter-scene { height:85px; position:relative; isolation:isolate; }
.fighter-scene::after { content:''; position:absolute; bottom:2px; left:-6px; right:-6px; height:2px; background:repeating-linear-gradient(90deg,#D9D6CF 0 4px,transparent 4px 8px); z-index:-1; }
.fighter-facing { position:absolute; width:64px; height:72px; left:14px; bottom:3px; }
.compare .fighter:nth-child(even) .fighter-facing { transform:scaleX(-1); }
.compare .fighter:nth-child(even) .fighter-head { transform:scaleX(-1); }
.fighter-sprite { position:relative; height:72px; transform-origin:32px 65px; }
.fighter-body { width:64px; height:72px; display:block; overflow:visible; }
.fighter-shirt { fill:#506964; }
.fighter-belt { fill:#7ED7C3; }
.fighter-glove { fill:#526D65; }
.fighter-head { position:absolute; top:3px; left:17px; width:30px; height:30px; display:grid; place-items:center; background:var(--surface); border:2px solid var(--ink); box-shadow:2px 2px 0 #10101020; font:12px var(--mono); }
.fighter-head img { position:absolute; inset:0; width:26px; height:26px; object-fit:contain; padding:2px; background:var(--surface); }
.fighter-shadow { position:absolute; left:27px; bottom:6px; width:38px; height:4px; background:#10101012; z-index:-1; }
.fighter-name { display:block; margin-top:5px; font:400 12px var(--display); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.fighter-verdict { display:block; margin-top:3px; font:9px var(--mono); color:var(--muted); }
.fighter-celebration { position:absolute; top:0; left:10px; width:72px; height:20px; fill:#48A68F; opacity:0; shape-rendering:crispEdges; }
.vote-crown { display:none; fill:#C19436; }
.fighter-alert { display:none; position:absolute; top:0; right:15px; color:#A37135; font:bold 15px var(--mono); }
.fighter-impact { position:absolute; right:-13px; top:20px; width:20px; height:20px; fill:#DFA45C; opacity:0; }
.fighter-arm.front { transform-origin:42px 36px; }
.fighter-arm.back { transform-origin:22px 36px; }
.fighter.waiting .fighter-sprite,.fighter.benched .fighter-sprite { opacity:.35; }
.fighter.benched .fighter-sprite,.fighter.paused .fighter-sprite { filter:grayscale(1); }
.fighter.defeated .fighter-sprite { transform:translate(8px,2px) rotate(-80deg); opacity:.45; filter:grayscale(1); }
.fighter.error .fighter-alert,.fighter.paused .fighter-alert { display:block; }
.fighter.error .fighter-sprite { transform:rotate(-8deg); }
.fighter.won .fighter-arm.front,.fighter.champion .fighter-arm.front { transform:translate(5px,-10px) rotate(-35deg); }
.fighter.won .fighter-arm.back,.fighter.champion .fighter-arm.back { transform:translate(-5px,-10px) rotate(35deg); }
.fighter.won .fighter-celebration,.fighter.champion .fighter-celebration { opacity:1; }
.fighter.champion .vote-crown { display:block; }
.fighter.won .fighter-verdict,.fighter.champion .fighter-verdict { color:var(--teal-ink); }
.fighter.fighting .fighter-verdict { color:var(--ink); }
.is-live .fighter.fighting .fighter-sprite { animation:fighter-bounce .68s steps(2,end) infinite; animation-delay:var(--beat); }
.is-live .fighter.fighting .fighter-arm.front { animation:fighter-punch .68s steps(2,end) infinite; animation-delay:var(--beat); }
.is-live .fighter.fighting .fighter-arm.back { animation:fighter-guard .68s steps(2,end) infinite; animation-delay:var(--beat); }
.is-live .fighter.fighting .fighter-impact { animation:fighter-spark .68s steps(1,end) infinite; animation-delay:var(--beat); }
.is-live .fighter.won .fighter-sprite { animation:fighter-victory .48s steps(3,end) 2; }
@keyframes fighter-bounce { 0%,100% { transform:translate(0,0); } 25% { transform:translate(-2px,-2px); } 50% { transform:translate(4px,-2px); } 75% { transform:translate(2px,0); } }
@keyframes fighter-punch { 0%,100% { transform:translate(0,0); } 25% { transform:translate(-4px,-2px); } 50%,65% { transform:translate(10px,-4px); } }
@keyframes fighter-guard { 50% { transform:translate(0,-4px); } }
@keyframes fighter-spark { 0%,45%,75%,100% { opacity:0; } 50%,70% { opacity:1; } }
@keyframes fighter-victory { 50% { transform:translateY(-6px); } }
@media(max-width:540px) { .fighter-roster { gap:24px; padding-left:8px; padding-right:8px; } .fighter { flex-basis:82px; } .fighter-facing { left:9px; } .fighter-celebration { left:5px; } }
/* Mode selection belongs to the battle; the composer stays focused on the query. */
.battle-toolbar { position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; padding:12px 50px 4px; }
.battle-modes { display:flex; align-items:center; gap:3px; }
.battle-modes button { display:flex; align-items:center; gap:6px; border:1px solid transparent; padding:5px 8px; border-radius:8px; background:transparent; color:var(--muted); font-size:11px; font-weight:400; }
.battle-modes button.active { border-color:var(--line); background:var(--card); color:var(--ink); }
.battle-modes svg { width:21px; height:17px; fill:none; stroke:currentColor; stroke-width:1; }
.battle-modes svg rect { fill:currentColor; stroke:none; }
.battle-explainer,.battle-mode-label { color:var(--muted); font-size:10px; }
.fighter-link { position:absolute; top:47px; right:-27px; width:24px; font:11px var(--display); color:#A19F98; }
.fighter-link svg { width:24px; height:14px; fill:none; stroke:currentColor; stroke-width:1.5; }
@media(max-width:540px) { .battle-toolbar { gap:4px; padding:10px 46px 3px; } .battle-explainer { text-align:center; } .price-hint { max-width:150px; } .fighter-link { right:-24px; } }
.battle-stop { position:absolute; right:18px; top:15px; font-size:11px; padding:4px 0; }
/* Query history behaves like sessions, without opening a modal. */
.arena-layout { display:flex; width:100%; flex:1; margin:0 auto; }
.arena-layout.has-history { max-width:none; }
.has-history main { max-width:1080px; margin-inline:auto; }
.arena-layout main { min-width:0; }
.session-sidebar { position:sticky; top:20px; align-self:flex-start; flex:0 0 248px; width:248px; max-height:calc(100svh - 118px); display:flex; flex-direction:column; gap:14px; margin:34px 0 24px 28px; }
.new-session { display:flex; align-items:center; justify-content:center; gap:8px; flex-shrink:0; padding:9px 12px; font-size:12px; border-radius:12px; background:var(--surface); }
.new-session>span { font-size:18px; line-height:1; }
.session-list { overflow-y:auto; scrollbar-width:none; display:flex; flex-direction:column; gap:3px; padding:2px; }
.session-list::-webkit-scrollbar { display:none; }
.history-load-more { flex-shrink:0; padding:10px 14px; margin-top:4px; border-radius:12px; font-size:12px; background:var(--surface); }
.session-item { display:block; flex-shrink:0; width:100%; border:1px solid transparent; border-radius:12px; padding:10px 11px; text-align:left; background:transparent; font-weight:400; }
.session-item.active { background:var(--surface); border-color:var(--line); }
.session-item:hover:not(:disabled) { background:#EEEDE980; }
.session-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; font-weight:500; }
.session-meta,.session-date { display:block; color:var(--muted); font-size:10px; line-height:1.5; margin-top:3px; }
.session-date { font-size:9px; color:var(--subtle); margin-top:1px; }
@media(max-width:1050px) { .session-sidebar { flex-basis:176px; width:176px; margin-left:16px; } .has-history main { padding-left:20px; padding-right:20px; } }
@media(max-width:700px) {
.arena-layout.has-history { flex-direction:column; }
.session-sidebar { position:static; width:calc(100% - 32px); flex-basis:auto; max-height:none; margin:14px 16px 0; gap:8px; }
.new-session { align-self:flex-start; padding:6px 12px; }
.session-list { flex-direction:row; overflow-x:auto; overflow-y:hidden; }
.session-item { width:180px; padding:8px 10px; }
.has-history main { padding-left:16px; padding-right:16px; }
}
.try-vendor { padding:6px 9px; font-size:11px; white-space:nowrap; }
.rating-button { width:25px; height:25px; display:grid; place-items:center; padding:4px; border:0; background:transparent; color:var(--muted); }
.rating-button svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.3; }
.reported-note { margin-top:4px; color:#956247; font-size:10px; }
.report-form { display:grid; gap:12px; padding:18px 22px; max-width:600px; }
.report-form strong { font-size:13px; font-weight:500; }
.report-form label { display:grid; gap:5px; font-size:11px; color:var(--muted); }
.report-form select,.report-form textarea { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:9px; font:12px var(--text); background:var(--surface); color:var(--ink); }
.report-form textarea { resize:vertical; }
.report-form>div { display:flex; align-items:center; gap:15px; }
.report-form button { font-size:11px; }
.results-table .row-actions { background:var(--surface); }
.results-table .picked .row-actions { background:var(--teal-soft); }
.vendor-row { cursor:pointer; }
.vendor-row:hover,.results-table .vendor-row:hover .row-actions { background:#F7F7F4; }
.vendor-row.picked:hover,.results-table .vendor-row.picked:hover .row-actions { background:var(--teal-soft); }
.vendor-row:focus-visible { outline:2px solid var(--teal); outline-offset:-2px; }
.reported-note { display:flex; align-items:center; gap:4px; }
.reported-note svg { width:12px; height:12px; fill:none; stroke:currentColor; stroke-width:1.3; }
.rating-button { width:28px; height:28px; border-radius:7px; }
.rating-button.thumb-up svg { transform:rotate(180deg); }
.rating-button[aria-pressed="true"] { opacity:1; color:var(--teal-ink); background:#E3F0EB; }
.rating-button.thumb-down[aria-pressed="true"] { color:#956247; background:#F5EAE3; }
.rating-button[aria-pressed="true"] svg { fill:currentColor; fill-opacity:.12; }
.fighter-toggle { padding:0; border:0; border-radius:10px; background:transparent; font:inherit; }
.fighter-toggle:hover { background:#50696408; }
.fighter-toggle:focus-visible { outline-offset:2px; }
.fighter-check { position:absolute; right:0; top:4px; width:16px; height:16px; border-radius:50%; background:#E3F0EB; color:var(--teal-ink); font:11px var(--text); line-height:16px; }
.fighter.excluded .fighter-sprite { opacity:.3; filter:grayscale(1); }
.fighter.excluded .fighter-name { color:var(--muted); }
.fighter.excluded .fighter-check { background:#EEEDE9; color:var(--muted); }
.fighter-hint { text-align:center; margin:0 0 12px; color:var(--muted); font-size:10px; }
.result-awards { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; }
/* Award belts sit on the sprite and move with it, with readable text when mirrored. */
.fighter-awards { position:absolute; z-index:2; top:40px; left:-2px; width:68px; display:grid; gap:1px; pointer-events:none; }
.compare .fighter:nth-child(even) .fighter-awards { transform:scaleX(-1); }
.award-badge { display:inline-flex; padding:2px 5px; border-radius:5px; font:9px var(--text); line-height:1.3; white-space:nowrap; }
.award-badge.fastest { color:#6B5792; background:#F0ECF7; }
.award-badge.cheapest { color:var(--teal-ink); background:#E3F0EB; }
.fighter-awards.double-belt { top:33px; }
.fighter-awards .award-badge { display:block; padding:0 3px; border:2px solid currentColor; border-radius:0; font:500 7.5px/9px var(--mono); letter-spacing:.2px; text-transform:uppercase; text-align:center; clip-path:polygon(2px 0,calc(100% - 2px) 0,calc(100% - 2px) 2px,100% 2px,100% calc(100% - 2px),calc(100% - 2px) calc(100% - 2px),calc(100% - 2px) 100%,2px 100%,2px calc(100% - 2px),0 calc(100% - 2px),0 2px,2px 2px); }
.fighter-awards .award-badge.fastest { background:#E8DCF8; color:#67458A; }
.fighter-awards .award-badge.cheapest { background:#BFE8D7; color:#295B49; }
.award-badge.winner { background:#F7ECCF; color:#8A641E; }
.fighter-awards .award-badge.winner { background:#F5D783; color:#785219; }
/* Historical metrics share the existing vendor pricing table. */
.pricing-table { min-width:690px; }
.pricing-table th:nth-child(2),.pricing-table th:nth-child(3) { width:26%; }
.inline-metric { display:flex; align-items:center; gap:12px; min-width:150px; }
.metric-track { flex:1; min-width:60px; height:10px; background:#EEF2EF; border-radius:3px; overflow:hidden; }
.metric-track>span { display:block; height:100%; background:#70A398; border-radius:3px; }
.time-track { background:#F0F2F5; }
.time-track>span { background:#9AAAC0; }
.metric-track.missing { background:transparent; border-bottom:1px dashed #D1D6D2; height:1px; border-radius:0; }
.metric-value { width:64px; text-align:right; flex-shrink:0; font:12px var(--mono); font-variant-numeric:tabular-nums; }
.pricing-table .historical-rate { min-width:125px; }
.pricing-table .number-cell small { white-space:normal; line-height:1.4; }
.stats-methodology { color:var(--muted); font-size:11px; margin-top:10px; }
.stats-methodology summary { cursor:pointer; width:fit-content; }
.stats-methodology summary span { margin-left:8px; }
.stats-methodology p { max-width:90ch; margin:8px 0; line-height:1.6; }
.stats-methodology a { display:inline-block; margin-bottom:6px; }
@media(max-width:600px) {
.stats-methodology summary span { display:block; margin:4px 0 0 14px; }
}
/* One compact chart surface, backed by the same database snapshot as the table. */
.stats-views { display:flex; flex-wrap:wrap; gap:4px; padding:4px; margin:18px 0 12px; width:fit-content; background:#EDEDEA; border:1px solid var(--line); border-radius:10px; }
.stats-views button { border:0; border-radius:7px; background:transparent; padding:8px 15px; font-size:12px; color:var(--muted); }
.stats-views button.active { background:#fff; color:var(--ink); box-shadow:0 1px 3px #00000012; }
.vendor-chart { border:1px solid var(--line); border-radius:16px; background:#fff; padding:24px 24px 14px; }
.chart-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
.chart-heading h3 { font:500 16px var(--text); }
.chart-heading h3 span { color:var(--muted); font-weight:400; }
.chart-heading p { margin-top:6px; color:var(--muted); font-size:12px; }
.chart-source { font:10px var(--mono); color:var(--muted); white-space:nowrap; padding-top:4px; }
.bar-chart-scroll { overflow-x:auto; padding-top:22px; }
.bar-chart { position:relative; height:334px; min-width:calc(var(--chart-columns)*112px + 48px); }
.bar-grid { position:absolute; top:18px; bottom:76px; left:48px; right:0; pointer-events:none; }
.bar-grid>div,.scatter-grid>div { position:absolute; width:100%; border-top:1px solid #ECEEEB; }
.bar-grid>div>span,.scatter-grid>div>span { position:absolute; right:calc(100% + 10px); transform:translateY(-50%); font:10px var(--mono); color:var(--subtle); white-space:nowrap; }
.bar-columns { position:absolute; inset:18px 0 0 48px; display:grid; grid-template-columns:repeat(var(--chart-columns),minmax(0,1fr)); gap:14px; }
.bar-column { padding:0 2px; display:grid; grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(0,1fr) 76px; border:0; border-radius:0; background:transparent; min-width:0; text-align:center; }
.bar-column:hover:not(:disabled),.bar-column.picked { background:transparent; }
.bar-track { position:relative; display:block; width:100%; max-width:72px; min-width:0; justify-self:center; border-bottom:1px solid var(--vendor-color); }
.bar-fill { position:absolute; bottom:0; left:0; width:100%; background:var(--vendor-color); opacity:.76; border-radius:5px 5px 0 0; }
.bar-column:hover .bar-fill,.bar-column.picked .bar-fill,.bar-column:focus-visible .bar-fill { opacity:1; }
.bar-value { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); color:var(--ink); font:500 12px var(--mono); white-space:nowrap; }
.bar-vendor { display:flex; flex-direction:column; align-items:center; gap:4px; padding-top:10px; min-width:0; font-size:11px; line-height:1.25; }
.bar-vendor>span { max-width:100%; overflow-wrap:anywhere; }
.bar-vendor img { width:18px; height:18px; object-fit:contain; }
.bar-vendor small { font-size:10px; color:var(--muted); }
.scatter-chart { padding:32px 14px 0 42px; }
.scatter-plot { height:240px; position:relative; border-bottom:1px solid #C7CECA; border-left:1px solid #C7CECA; }
.scatter-grid { position:absolute; inset:0; pointer-events:none; }
.scatter-x { position:absolute; bottom:-12px; width:100%; }
.scatter-x span { position:absolute; transform:translateX(-50%); white-space:nowrap; color:var(--muted); font:10px var(--mono); }
.scatter-dot { position:absolute; display:grid; place-items:center; width:36px; height:36px; padding:5px; border:1px solid var(--line); border-radius:10px; background:#fff; transform:translate(-50%,50%); box-shadow:0 2px 6px #00000012; }
.scatter-dot img { position:relative; grid-area:1 / 1; width:24px; height:24px; object-fit:contain; background:#fff; }
.scatter-logo-fallback { grid-area:1 / 1; font:500 14px var(--text); color:var(--ink); }
.scatter-dot:hover:not(:disabled),.scatter-dot.picked,.scatter-dot:focus-visible { background:#fff; border-color:var(--vendor-color); box-shadow:0 0 0 3px #DDEBE5,0 2px 6px #00000012; z-index:2; }
.scatter-axis-title { text-align:center; margin:38px 0 18px; color:var(--muted); font-size:11px; }
.chart-legend { display:flex; flex-wrap:wrap; justify-content:center; gap:4px 10px; }
.chart-legend button { display:flex; align-items:center; gap:6px; padding:5px 7px; font-size:11px; border:0; border-radius:5px; background:transparent; }
.chart-legend button[aria-pressed=true] { background:var(--card); }
.chart-legend i { width:8px; height:8px; border-radius:50%; }
.chart-detail { display:flex; flex-wrap:wrap; align-items:center; gap:8px 20px; min-height:46px; margin-top:12px; padding:12px 0; border-top:1px solid var(--line); color:var(--muted); font-size:11px; }
.chart-detail strong { color:var(--ink); font-size:12px; font-weight:500; }
.chart-detail b { color:var(--ink); font:11px var(--mono); margin-left:4px; }
.chart-caveat { color:var(--muted); font-size:10px; line-height:1.5; }
.chart-empty { min-height:240px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; color:var(--muted); text-align:center; }
@media(max-width:600px) {
.vendor-chart { padding:18px 14px 12px; }
.chart-heading { display:block; }
.chart-heading h3 { font-size:14px; }
.chart-source { display:none; }
.stats-views { width:100%; }
.stats-views button { flex:1 1 auto; padding:8px; font-size:11px; }
.scatter-chart { padding-left:32px; padding-right:14px; }
.scatter-plot { height:210px; }
.scatter-x span:nth-child(even) { display:none; }
}
/* The dashboard and Arena share the agent picker and setup instructions. */
.intro { flex-wrap:wrap; }
.setup-trigger { margin-left:auto; display:inline-flex; align-items:center; gap:10px; padding:9px 12px; border-radius:12px; background:var(--surface); font-size:12px; white-space:nowrap; }
.setup-logos { display:inline-flex; align-items:center; gap:5px; }
.setup-logos img { width:20px; height:20px; object-fit:contain; }
.setup-logos>span { color:var(--muted); font-size:11px; }
.setup-dialog { width:min(94vw,560px); padding:28px; }
.setup-dialog h2 { font-family:var(--text); font-size:20px; margin:0 0 8px; }
.setup-dialog .eyebrow { margin-bottom:12px; }
.setup-subtitle { color:var(--muted); font-size:14px; margin-bottom:18px; }
.setup-dialog .agent-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; }
.setup-dialog .agent-card { display:flex; align-items:center; gap:9px; padding:12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--ink); font-size:13px; text-align:left; }
.setup-dialog .agent-card img { width:20px; height:20px; flex:none; }
.setup-dialog .agent-card.on { border-color:var(--ink); box-shadow:inset 0 0 0 1px var(--ink); background:var(--bg); }
.setup-footer { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-top:22px; padding-top:16px; border-top:1px solid var(--line); }
.setup-dialog .sub { color:var(--muted); }
.setup-dialog .lc-codewrap { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:48px 16px 16px; }
.setup-dialog pre { font-family:var(--mono); font-size:13px; line-height:1.6; margin:0; }
.setup-dialog .lc-cp { position:absolute; right:10px; top:10px; padding:5px 10px; font-size:12px; border-radius:7px; }
.setup-dialog .btn { padding:10px 14px; border:1px solid var(--line); border-radius:10px; }
@media(max-width:560px){
.setup-trigger { margin-left:0; }
.setup-dialog .agent-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
}
.setup-dialog.setup-try-dialog { width:min(94vw,680px); }
.setup-dialog .wc-wait { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:13px; }
.setup-dialog .wc-waitdot { width:8px; height:8px; border-radius:50%; background:var(--teal); flex:none; }
.setup-dialog .try-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.setup-dialog .try-card { text-align:left; padding:14px 16px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--ink); }
.setup-dialog .try-card:hover,.setup-dialog .prov-chip:hover { border-color:var(--ink); }
.setup-dialog .try-cat { display:flex; justify-content:space-between; align-items:center; gap:6px; font-size:10px; text-transform:uppercase; color:var(--muted); margin-bottom:8px; }
.setup-dialog .try-ico { width:15px; height:15px; flex:none; }
.setup-dialog .try-ico.avatar { border-radius:50%; }
.setup-dialog .try-copy { text-transform:none; white-space:nowrap; }
.setup-dialog .try-copy.done { color:var(--teal-ink); }
.setup-dialog .try-txt { display:block; font-size:13px; line-height:1.55; }
.setup-dialog .oauth-div { margin:24px 0 12px; padding-top:16px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; }
.setup-dialog .oauth-grp { font-size:12px; font-weight:600; margin-bottom:8px; }
.setup-dialog .prov-chip { display:inline-flex; align-items:center; gap:7px; padding:7px 12px; border:1px solid var(--line); border-radius:999px; background:var(--surface); font-size:12px; }
.setup-dialog .prov-chip img { width:15px; height:15px; }
@media(max-width:560px){ .setup-dialog .try-grid { grid-template-columns:1fr; } }
.search-results { width:100%; }
.search-match { display:grid; grid-template-columns:24px 1fr; gap:10px; padding:14px 0; border-bottom:1px solid var(--line); }
.search-match-index { font:11px var(--mono); color:var(--subtle); padding-top:3px; }
.search-match strong { font-weight:500; font-size:14px; }
.search-match p { color:var(--muted); font-size:12px; margin-top:4px; overflow-wrap:anywhere; }
.search-match-links { display:flex; flex-wrap:wrap; gap:12px; font-size:12px; margin-top:6px; }
.search-match-links a { color:var(--teal-ink); }
.pricing-table.discovery-pricing { min-width:0; }
/* Batch inputs share one header, with accessible labels retained on every field. */
.entry-labels,.multiple-entries .input-row { display:grid; grid-template-columns:16px repeat(var(--input-columns),minmax(0,1fr)) 28px; gap:8px; padding:4px 12px; }
.entry-column-label { min-width:0; padding-left:6px; color:var(--muted); font-size:12px; }
.entry-column-label+.entry-column-label { padding-left:19px; }
.multiple-entries .query-field+.query-field { border-top:0; border-left:1px solid var(--line); padding-left:12px; }
/* Use cases lead the composer; input tools and Run share its footer. */
.composer-tab-scroll { padding:16px 18px 0; }
.composer-tabs { display:flex; align-items:center; min-width:0; gap:3px; overflow-x:auto; padding:2px 0; scrollbar-width:none; overscroll-behavior-x:contain; }
.composer-tabs button { display:flex; flex:0 0 auto; align-items:center; gap:7px; padding:9px 11px; border:1px solid transparent; border-radius:10px; background:transparent; color:var(--muted); font-size:13px; white-space:nowrap; }
.composer-tabs button svg { width:15px; height:15px; flex-shrink:0; }
.composer-tabs button.active { background:#F0F2EF; color:var(--ink); border-color:#E3E7E1; }
.composer-tabs button:focus-visible { outline-offset:-2px; }
#composer .compose-main { padding-bottom:16px; }
.entry-tools { margin-top:14px; gap:12px; }
.entry-submit { display:flex; align-items:center; gap:12px; margin-left:auto; max-width:100%; }
.spreadsheet-hint { color:var(--muted); font-size:11px; line-height:1.4; }
.entry-tools>span { margin-left:0; }
.discovery-hint { margin:0 22px 12px; color:var(--muted); font-size:11px; }
@media(max-width:540px) {
.composer-tab-scroll { padding:12px 12px 0; }
.composer-tabs button { padding:7px 9px; }
.entry-tools { gap:10px; }
.entry-actions { flex-wrap:wrap; column-gap:12px; row-gap:4px; }
.discovery-hint { margin:0 16px 12px; font-size:10px; }
}
.masthead { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); }
.masthead>nav[aria-label="Arena account"] { justify-self:end; }
.masthead .arena-page-tabs { display:flex; justify-self:center; gap:4px; padding:5px; margin:0; border:1px solid #E9ECE9; border-radius:999px; background:#fff; box-shadow:0 2px 8px #18241C04; }
.arena-page-tabs a { display:flex; align-items:center; justify-content:center; gap:8px; padding:9px 18px; border-radius:999px; text-decoration:none; font-size:13px; line-height:20px; color:var(--muted); font-weight:500; white-space:nowrap; transition:background .15s,color .15s; }
.arena-page-tabs a svg { width:16px; height:16px; flex-shrink:0; }
.arena-page-tabs a:hover { color:var(--ink); background:#F7F9F7; }
.arena-page-tabs a[aria-current="page"] { color:#31594F; background:#EDF5F1; box-shadow:inset 0 0 0 1px #E2EEE7; }
@media(max-width:960px) {
.masthead { grid-template-columns:minmax(0,1fr) auto; }
.masthead .arena-page-tabs { grid-column:1 / -1; grid-row:2; margin-top:4px; }
.masthead>nav[aria-label="Arena account"] { grid-column:2; grid-row:1; }
}
/* Dedicated leaderboard: category row, quiet filter rail and one comparison canvas. */
.leaderboard-controls { margin-bottom:32px; }
.leaderboard-title { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:20px; text-align:center; padding:34px 0 28px; }
.leaderboard-title>div { grid-column:2; }
.leaderboard-title .setup-trigger { grid-column:3; justify-self:end; margin-left:0; }
.leaderboard-title p { margin-top:8px; color:var(--muted); font-size:14px; }
.leaderboard-controls .composer-tab-scroll { padding:0; width:fit-content; max-width:100%; margin-inline:auto; }
.leaderboard-controls .composer-tabs { gap:5px; background:#fff; border:1px solid var(--line); border-radius:999px; padding:5px; }
.leaderboard-controls .composer-tabs button { padding:10px 17px; border:0; border-radius:999px; }
.leaderboard-controls .composer-tabs button[aria-selected="true"] { background:#EDF4F0; color:#345D50; box-shadow:none; }
.leaderboard-results { display:grid; grid-template-columns:200px minmax(0,1fr); gap:0 24px; align-items:start; }
.leaderboard-filters { grid-column:1; grid-row:1 / span 5; }
.leaderboard-results>.vendor-chart,.leaderboard-results>.pricing-note,.leaderboard-results>.stats-methodology { grid-column:2; }
.filter-label { color:var(--muted); font-size:11px; font-weight:500; margin-bottom:10px; }
.leaderboard-filters .stats-views { display:grid; gap:4px; width:100%; margin:0 0 28px; border:0; background:none; padding:0; }
.leaderboard-filters .stats-views button { display:flex; gap:10px; align-items:center; text-align:left; padding:11px 12px; font-size:13px; border-radius:7px; }
.leaderboard-filters .stats-views button svg { width:17px; height:17px; flex-shrink:0; }
.leaderboard-filters .stats-views button.active { background:#E9ECE9; box-shadow:none; color:#365C52; }
.leaderboard-inputs { border:0; padding:0; margin:0; min-width:0; }
.leaderboard-inputs label { display:flex; gap:9px; align-items:center; padding:10px 12px; font-size:12px; color:var(--muted); cursor:pointer; border-radius:7px; }
.leaderboard-inputs label.active { color:var(--ink); background:#EEEEEB; }
.leaderboard-inputs input { margin:0; width:13px; height:13px; accent-color:#52796E; flex-shrink:0; }
.leaderboard-source { margin:26px 12px 0; color:var(--muted); font-size:11px; line-height:1.7; }
.leaderboard-source span { display:block; font-size:10px; margin-top:6px; }
.leaderboard-results .vendor-chart { border-radius:10px; padding:22px 22px 16px; }
.leaderboard-results .bar-chart { height:380px; }
.leaderboard-results .chart-heading h3 { font-size:16px; }
.leaderboard-results .chart-heading h3 span { display:block; font-size:12px; margin-top:5px; }
.leaderboard-results .chart-source { font:500 11px var(--text); }
@media(max-width:760px) {
.leaderboard-results { grid-template-columns:minmax(0,1fr); }
.leaderboard-filters { grid-column:1; grid-row:auto; margin-bottom:18px; }
.leaderboard-results>.vendor-chart,.leaderboard-results>.pricing-note,.leaderboard-results>.stats-methodology { grid-column:1; }
.leaderboard-filters .stats-views { display:flex; margin-bottom:16px; gap:3px; }
.leaderboard-filters .stats-views button { flex:1; padding:10px 8px; font-size:11px; justify-content:center; }
.leaderboard-inputs { display:flex; flex-wrap:wrap; gap:4px; }
.leaderboard-inputs label { padding:8px 10px; }
.leaderboard-source { display:none; }
.leaderboard-title { padding:12px 0 22px; }
.leaderboard-controls { margin-bottom:22px; }
}
/* Show directional overflow affordances without a visible scrollbar. */
.composer-tab-viewport { position:relative; min-width:0; --tab-edge:#fff; }
.composer-tabs::-webkit-scrollbar { display:none; }
.tab-overflow { position:absolute; top:0; bottom:0; z-index:1; width:46px; display:flex; align-items:center; pointer-events:none; }
.tab-overflow-left { left:0; justify-content:flex-start; background:linear-gradient(to right,var(--tab-edge) 55%,transparent); }
.tab-overflow-right { right:0; justify-content:flex-end; background:linear-gradient(to left,var(--tab-edge) 55%,transparent); }
.tab-overflow button { display:grid; place-items:center; width:27px; height:27px; padding:0; border:1px solid #E8ECE8; border-radius:50%; background:#fff; color:#7A827C; pointer-events:auto; box-shadow:0 1px 4px #00000006; }
.tab-overflow button:hover { background:#F2F6F3; color:var(--ink); }
.tab-overflow svg { width:14px; height:14px; }
.leaderboard-controls .composer-tab-viewport { --tab-edge:#fff; border-radius:999px; overflow:hidden; }
@media(max-width:960px) {
.leaderboard-title { grid-template-columns:minmax(0,1fr); gap:16px; }
.leaderboard-title>div { grid-column:1; }
.leaderboard-title .setup-trigger { grid-column:1; justify-self:center; }
}
.price-chart .bar-grid { left:76px; }
.price-chart .bar-columns { left:76px; }
.price-chart .bar-chart { min-width:calc(var(--chart-columns)*112px + 76px); }
.price-comparison { margin-top:20px; }
.price-comparison-row { display:grid; grid-template-columns:minmax(130px,190px) minmax(0,1fr) 84px; align-items:center; gap:16px; width:100%; border:0; border-radius:8px; padding:10px 8px; background:transparent; text-align:left; }
.price-comparison-row:hover:not(:disabled),.price-comparison-row.picked { background:#F6F8F6; }
.price-vendor { display:flex; align-items:center; gap:10px; min-width:0; font-size:12px; }
.price-vendor img { flex-shrink:0; object-fit:contain; }
.price-vendor>span { overflow-wrap:anywhere; }
.price-track { height:12px; border-radius:3px; background:#F1F3F0; overflow:hidden; }
.price-track>span { display:block; height:100%; border-radius:3px; opacity:.8; }
.price-amount { text-align:right; font:12px var(--mono); font-variant-numeric:tabular-nums; }
.price-scale-note { text-align:right; font-size:10px; color:var(--muted); padding:8px; }
@media(max-width:600px) {
.price-comparison-row { grid-template-columns:minmax(0,1fr) 72px; gap:8px 10px; padding:12px 0; }
.price-comparison-row .price-vendor { grid-column:1; grid-row:1; }
.price-comparison-row .price-amount { grid-column:2; grid-row:1; }
.price-comparison-row .price-track { grid-column:1/-1; grid-row:2; height:8px; }
.price-vendor { font-size:11px; gap:7px; }
.price-amount { font-size:11px; }
}
.auto-verify-control { position:relative; flex-shrink:0; width:fit-content; max-width:100%; margin:0; }
.verification-switch { position:relative; display:inline-flex; align-items:center; gap:9px; padding:7px 0; border:0; border-radius:6px; background:transparent; color:var(--muted); font-size:12px; font-weight:500; cursor:pointer; transition:border-color .15s,background .15s; }
.verification-switch:hover { color:var(--ink); }
.verification-switch.enabled { color:var(--ink); }
.verification-switch input { position:absolute; width:1px; height:1px; padding:0; margin:0; opacity:0; }
.verification-switch:has(input:focus-visible) { outline:2px solid var(--teal-ink); outline-offset:3px; }
.verification-switch.disabled { opacity:.5; cursor:not-allowed; }
.verification-switch-track { display:flex; align-items:center; width:30px; height:18px; padding:2px; flex-shrink:0; border-radius:999px; background:#DCE2DE; transition:background .15s; }
.verification-switch-track>span { width:14px; height:14px; border-radius:50%; background:white; box-shadow:0 1px 3px #0002; transition:transform .15s; }
.verification-switch input:checked+.verification-switch-track { background:var(--teal-ink); }
.verification-switch input:checked+.verification-switch-track>span { transform:translateX(12px); }
.verification-info { width:14px; height:14px; stroke:currentColor; stroke-width:1.4; opacity:.65; }
.verification-cost-hint { position:absolute; z-index:5; bottom:calc(100% + 8px); left:0; width:260px; max-width:calc(100vw - 80px); padding:12px 14px; border:1px solid #E4E8E4; border-radius:12px; background:#fff; color:var(--muted); box-shadow:0 6px 24px #182F2414; font-size:11px; line-height:1.5; visibility:hidden; opacity:0; transition:opacity .15s; }
.verification-cost-hint::after { content:""; position:absolute; left:0; right:0; top:100%; height:10px; }
.verification-cost-hint strong { display:block; margin-bottom:4px; color:var(--ink); font-size:12px; font-weight:500; }
.auto-verify-control:not(.hint-dismissed):is(:hover,:focus-within) .verification-cost-hint { visibility:visible; opacity:1; }
@media (prefers-reduced-motion:reduce) { .verification-switch,.verification-switch-track,.verification-switch-track>span,.verification-cost-hint { transition:none; } }
.verification-result { display:flex; flex-wrap:wrap; align-items:center; gap:5px 8px; margin-top:6px; font-size:11px; }
.verification-result strong { font-weight:500; text-transform:capitalize; background:#EFF4F1; border-radius:5px; padding:2px 6px; }
.verification-result>span { color:var(--muted); font-size:10px; }
.outcome.verdict-valid,.verification-result strong.verdict-valid { color:#14663D; background:#DDF5E6; }
.outcome.verdict-invalid,.verification-result strong.verdict-invalid { color:#B42332; background:#FDE8EB; }
.outcome.verdict-risky,.verification-result strong.verdict-risky { color:#805400; background:#FFF2CC; }
.outcome.verdict-unknown,.verification-result strong.verdict-unknown { color:#58616B; background:#EDF0F3; }
/* Published people-search benchmark, separate from observed vendor call stats. */
.is-benchmark .arena-layout { display:block; }
.is-benchmark main { width:100%; max-width:1160px; margin:0 auto; }
.bench-title { margin-bottom:12px; }
.bench-source { text-align:center; color:var(--muted); font-size:12px; margin:0 0 32px; }
.bench-source a,.bench-methodology a { color:var(--teal-ink); text-underline-offset:3px; }
.bench-category-tabs { display:flex; justify-content:center; flex-wrap:wrap; gap:5px; width:fit-content; max-width:100%; padding:5px; margin:0 auto 28px; border:1px solid var(--line); background:white; border-radius:999px; }
.bench-category-tabs button { padding:10px 17px; border:0; border-radius:999px; background:transparent; color:var(--muted); font-size:13px; }
.bench-category-tabs button[aria-pressed="true"] { background:#EDF4F0; color:#345D50; }
.bench-chart,.bench-mini { background:white; border:1px solid var(--line); border-radius:24px; }
.bench-chart { padding:30px; }
.bench-chart-heading { display:flex; justify-content:space-between; align-items:start; margin-bottom:28px; gap:12px; }
.bench-chart h2,.bench-overview-heading h2 { margin:0; font-size:23px; font-weight:500; }
.bench-chart-heading p,.bench-overview-heading p { margin:6px 0 0; color:var(--muted); font-size:13px; }
.bench-chart-heading>span { color:var(--muted); font:11px var(--mono); }
.bench-axis { display:flex; justify-content:space-between; margin:0 57px 8px 206px; color:var(--subtle); font:10px var(--mono); }
.bench-bar-row { display:grid; grid-template-columns:190px minmax(0,1fr) 42px; align-items:center; gap:16px; min-height:65px; }
.bench-system { display:flex; gap:10px; align-items:center; font-size:13px; }
.bench-system img,.bench-mini-row img { object-fit:contain; border-radius:4px; flex-shrink:0; }
.bench-bar-track { background:repeating-linear-gradient(to right,#E9EDEA 0,#E9EDEA 1px,transparent 1px,transparent 25%); border-right:1px solid #E9EDEA; height:32px; }
.bench-bar-track>span { display:block; height:100%; background:var(--bench-color); border-radius:0 7px 7px 0; }
.bench-bar-row>strong,.bench-mini-row>strong { font:13px var(--mono); font-weight:500; text-align:right; }
.bench-chart-note { border-top:1px solid var(--line); padding-top:18px; margin:24px 0 0; font-size:12px; color:var(--muted); line-height:1.6; }
.bench-overview-heading { margin:38px 0 18px; }
.bench-overview { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; }
.bench-mini { padding:24px; }
.bench-mini h3 { margin:0 0 20px; font-size:17px; font-weight:500; }
.bench-mini-row { display:grid; grid-template-columns:155px minmax(0,1fr) 36px; align-items:center; gap:12px; margin:15px 0; }
.bench-mini-row>span { display:flex; align-items:center; gap:8px; font-size:11px; }
.bench-mini-row .bench-bar-track { height:17px; }
.bench-mini-row>strong { font-size:11px; }
.bench-methodology { margin-top:24px; padding:20px 24px; border:1px solid var(--line); border-radius:18px; background:#FFFFFFA6; color:var(--muted); font-size:12px; line-height:1.7; }
.bench-methodology summary { cursor:pointer; color:var(--ink); font-size:13px; }
.bench-methodology a { margin-right:20px; }
.bench-message { padding:50px 20px; text-align:center; color:var(--muted); }
@media(max-width:1100px) { .masthead .brand-sub,.masthead .brand-divider { display:none; } }
@media(max-width:760px) {
.masthead { grid-template-columns:1fr auto; }
.masthead .arena-page-tabs { grid-column:1/-1; grid-row:2; margin-top:12px; max-width:100%; }
.masthead>nav[aria-label="Arena account"] { grid-column:2; grid-row:1; }
.bench-overview { grid-template-columns:1fr; }
.bench-chart { padding:20px 16px; }
.bench-bar-row { grid-template-columns:140px minmax(0,1fr) 35px; gap:10px; }
.bench-system { font-size:11px; gap:6px; }
.bench-system img { width:19px; height:19px; }
.bench-axis { margin-left:150px; margin-right:45px; }
}
@media(max-width:440px) {
.masthead .arena-page-tabs a { padding:9px 8px; font-size:11px; gap:0; }
.masthead .arena-page-tabs a svg { display:none; }
.bench-category-tabs { border-radius:18px; }
.bench-bar-row { grid-template-columns:minmax(0,1fr) 40px; gap:8px; margin:18px 0; }
.bench-system { grid-column:1/-1; }
.bench-axis { margin-left:0; margin-right:48px; }
.bench-mini { padding:20px 16px; }
.bench-mini-row { grid-template-columns:132px minmax(0,1fr) 32px; gap:8px; }
}
/* Verification estimates share the existing metric layout. */
.verified-track>span{background:#7b8fae}
.verified-explanation{font-size:13px;line-height:1.6;color:var(--muted,#777);margin:0 0 18px;max-width:80ch}
.metric-help-header { cursor:help; }
.metric-help-trigger { border:0; padding:4px 0; background:transparent; color:inherit; font:inherit; cursor:help; text-decoration:underline dotted; text-underline-offset:5px; text-decoration-color:#a9b5af; }
.metric-help-trigger:focus-visible { outline:2px solid #5b8f88; outline-offset:4px; border-radius:4px; }
.metric-help-tooltip { position:fixed; z-index:10000; box-sizing:border-box; padding:14px 16px; border:1px solid #dfe5e1; border-radius:14px; background:#fff; color:#38473f; box-shadow:0 8px 28px #182e2026; font:13px/1.6 system-ui,sans-serif; text-align:left; pointer-events:none; }
/* A quiet addition to the vendor lineup, separate from selectable fighters. */
.fighter-request { flex:0 0 40px; display:grid; place-items:center; align-self:center; padding:0; background:transparent; border:0; color:#98a59e; cursor:pointer; }
.fighter-request-plus { display:grid; place-items:center; width:34px; height:34px; border:1px solid #e0e6e2; border-radius:50%; background:transparent; transition:background .15s,color .15s; }
.fighter-request-plus svg { width:16px; height:16px; }
.fighter-request:hover { color:#315e50; }
.fighter-request:hover .fighter-request-plus { background:#f0f5f2; border-color:#b9ccc1; }
.fighter-request:focus-visible { outline:2px solid #5b8f88; outline-offset:3px; border-radius:50%; }
.vendor-request-dialog { width:min(540px,calc(100vw - 32px)); box-sizing:border-box; padding:28px; background:#fff; }
.vendor-request-dialog h2 { padding-right:28px; font-size:26px; margin:0 0 12px; }
.vendor-request-dialog .request-intro,.vendor-selfserve>p { font-size:13px; line-height:1.6; color:var(--muted); margin:0; }
.vendor-request-dialog textarea { display:block; width:100%; box-sizing:border-box; resize:vertical; min-height:80px; border:1px solid var(--line); border-radius:12px; padding:12px; background:#fafbfa; color:var(--ink); font:inherit; margin-top:6px; }
.request-optional { color:var(--muted); font-size:12px; font-weight:400; }
.request-footer { display:flex; align-items:center; gap:14px; margin-top:18px; }
.request-footer>span { color:var(--muted); font-size:11px; line-height:1.5; }
.vendor-request-success { display:flex; align-items:flex-start; gap:10px; padding:0 0 4px; }
.vendor-request-success>span { display:grid; place-items:center; flex:0 0 24px; height:24px; border-radius:50%; background:#e9f4ee; color:#35735d; font-size:15px; }
.vendor-request-success>p { font-size:14px; line-height:1.6; color:var(--muted); margin:0; }
.vendor-selfserve { border-top:1px solid var(--line); padding-top:20px; margin-top:22px; }
.vendor-selfserve h3 { font-family:inherit; font-size:14px; font-weight:600; margin:0 0 6px; }
.vendor-selfserve .vendor-listing-prompt { min-height:94px; resize:none; font:12px/1.65 var(--mono); margin-top:12px; }
.vendor-selfserve-actions { display:flex; align-items:center; justify-content:space-between; margin-top:12px; }
.vendor-copy-prompt { background:#f4f7f5; color:#315e50; border:1px solid #dbe5df; border-radius:9px; padding:8px 12px; font-family:inherit; font-size:12px; cursor:pointer; }
.vendor-copy-prompt:hover { background:#eaf1ed; }
/* Shared landing-page community links, kept visible beside the account controls. */
.masthead>nav[aria-label="Arena account"] { flex-wrap:wrap; justify-content:flex-end; row-gap:8px; }
.arena-community { display:flex; align-items:center; gap:14px; flex-shrink:0; }
.arena-community a { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:36px; color:var(--muted); text-decoration:none; white-space:nowrap; }
.arena-community a:hover { color:var(--ink); }
.arena-community a:focus-visible { outline:2px solid var(--teal-ink); outline-offset:4px; border-radius:6px; }
.arena-community svg { flex-shrink:0; }
@media(max-width:1280px) {
.masthead { grid-template-columns:minmax(0,1fr) auto; }
.masthead .arena-page-tabs { grid-column:1/-1; grid-row:2; }
.masthead>nav[aria-label="Arena account"] { grid-column:2; grid-row:1; }
}
/* Mobile layouts keep wide data inside their own scroll regions. */
body:has(dialog[open]) { overflow:hidden; }
@media(max-width:760px) {
.leaderboard-filters .stats-views { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
.leaderboard-filters .stats-views button { min-height:44px; font-size:13px; justify-content:flex-start; }
.leaderboard-filters .stats-views button:last-child:nth-child(odd) { grid-column:1/-1; }
.leaderboard-inputs label { min-height:44px; font-size:13px; }
}
@media(max-width:600px) {
.masthead { grid-template-columns:minmax(0,1fr) auto auto; gap:8px; padding:12px 16px; }
.masthead>nav[aria-label="Arena account"] { display:contents; }
.masthead .brand { grid-column:1; grid-row:1; min-height:44px; }
.masthead .sign-in-button,.masthead .balance { grid-column:3; grid-row:1; justify-self:end; }
.masthead .arena-community { grid-column:2; grid-row:1; }
.masthead nav>.text-button { grid-column:3; grid-row:2; justify-self:end; }
.masthead .team-switcher { grid-column:1/3; grid-row:2; }
.masthead:has(nav>.text-button) .arena-page-tabs { grid-row:3; }
.masthead .arena-page-tabs { grid-column:1/-1; grid-row:2; width:100%; gap:2px; margin-top:0; }
.masthead .arena-page-tabs a { flex:1; min-height:44px; justify-content:center; padding:10px 6px; font-size:12px; }
.arena-community { gap:0; }
.arena-community a { min-width:44px; min-height:44px; }
.masthead .balance { display:flex; min-height:44px; }
.masthead .text-button,.masthead .sign-in-button { min-height:44px; }
.masthead select { width:160px; max-width:100%; min-height:44px; font-size:16px; }
.masthead select,.input-method select,dialog select,.report-form select { appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cpath d='m3 5 4 4 4-4' fill='none' stroke='%23737373' stroke-width='1.5'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 6px center; background-size:14px; padding-right:26px; }
.composer-tabs button { min-height:44px; }
.tab-overflow { width:50px; }
.tab-overflow button { width:44px; height:44px; }
.entry-inputs { overflow-x:auto; scrollbar-width:thin; overscroll-behavior-x:contain; padding-bottom:4px; }
.entry-labels,.multiple-entries .input-row { min-width:calc(108px + var(--input-columns)*112px); grid-template-columns:16px repeat(var(--input-columns),minmax(0,1fr)) 44px; }
.query-field input,.multiple-entries .query-field input { height:44px; font-size:16px; }
.remove-entry { width:44px; height:44px; }
.entry-actions { width:100%; flex-wrap:wrap; gap:4px 16px; }
.entry-tools .text-button { min-height:44px; }
.input-method { min-width:0; max-width:100%; }
.input-method select { min-height:44px; font-size:16px; }
.verification-switch { min-height:44px; font-size:13px; }
.entry-submit { width:100%; justify-content:space-between; margin-top:4px; }
.spreadsheet-hint { min-width:0; max-width:160px; font-size:12px; }
.run-button { min-height:44px; min-width:120px; font-size:13px; flex-shrink:0; }
.battle-modes button { min-height:44px; font-size:13px; }
.battle-explainer { font-size:12px; }
.setup-trigger { min-height:44px; }
.results-table-wrap { overscroll-behavior-x:contain; }
.matrix-overview.picked,.vendor-summary.picked { transform:none; }
.entry-detail-panel { position:static; width:100%; }
.result-detail-table .vendor-row>.row-actions { grid-column:1/-1; grid-row:auto; }
.rating-button,.details-button,.try-vendor { min-width:44px; min-height:44px; }
.matrix-entry,.matrix-result { min-height:44px; }
.batch-controls select,.report-form select,.report-form textarea { font-size:16px; }
.after-actions .text-button { min-height:44px; }
.fighter-request { min-width:44px; min-height:44px; }
.bench-category-tabs button { min-height:44px; }
.pricing-table th:first-child,.pricing-table td:first-child { position:sticky; left:0; z-index:1; background:var(--surface); }
dialog,.setup-dialog,.vendor-request-dialog { padding:24px 18px; border-radius:22px; }
dialog>h2 { padding-right:36px; }
.dialog-close { width:44px; height:44px; right:6px; top:6px; }
dialog input,dialog select,dialog textarea { font-size:16px; }
dialog button { min-height:44px; }
.setup-dialog .agent-card { min-width:0; padding:10px 8px; overflow-wrap:anywhere; }
.setup-dialog .lc-codewrap { padding-inline:12px; }
}
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } }
@container(max-width:400px) {
.result-detail-table .field-list>div { grid-template-columns:minmax(0,1fr); gap:4px; }
}
/* Keep community links in the top row without shrinking their touch targets. */
@media(max-width:440px) { .masthead .arena-community a>span { display:none; } }
@media(max-width:360px) {
.masthead .brand-name { display:none; }
.masthead .sign-in-button { padding-inline:14px; }
}
+752
View File
@@ -0,0 +1,752 @@
/* Standalone Arena: anonymous browsing, authenticated spending, one-click voting with visible vendors. */
(() => {
'use strict';
if (!window.Vue) return;
const SIGNUP_SETUP = 'treg.arena.signup-setup.v1';
const DRAFT = 'treg.arena.draft.v1';
const ACTIVE = 'treg.arena.active.v1'; // Cleanup only: saved runs now reopen through their URL.
const NAMES = {'treg':'Email verification waterfall','millionverifier':'MillionVerifier','contactout':'ContactOut','trykitt':'Kitt','fiber-ai':'Fiber','pdl':'People Data Labs','branddev':'Brand.dev',
'thecompaniesapi':'TheCompaniesAPI','companyenrich':'CompanyEnrich','leadmagic':'LeadMagic',
'findymail':'Findymail','leadsforge':'Leadsforge','icypeas':'Icypeas','predictleads':'PredictLeads',
'apollo':'Apollo','exa':'Exa','aviato':'Aviato','hunter':'Hunter','tomba':'Tomba','lusha':'Lusha'};
const LABELS = {q:'Describe the people',country:'Country code',count:'Matches returned',people:'People',companies:'Companies',full_name:'Full name',domain:'Company domain',linkedin_url:'LinkedIn URL',name:'Company name',
email:'Email',first_name:'First name',last_name:'Last name',company_domain:'Company domain',
line_type:'Line type',country_code:'Country code',valid:'Valid mailbox',verified:'Verified',
employees:'Employees',founded:'Founded',website:'Website',description:'Description',industry:'Industry',
company:'Company',title:'Job title',location:'Location',phone:'Phone',confidence:'Confidence',status:'Verdict',score:'Score'};
const read = key => {try{return JSON.parse(sessionStorage.getItem(key)||'null');}catch{return null;}};
const write = (key,value) => {try{sessionStorage.setItem(key,JSON.stringify(value));}catch{}};
const remove = key => {try{sessionStorage.removeItem(key);}catch{}};
// Horizontal overflow contains native sticky headers. Offset the real header on page scroll
// instead, preserving column alignment without making the results a vertical scroll box.
const StickyHeader={
mounted(table){
let frame=0,pinnedRow=null,pinnedOffset=0;
const update=()=>{
frame=0;
const head=table.tHead;if(!head)return;
// Mobile results become cards; pinning their overview covers the expanded answer.
if(window.matchMedia?.('(max-width:600px)').matches){
head.style.setProperty('--header-offset','0px');
pinnedRow?.style.removeProperty('--entry-offset');pinnedRow=null;pinnedOffset=0;
return;
}
const parent=table.closest('.entry-detail-panel')?.closest('.batch-matrix')||table.closest('.vendor-detail-panel')?.closest('.batch-scoreboard')?.querySelector(':scope > table');
const selectedRow=':scope > tbody > .matrix-overview.picked, :scope > tbody > .vendor-summary.picked';
const top=(parent?.tHead?.getBoundingClientRect().height||0)+(parent?.querySelector(selectedRow)?.getBoundingClientRect().height||0);
const bounds=table.getBoundingClientRect(),height=head.getBoundingClientRect().height;
head.style.setProperty('--header-offset',Math.max(0,Math.min(top-bounds.top,bounds.height-height))+'px');
const row=table.querySelector(selectedRow);
if(row!==pinnedRow){pinnedRow?.style.removeProperty('--entry-offset');pinnedRow=row;pinnedOffset=0;}
if(row){
const rect=row.getBoundingClientRect(),originalTop=rect.top-pinnedOffset;
const end=row.nextElementSibling.getBoundingClientRect().bottom;
pinnedOffset=Math.max(0,Math.min(height-originalTop,end-originalTop-rect.height));
row.style.setProperty('--entry-offset',pinnedOffset+'px');
}
};
const schedule=()=>{if(!frame)frame=requestAnimationFrame(update);};
const resize=new ResizeObserver(schedule);resize.observe(table);
window.addEventListener('scroll',schedule,{passive:true});
window.addEventListener('resize',schedule);
table._updateStickyHeader=schedule;
table._stopStickyHeader=()=>{cancelAnimationFrame(frame);resize.disconnect();window.removeEventListener('scroll',schedule);window.removeEventListener('resize',schedule);};
schedule();
},
updated(table){table._updateStickyHeader?.();},
unmounted(table){table._stopStickyHeader?.();}
};
// Spreadsheet TSV and CSV, including quoted delimiters and embedded newlines.
function pasteRows(text,columnCount){
const delimiter=text.includes('\t')||columnCount===1&&!text.trim().startsWith('"')?'\t':',',rows=[];let row=[],value='',quoted=false;
for(let i=0;i<text.length;i++){
const c=text[i];
if(c==='"'){if(quoted&&text[i+1]==='"'){value+='"';i++;}else quoted=!quoted;}
else if(!quoted&&(c===delimiter||c==='\n'||c==='\r')){
row.push(value.trim());value='';
if(c!==delimiter){if(row.some(Boolean))rows.push(row);row=[];if(c==='\r'&&text[i+1]==='\n')i++;}
}else value+=c;
}
if(quoted)throw new Error('The pasted list has an unclosed quote. Check the last row.');
row.push(value.trim());if(row.some(Boolean))rows.push(row);
return rows;
}
const ArenaFighters = {
props:{entries:{type:Array,default:()=>[]},mode:String,requestable:Boolean,active:Boolean,selectable:Boolean,disabled:Boolean,selected:{type:Array,default:()=>[]},choosable:Boolean,enabledProviders:{type:Array,default:()=>[]},awards:{type:Object,default:()=>({})}},
template:'#arena-fighters-template',
emits:['change-mode','toggle-provider','request-vendor'],
methods:{
chooseMode(mode){if(this.selectable&&!this.disabled&&!this.active&&mode!==this.mode)this.$emit('change-mode',mode);},
enabled(entry){return !this.choosable||this.enabledProviders.includes(entry.provider);},
chooseProvider(entry){if(this.choosable&&!this.disabled&&!this.active)this.$emit('toggle-provider',entry.provider);},
name(provider){return NAMES[provider]||provider;},
rejected(entry){return (entry.rating?.value || (entry.report?'down':''))==='down';},
state(entry){
if(this.choosable&&!this.active&&!this.enabled(entry))return 'excluded';
if(this.rejected(entry))return 'defeated';
if(this.selected.includes(entry.id))return 'champion';
if(entry.state==='running')return this.active?'fighting':'paused';
return ({hit:'won',miss:'defeated',error:'error',timeout:'error',interrupted:'paused',cancelled:'paused',skipped:'benched',not_attempted:'benched',queued:'waiting'})[entry.state]||'ready';
},
label(entry){return entry.batchLabel||({excluded:'Excluded',champion:'Winner',won:'Hit!',defeated:this.rejected(entry)?'Thumbs down':'No match',error:entry.state==='timeout'?'Timed out':'Error',paused:'Stopped',benched:'Not called',waiting:'Waiting',fighting:'Fighting…',ready:'Ready'})[this.state(entry)];}
}
};
const TaskIcon={props:['task'],computed:{path(){return ({
'people.search':'M14 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM3 21v-2a6 6 0 0 1 8-5.65M20 17a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm-1 2 3 3',
'people.company.search':'M3 21V4h11v6M6 8h1m3 0h1M6 12h1m-1 4h1m-1 4h1M21 21v-1a5 5 0 0 0-10 0v1m8-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
'companies.similar':'M3 3h7v7H3zM14 14h7v7h-7zM14 3h7v7h-7zM6.5 14v3.5H10m0 0-2-2m2 2-2 2',
'people.email.find':'M11 19H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5M2 6l10 7 10-7M20 17a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm-1 2 3 3',
'people.phone.find':'M21 16.5v3a2 2 0 0 1-2.2 2A18 18 0 0 1 2.5 5.2 2 2 0 0 1 4.5 3h3l1.5 5-2 1.5a13 13 0 0 0 7.5 7.5l1.5-2Z',
'people.enrich':'M14 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM3 21v-2a7 7 0 0 1 11-5.74M19 14v8m-4-4h8',
'companies.enrich':'M3 21V3h13v9M7 7h1m4 0h1M7 11h1m4 0h1M7 15h1m-1 4h1M19 14v8m-4-4h8',
'people.identity.resolve':'M10 13a5 5 0 0 0 7 .1l4-4a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7-.1l-4 4a5 5 0 0 0 7 7l2-2',
'people.phone.verify':'M5 3h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4c-8 3-18-7-16-16M15 5l2 2 4-4',
'people.email.verify':'M12 3 3 7v6c0 5 9 9 9 9s9-4 9-9V7ZM8 12l3 3 5-6'
})[this.task]||'M4 4h16v16H4z';}},template:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="path"/></svg>'};
const ArenaTaskTabs={
components:{TaskIcon},props:['groups','value','disabled'],emits:['change'],
data:()=>({resizeObserver:null,overflowLeft:false,overflowRight:false}),
computed:{
tasks(){const all=this.groups.flatMap(g=>g.tasks),order=['people.email.find','people.phone.find','people.enrich','companies.enrich','people.email.verify','people.phone.verify','people.identity.resolve','people.search','people.company.search','companies.similar'];return order.map(id=>all.find(t=>t.id===id)).filter(Boolean);},
},
watch:{value:'reveal',tasks:'reveal'},
methods:{
updateOverflow(){
const host=this.$refs.scroller;
this.overflowLeft=host.scrollLeft>1;
this.overflowRight=host.scrollWidth-host.clientWidth-host.scrollLeft>1;
},
scrollTabs(direction){
const host=this.$refs.scroller;
host.scrollLeft+=direction*Math.max(160,host.clientWidth*.65);
this.updateOverflow();
},
async reveal(){
await this.$nextTick();
const host=this.$refs.scroller,tab=host.querySelector('[aria-selected="true"]');
if(tab&&host.scrollWidth>host.clientWidth){
const box=host.getBoundingClientRect(),item=tab.getBoundingClientRect(),inset=36;
if(item.left<box.left+inset)host.scrollLeft-=box.left+inset-item.left;
else if(item.right>box.right-inset)host.scrollLeft+=item.right-box.right+inset;
}
this.updateOverflow();
},
label(task){return ({'people.email.find':'Find work email','people.enrich':'Enrich person','companies.enrich':'Enrich company','people.phone.find':'Find phone number','people.identity.resolve':'Find LinkedIn profile','people.company.search':'Find people at company'})[task.id]||task.label;},
select(id){if(!this.disabled&&id!==this.value)this.$emit('change',id);},
keydown(event){
if(this.disabled)return;
const tabs=[...event.currentTarget.querySelectorAll('[role="tab"]')],index=tabs.indexOf(event.target.closest('[role="tab"]'));
const next=({ArrowRight:(index+1)%tabs.length,ArrowLeft:(index-1+tabs.length)%tabs.length,Home:0,End:tabs.length-1})[event.key];
if(next!==undefined&&tabs[next]){event.preventDefault();tabs[next].focus();this.select(tabs[next].dataset.task);}
}
},
mounted(){this.resizeObserver=new ResizeObserver(this.reveal);this.resizeObserver.observe(this.$refs.scroller);},
beforeUnmount(){this.resizeObserver?.disconnect();},
template:`<div class="composer-tab-scroll"><div class="composer-tab-viewport"><div ref="scroller" class="composer-tabs" role="tablist" aria-label="Use case" @keydown="keydown" @scroll.passive="updateOverflow"><button v-for="task in tasks" :key="task.id" type="button" role="tab" :id="'arena-task-'+task.id" :data-task="task.id" aria-controls="task-inputs" :aria-selected="value===task.id" :tabindex="value===task.id?0:-1" :disabled="disabled" :class="{active:value===task.id}" @click="select(task.id)"><task-icon :task="task.id"/><span>{{label(task)}}</span></button></div><div v-if="overflowLeft" class="tab-overflow tab-overflow-left"><button type="button" aria-label="Scroll use cases left" @click="scrollTabs(-1)"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m10 4-4 4 4 4"/></svg></button></div><div v-if="overflowRight" class="tab-overflow tab-overflow-right"><button type="button" aria-label="Scroll use cases right" @click="scrollTabs(1)"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg></button></div></div></div>`
};
Vue.createApp({
directives:{stickyHeader:StickyHeader},
components:{ArenaTaskTabs,TregTryItOut:TregAgentSetup.TryItOut,TregAgentPicker:TregAgentSetup.AgentPicker,TregSetupInstructions:TregAgentSetup.SetupInstructions,ArenaFighters,ArenaResultTable:{directives:{stickyHeader:StickyHeader},inject:['arena'],props:{rows:{type:Array,required:true},entryView:Boolean},methods:{resultLabel(r){return this.arena.providerName(r.provider)+(this.entryView?' · '+this.arena.entryLabel(this.arena.runEntries[r.entry_index||0]):'');}},template:'#arena-result-table-template'}},
provide(){return {arena:this};},
data:()=>({benchmark:(location.pathname||'').endsWith('/people-search-bench'),benchCategories:[],benchCategory:'',benchLoading:false,benchError:'',leaderboard:(location.pathname||'').endsWith('/leaderboard'),setupStep:1,setupTeamName:'',setupExampleCopied:'',setupAgentId:'claude-code',setupToken:null,setupShowToken:false,setupCopied:false,setupError:'',setupLoading:false,setupSequence:0,tasks:[],taskId:'people.email.find',variant:0,inputs:{},extraInputs:[],showAllEntries:false,selectedEntry:null,selectedVendor:'',resultFilter:'all',mode:'waterfall',autoVerify:true,verificationHintHidden:false,verificationQuotes:{},verificationPending:{},
user:null,teams:[],team:'',balance:null,meta:{},busy:false,error:'',run:null,quote:null,history:[],customServices:false,services:[],
insights:null,insightsState:'idle',insightsTimer:null,statsView:'rate',chartFocus:null,metricTooltip:null,requestDone:false,requestBusy:false,vendorPromptCopied:false,vendorPromptError:'',requestError:'',requestQuery:'',requestForm:{capability:'',note:'',contact:''},
manualQuotes:{},manualPending:{},reporting:'',reportDrafts:{},pricing:false,quoteTimer:null,quoteSequence:0,pricedKey:'',expandedResults:[],email:'',code:'',emailStep:'email',devCode:'',authBusy:false,authError:'',
newTeamName:'',pendingSubmit:false,pollTimer:null,pollFailures:0,runTeam:'',booted:false,draftRestored:false,historySequence:0,historyLoading:false,historyHasMore:false,linkedRunPending:false,urlPopHandler:null}),
watch:{
'run.id'(){this.selectedVendor='';},
quoteKey(){this.scheduleQuote();},
running(value){if(!value)this.scheduleQuote();},
user(){this.scheduleQuote();}
},
computed:{
setupFeatured(){return ['claude-code','codex','openclaw','hermes'].map(id=>TregAgentSetup.agents.find(a=>a.id===id));},
setupOtherCount(){return TregAgentSetup.agents.length+TregAgentSetup.moreAgents.length-this.setupFeatured.length;},
setupAgent(){return [...TregAgentSetup.agents,...TregAgentSetup.moreAgents].find(a=>a.id===this.setupAgentId)||TregAgentSetup.agents[0];},
setupCommand(){return TregAgentSetup.command(this.meta.public_url||location.origin);},
inputRows(){return [this.inputs,...this.extraInputs];},
visibleInputs(){return this.showAllEntries?this.inputRows:this.inputRows.slice(0,3);},
runEntries(){return this.run?.identities||[this.run?.identity||{}];},
isBatch(){return this.runEntries.length>1;},
vendorResults(){return (this.run?.results||[]).filter(r=>r.provider===this.selectedVendor&&this.hasAttempt(r));},
hasBatchFeedback(){return this.batchVendors.some(v=>v.up>0||v.down>0);},
visibleResults(){return this.isBatch?this.run.results.filter(r=>r.entry_index===this.selectedEntry):this.run?.results||[];},
matrixEntries(){return this.runEntries.map((identity,index)=>({identity,index,results:(this.run?.results||[]).filter(r=>(r.entry_index||0)===index)})).filter(e=>{
const hits=e.results.filter(r=>r.state==='hit'&&this.ratingValue(r)!=='down');
const answer=r=>this.discoveryRun()?this.searchMatches(r).map(match=>JSON.stringify(match)).sort():this.primaryFields.map(k=>r.output[k]??null);
return this.resultFilter==='all'||this.resultFilter==='found'&&hits.length>0||this.resultFilter==='unresolved'&&!hits.length||this.resultFilter==='disagreement'&&new Set(hits.map(r=>JSON.stringify(answer(r)))).size>1;
});},
batchVendors(){
if(!this.isBatch)return [];
const groups=new Map();
for(const r of this.run.results){if(!groups.has(r.provider))groups.set(r.provider,[]);groups.get(r.provider).push(r);}
return [...groups].map(([provider,results])=>{
const hits=results.filter(r=>r.state==='hit'),kept=hits.filter(r=>this.ratingValue(r)!=='down');
const attempted=results.filter(r=>!['not_attempted','skipped','queued'].includes(r.state));
const complete=results.every(r=>['hit','miss','error','timeout'].includes(r.state));
const timings=kept.map(r=>r.duration_ms).filter(Number.isFinite).sort((a,b)=>a-b);
const cost=results.every(r=>Number.isFinite(r.charged_micro))?results.reduce((sum,r)=>sum+r.charged_micro,0):null;
const state=results.some(r=>r.state==='running')?'running':results.some(r=>r.state==='queued')?'queued':kept.length?'hit':attempted.length?'miss':'not_attempted';
const total=this.runEntries.length;
return {id:provider,provider,state,total,complete,found:hits.length,kept:kept.length,attempted:attempted.length,
up:results.filter(r=>this.ratingValue(r)==='up').length,down:results.filter(r=>this.ratingValue(r)==='down').length,
cost,costPerKept:kept.length&&cost!==null?cost/kept.length:null,
median:timings.length===kept.length&&timings.length?(timings[Math.floor((timings.length-1)/2)]+timings[Math.floor(timings.length/2)])/2:null,
batchLabel:this.discoveryRun()?hits.length+'/'+(this.run.mode==='waterfall'?attempted.length:total)+' queries matched':this.run.mode==='waterfall'?hits.length+' found · '+attempted.length+' tried':Math.round(hits.length/total*100)+'% · '+hits.length+'/'+total+' found'};
});
},
batchAwards(){
const awards={};if(!this.isBatch||this.run.state!=='completed'||this.run.mode!=='compare')return awards;
const eligible=this.batchVendors.filter(v=>v.kept>0);
// A partial vendor cohort cannot win a completed benchmark.
if(this.batchVendors.some(v=>!v.complete||v.attempted!==v.total))return awards;
for(const [field,label,maximize] of [['kept','Coverage',true],['costPerKept','Cheapest',false],['median','Fastest',false]]){
if(!eligible.length||!eligible.every(v=>Number.isFinite(v[field])))continue;
const best=(maximize?Math.max:Math.min)(...eligible.map(v=>v[field]));
for(const v of eligible)if(v[field]===best)(awards[v.id]||=[]).push(label);
}return awards;
},
quoteKey(){return JSON.stringify({team:this.team,capability:this.taskId,auto_verify:this.autoVerify&&!!this.verificationTask,identities:this.identities(),mode:this.mode,providers:this.customServices?this.services:null});},
readyQuote(){return this.quote&&this.pricedKey===this.quoteKey?this.quote:null;},
runButtonLabel(){if(this.running)return 'Running…';if(this.busy)return 'Starting…';if(this.pricing)return 'Pricing…';const q=this.readyQuote,n=this.inputRows.length,run=n>1?'Run '+n+' entries':'Run';if(q)return this.mode==='waterfall'?(q.affordable?run+' from ':'Top up · from ')+this.usd(q.required_micro):(q.affordable?run:'Top up')+' · ~'+this.usd(q.required_micro);return n>1?run:this.mode==='waterfall'?'Run waterfall':'Battle';},
benchSelected(){return this.benchCategories.find(c=>c.id===this.benchCategory)||this.benchCategories[0]||null;},
benchRows(){return [...(this.benchSelected?.rows||[])].sort((a,b)=>b.score-a.score);},
verificationTask(){return ({'people.email.find':'people.email.verify','people.phone.find':'people.phone.verify'})[this.taskId]||null;},
verificationPreview(){return this.verificationEstimate(this.verificationTask);},
discovery(){return !!this.currentTask.discovery;},
maxEntries(){return this.currentTask.max_entries||50;},
jobGroups(){const groups=[{label:'Discover',ids:['people.search','people.company.search','companies.similar']},{label:'Enrich',ids:['people.email.find','people.phone.find','people.enrich','companies.enrich','people.identity.resolve']},{label:'Verify',ids:['people.email.verify','people.phone.verify']}];return groups.map(g=>({...g,tasks:g.ids.map(id=>this.tasks.find(t=>t.id===id)).filter(Boolean)})).filter(g=>g.tasks.length);},
currentTask(){return this.tasks.find(t=>t.id===this.taskId)||{description:'Choose a task to get started.',variants:[],providers:[],fields:[]};},
inputKeys(){return this.currentTask.variants[this.variant]||[];},
insightInput(){return this.inputKeys.length===2&&this.inputKeys.includes('full_name')&&this.inputKeys.includes('domain')?'name_domain':this.inputKeys.length===1?this.inputKeys[0]:'';},
insightRows(){
// Always use the public task cohort, independent of paid quotes and avatar selection.
const rows=this.insights?.rows||[];
return (this.currentTask.provider_previews?.[this.variant]||[]).map(p=>{
const sample=rows.find(r=>r.task===this.taskId&&r.input===this.insightInput&&r.endpoint===p.endpoint_id);
const audit=(this.insights?.verification?.rows||[]).find(r=>r.task===this.taskId&&r.input===this.insightInput&&r.endpoint===p.endpoint_id);
return {...p,sample,audit};
}).sort((a,b)=>this.providerName(a.provider).localeCompare(this.providerName(b.provider)));
},
supportsVerifiedRate(){return ['people.email.find','people.phone.find'].includes(this.taskId);},
vendorListingPrompt(){return 'Read https://treg.to/vendor-listing.md and add our API to the treg catalog, then open a PR.';},
verifiedRateLabel(){return this.taskId==='people.email.find'?'Email validity rate':'Verified hit rate';},
verifiedExplanation(){return this.taskId==='people.phone.find'?'Phone format checks do not establish reachability or ownership. Verified hit rate is not available yet.':'Percentage of sampled returned emails with completed checks that both verifiers marked valid. Risky, unknown and conflicting verdicts do not count as valid.';},
verificationWindow(){const v=this.insights?.verification;if(!v)return 'No published pilot';const date=x=>new Date(x).toLocaleDateString('en-US',{timeZone:'UTC'});return 'Sample '+date(v.sample_since)+'–'+date(v.sample_until)+' · Checked '+date(v.checked_at)+' · UTC';},
insightVerification(){return ['people.email.verify','people.phone.verify'].includes(this.taskId);},
insightWindow(){if(!this.insights)return '';const date=s=>new Intl.DateTimeFormat('en-US',{month:'short',day:'numeric',year:'numeric',timeZone:'UTC'}).format(new Date(s));const observed=this.insights.observed_since&&this.insights.observed_until;return (observed?'Recorded ':'Window ')+date(observed?this.insights.observed_since:this.insights.since)+' – '+date(observed?this.insights.observed_until:this.insights.until)+' · UTC';},
insightReviewedOn(){return this.insights?.updated_at?new Intl.DateTimeFormat('en-US',{dateStyle:'medium',timeStyle:'short',timeZone:'UTC'}).format(new Date(this.insights.updated_at))+' UTC':'';},
availableProviders(){
const catalog=this.currentTask.provider_previews?.[this.variant]||[],quoted=this.readyQuote?.providers||[];
return [...quoted,...catalog.filter(p=>!quoted.some(q=>q.provider===p.provider))];
},
enabledProviders(){return this.customServices?this.services:this.availableProviders.map(p=>p.provider);},
resultFighters(){if(this.isBatch)return this.batchVendors;return this.showProviderPreview||this.running?this.run.results:[...this.run.results,...this.availableProviders.filter(p=>!this.run.results.some(r=>r.provider===p.provider))];},
previewProviders(){if(this.leaderboard)return this.insightRows;const rows=this.readyQuote?.providers||(this.currentTask.provider_previews?.[this.variant]||[]).map(p=>({...p,estimate_micro:p.estimate_micro==null?null:p.estimate_micro*this.inputRows.length}));const selected=this.customServices?rows.filter(p=>this.services.includes(p.provider)):rows;return selected.map(p=>({...p,sample:this.insightRows.find(r=>r.endpoint_id===p.endpoint_id)?.sample,audit:this.insightRows.find(r=>r.endpoint_id===p.endpoint_id)?.audit}));},
chartViews(){return [{id:'rate',label:this.insightVerification?'Verdict rate':'Hit rate'},...(this.supportsVerifiedRate?[{id:'verified',label:this.verifiedRateLabel}]:[]),{id:'speed',label:'Response time'},{id:'price',label:'Price'},{id:'value',label:this.insightVerification?'Price vs verdict rate':'Price vs hit rate'}];},
chartRows(){
const colors=['#5b8f88','#7b8fae','#b39a69','#9787a6','#8b9c76','#b98476'];
return this.previewProviders.map(p=>({...p,
color:colors[Math.max(0,this.insightRows.findIndex(r=>r.endpoint_id===p.endpoint_id))%colors.length],
verifiedRate:this.verifiedRateValue(p),
rate:p.sample?.unique_requests>=20&&Number.isFinite(p.sample.unique_rate)&&p.sample.unique_rate>=0&&p.sample.unique_rate<=100?p.sample.unique_rate:null,
ms:p.sample?.timed_hits>=20&&Number.isFinite(p.sample.median_hit_ms)&&p.sample.median_hit_ms>=0?p.sample.median_hit_ms:null
}));
},
chartBars(){const field=this.statsView==='verified'?'verifiedRate':this.statsView==='price'?'estimate_micro':this.statsView==='speed'?'ms':'rate';return this.chartRows.filter(p=>Number.isFinite(p[field])&&p[field]>=0).map(p=>({...p,value:p[field]})).sort((a,b)=>(['rate','verified'].includes(this.statsView)?b.value-a.value:a.value-b.value)||this.providerName(a.provider).localeCompare(this.providerName(b.provider)));},
chartMax(){if(this.statsView==='price')return Math.max(1,...this.chartBars.map(p=>p.value));return this.statsView==='speed'?this.chartCeiling(Math.max(0,...this.chartRows.map(p=>p.ms||0))):100;},
chartTimeMax(){return Math.max(1,...this.chartRows.map(p=>p.ms||0));},
chartPoints(){return this.chartRows.filter(p=>p.rate!==null&&Number.isFinite(p.estimate_micro)&&p.estimate_micro>=0);},
chartPriceMax(){return this.chartCeiling(Math.max(0,...this.chartPoints.map(p=>p.estimate_micro)));},
chartDetail(){const rows=this.statsView==='table'?this.chartRows:this.statsView==='value'?this.chartPoints:this.chartBars;return rows.find(p=>p.endpoint_id===this.chartFocus)||null;},
showProviderPreview(){if(!this.run)return true;if(this.running)return false;const canonical=rows=>JSON.stringify(rows.map(r=>Object.entries(r).sort()));return this.run.capability!==this.taskId||this.run.mode!==this.mode||canonical(this.identities())!==canonical(this.runEntries)||this.customServices&&this.services.slice().sort().join()!==[...new Set(this.run.results.map(r=>r.provider))].sort().join();},
running(){return this.run?.state==='running';},
upvotedResults(){return (this.run?.results||[]).filter(r=>this.ratingValue(r)==='up');},
winnerIds(){if(this.isBatch)return Object.keys(this.batchAwards).filter(id=>this.batchAwards[id].includes('Coverage'));if(this.run?.state!=='completed')return [];const upvoted=this.upvotedResults;return upvoted.length===1?[upvoted[0].id]:upvoted.length>1?Object.keys(this.battleAwards):[];},
battleAwards(){
if(this.isBatch)return this.batchAwards;
const awards={};
if(!this.run||this.run.state!=='completed')return awards;
const upvoted=this.upvotedResults;
if(!upvoted.length&&this.run.mode!=='compare')return awards;
const results=upvoted.length>1?upvoted:this.run.results.filter(r=>r.state==='hit'&&this.ratingValue(r)!=='down');
if(results.length<2)return awards;
for(const [field,label] of [['duration_ms','Fastest'],['charged_micro','Cheapest']]){
// Unknown charges/timings cannot establish a winner; genuine zeros can.
if(!results.every(r=>Number.isFinite(r[field])&&r[field]>=0))continue;
const best=Math.min(...results.map(r=>r[field]));
for(const r of results)if(r[field]===best)(awards[r.id] ||= []).push(label);
}
return awards;
},
primaryFields(){if(['people.search','people.company.search','companies.similar'].includes(this.run?.capability))return ['count'];const preferred={'people.email.find':['email','verified'],'people.enrich':['name','full_name','title','company'],'companies.enrich':['name','domain','industry'],'people.phone.find':['phone','line_type'],'people.email.verify':['valid','status'],'people.phone.verify':['valid','line_type','country_code'],'people.identity.resolve':['linkedin_url','full_name']};const fields=this.run?.fields||[];const core=fields.filter(k=>(preferred[this.run?.capability]||[]).includes(k));return core.length?core:fields.slice(0,3);},
timelineEnd(){return Math.max(1,...(this.run?.results||[]).map(r=>(r.started_ms||0)+(r.duration_ms||0)));}
},
methods:{
async loadBenchmark(){
this.benchLoading=true;this.benchError='';
try{
const response=await fetch('/people-search',{credentials:'omit'});
if(!response.ok)throw new Error('Benchmark source unavailable.');
this.benchCategories=window.ArenaBench.parseDocument(new DOMParser().parseFromString(await response.text(),'text/html'));
if(!this.benchCategories.some(c=>c.id===this.benchCategory))this.benchCategory=this.benchCategories.find(c=>c.id==='b2b-prospecting')?.id||this.benchCategories[0].id;
}catch(e){this.benchCategories=[];this.benchError='Could not load the published benchmark. Please try again or open the source.';}
finally{this.benchLoading=false;}
},
sectionUrl(leaderboard){return '/enrich-arena'+(leaderboard?'/leaderboard':'')+'?capability='+encodeURIComponent(this.taskId)+'&variant='+this.variant;},
runUrl(id,team=this.runTeam||this.team){return '/enrich-arena?run='+encodeURIComponent(id)+(team?'&team='+encodeURIComponent(team):'');},
syncUrl(runId='',replace=false){
if(!this.booted||this.benchmark)return;
const url=runId?this.runUrl(runId):this.sectionUrl(this.leaderboard);
if((location.pathname||'/enrich-arena')+(location.search||'')!==url)window.history?.[replace?'replaceState':'pushState'](null,'',url);
},
async restoreLinkedRun(){
const params=new URLSearchParams(location.search),id=params.get('run');
if(!id||this.leaderboard||this.benchmark)return false;
this.linkedRunPending=true;
if(!this.user){await this.openLogin(false);return true;}
const team=params.get('team');
if(team&&!this.teams.some(t=>t.slug===team)){this.error='This saved run is not available to your account or team.';return true;}
if(team)this.team=team;
if(!this.team){this.error='This saved run is not available to your account or team.';return true;}
await this.loadHistory(id,{replace:true});this.linkedRunPending=false;
await this.loadBalance();await this.refreshHistory();return true;
},
async loadInsights(){
if(this.insightsState==='loading')return;
this.insightsState='loading';
try{const data=await this.api('/arena/insights',{},'');if(data.version!==2||!Array.isArray(data.rows)||!['ready','warming'].includes(data.status)||!Number.isFinite(Date.parse(data.since))||!Number.isFinite(Date.parse(data.until))||data.updated_at!==null&&!Number.isFinite(Date.parse(data.updated_at)))throw new Error('Invalid stats response');this.insights=data;this.insightsState='ready';}
catch{this.insightsState='error';}
},
insightCount(n){return new Intl.NumberFormat('en-US').format(n);},
providerLabelParts(provider){return this.providerName(provider).split(/(?<=[a-z])(?=[A-Z])/);},
chartCeiling(value){if(value<=0)return 1;const power=10**Math.floor(Math.log10(value)),step=[1,2,2.5,5,10].find(n=>n*power>=value);return step*power;},
chartTick(value){return this.statsView==='price'?this.usd(value):this.statsView==='speed'?this.seconds(Math.round(value)):value+'%';},
chartLabel(p){if(this.statsView==='verified')return this.providerName(p.provider)+': '+this.verifiedRateLabel.toLowerCase()+' '+this.verifiedRate(p)+'. '+this.verifiedRateNote(p);return this.providerName(p.provider)+': '+(this.insightVerification?'verdict rate ':'hit rate ')+this.insightRate(p)+', response time '+this.insightResponseTime(p)+', estimated price '+(p.estimate_micro==null?'unavailable':this.usd(p.estimate_micro));},
showMetricTooltip(event){const r=event.currentTarget.getBoundingClientRect(),width=Math.min(300,window.innerWidth-24);this.metricTooltip={width,left:Math.max(12,Math.min(r.left+(r.width-width)/2,window.innerWidth-width-12)),top:Math.max(12,Math.min(r.bottom+8,window.innerHeight-140))};},
verifiedRateValue(row){
const a=row.audit;
return this.taskId==='people.email.find'&&a?.method==='email_verifier_consensus'&&a.checked_n>=20&&Number.isFinite(a.validity_rate)&&a.validity_rate>=0&&a.validity_rate<=100?a.validity_rate:null;
},
verifiedRate(row){const rate=this.verifiedRateValue(row);return rate===null?'—':rate.toFixed(1)+'%';},
verifiedRateNote(row){
if(this.taskId==='people.phone.find')return 'Format checked only; reachability and ownership are not verified.';
const a=row.audit;
if(!a)return 'No published verification sample for this endpoint and input.';
if(!(a.checked_n>=20))return 'Insufficient completed verification sample.';
const v=this.insights?.verification,date=x=>x?new Date(x).toLocaleDateString('en-US',{timeZone:'UTC'}):'';
return 'Share of sampled returned emails marked valid by '+a.verifiers.map(p=>this.providerName(p)).join(' + ')+'. Checks completed '+date(v?.checked_at)+'. '+'Risky, unknown and conflicting verdicts are not counted as valid; unfinished checks are excluded. Verifier agreement does not confirm delivery or ownership.';
},
insightRate(row){
const s=row.sample;
if(!s||s.unique_requests<20)return '—';
return Number.isFinite(s.unique_rate)?s.unique_rate.toFixed(1)+'%':'—';
},
insightSampleLabel(row){
const s=row.sample;
if(!s?.unique_requests)return this.insights?.status==='warming'?'Processing…':this.insightsState==='ready'?'':this.insightsState==='error'?'Unavailable':'Loading…';
return '';
},
insightResponseTime(row){
const s=row.sample;
return s&&s.timed_hits>=20&&Number.isFinite(s.median_hit_ms)&&s.median_hit_ms>=0?this.seconds(Math.round(s.median_hit_ms)):'—';
},
awardTitle(badge){if(this.isBatch)return ({Coverage:'Most found results after thumbs-down rejections; not verified accuracy.',Cheapest:'Total charges, including misses, divided by results kept.',Fastest:'Lowest median response time of results kept; excludes queue time.'})[badge];if(badge==='Winner')return 'Your thumbs-up winner';const cohort=this.upvotedResults.length>1?'thumbs-up results':'successful, non-downvoted results';return (badge==='Fastest'?'Lowest response time':'Lowest actual charge')+' among '+cohort;},
entryLabel(identity){return Object.values(identity).join(' · ');},
entryResult(entry,provider){return entry.results.find(r=>r.provider===provider);},
hasAttempt(result){return !['not_attempted','skipped','queued'].includes(result.state);},
toggleEntryRow(event,index){if(event.target.closest('button,a,input,select,textarea,summary'))return;this.selectEntry(index);},
selectEntry(index,result){const collapse=this.selectedEntry===index&&(!result||this.expandedResults.includes(result.id));this.selectedVendor='';this.selectedEntry=collapse?null:index;this.reporting='';this.expandedResults=!collapse&&result?[result.id]:[];},
selectVendor(v){if(!v.attempted)return;this.selectedVendor=this.selectedVendor===v.provider?'':v.provider;this.selectedEntry=null;this.reporting='';this.expandedResults=[];},
toggleVendorRow(event,v){if(event.target.closest('button,a,input,select,textarea,summary'))return;this.selectVendor(v);},
addEntry(){if(this.busy||this.running||this.inputRows.length>=this.maxEntries)return;this.extraInputs.push({});this.showAllEntries=true;this.quote=null;},
removeEntry(index){if(this.busy||this.running||this.inputRows.length===1)return;const rows=this.inputRows;rows.splice(index,1);this.inputs=rows[0];this.extraInputs=rows.slice(1);this.quote=null;},
pasteEntries(event,index){
if(this.busy||this.running)return;
const text=event.clipboardData?.getData('text/plain')||'';
if(!/[\n\r\t]/.test(text.trim()))return;
event.preventDefault();this.error='';
try{
let rows=pasteRows(text,this.inputKeys.length);
const aliases={q:['q','query','description','searchdescription'],title:['title','jobtitle','role'],country:['country','countrycode'],company_domain:['companydomain','domain','companywebsite'],full_name:['fullname','name','personname'],domain:['domain','companydomain','companywebsite'],email:['email','emailaddress'],linkedin_url:['linkedin','linkedinurl','linkedinprofile'],name:['name','company','companyname']};
const header=rows[0]?.map(v=>v.toLowerCase().replace(/[^a-z]/g,''))||[];
const columns=this.inputKeys.map(k=>header.findIndex(h=>(aliases[k]||[k]).includes(h)));
const hasHeader=columns.every(i=>i>=0);
if(hasHeader)rows=rows.slice(1);
if(!rows.length)return;
if(rows.some(r=>r.length!==this.inputKeys.length)&&!hasHeader)throw new Error('Paste '+this.inputKeys.length+' columns: '+this.inputKeys.map(k=>this.fieldLabel(k)).join(' + ')+'.');
const entries=rows.map(r=>Object.fromEntries(this.inputKeys.map((k,i)=>[k,r[hasHeader?columns[i]:i]||''])));
if(this.inputRows.length-1+entries.length>this.maxEntries)throw new Error('Use at most '+this.maxEntries+' entries. Nothing was pasted.');
const all=this.inputRows;all.splice(index,1,...entries);this.inputs=all[0];this.extraInputs=all.slice(1);this.showAllEntries=false;this.quote=null;this.error=this.inputError();this.saveDraft(false);
}catch(e){this.error=e.message;}
},
usd(n){return n==null?'—':new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',minimumFractionDigits:2,maximumFractionDigits:6}).format(n/1e6);},
billingLabel(p){if(this.discovery&&p.price_type==='per_result'&&['platform','catalog'].includes(p.tier))return 'Estimate for up to '+this.currentTask.result_limit+' matches';if(p.tier&&p.tier!=='platform'&&p.tier!=='catalog')return 'Your key · no treg charge';return ({per_success:'Per successful request',per_result:'Per result',per_call:'Per request'})[p.price_type]||'Per lookup';},
seconds(ms){return ms<1000?ms+' ms':(ms/1000).toFixed(1)+' s';},
providerName(p){return NAMES[p]||p;},fieldLabel(k){return LABELS[k]||k.replaceAll('_',' ');},
variantLabel(v){return v.map(k=>({q:'Search description',title:'Job title',country:'Country code',company_domain:'Company domain',full_name:'Name',domain:'company domain',linkedin_url:'LinkedIn URL',email:'Email',phone:'Phone number',name:'Company name'})[k]||k).join(' + ');},
taskLabel(id){return this.tasks.find(t=>t.id===id)?.label||id;},
placeholder(k){return ({phone:'+14155550100',q:'e.g. Heads of growth at B2B software companies in Chicago',title:'e.g. Head of growth',country:'e.g. US',company_domain:'e.g. acme.com',full_name:'First and last name',domain:'company.com',linkedin_url:this.taskId==='companies.enrich'?'https://linkedin.com/company/…':'https://linkedin.com/in/…',email:'name@company.com',name:'Company name'})[k]||k;},
domainMismatch(r){const identity=this.runEntries[r.entry_index||0];if(this.run?.capability!=='people.email.find'||!identity.domain||typeof r.output.email!=='string')return '';const actual=r.output.email.split('@')[1]?.toLowerCase();const expected=identity.domain.toLowerCase();return actual&&actual!==expected&&!actual.endsWith('.'+expected)?'Email domain is '+actual+'; you searched '+expected+'.':'';},
identityWarning(r){
if(r.state!=='hit'||!['people.enrich','people.identity.resolve'].includes(this.run?.capability))return '';
const name=v=>typeof v==='string'?v.normalize('NFKD').replace(/\p{M}/gu,'').toLowerCase().replace(/[^\p{L}\p{N}]+/gu,' ').trim():'';
const profile=v=>typeof v==='string'?v.toLowerCase().replace(/^https?:\/\//,'').replace(/^www\./,'').replace(/\/$/,''):'';
const entry=r.entry_index||0,identity=this.runEntries[entry]||{},out=r.output||{};
if(identity.full_name&&out.full_name&&name(identity.full_name)!==name(out.full_name))return 'Returned name differs from your query. Review the original response.';
if(identity.linkedin_url&&out.linkedin_url&&profile(identity.linkedin_url)!==profile(out.linkedin_url))return 'Returned LinkedIn profile differs from your query.';
const peers=this.run.results.filter(p=>p.state==='hit'&&(p.entry_index||0)===entry);
if(['full_name','linkedin_url'].some(key=>new Set(peers.map(p=>(key==='full_name'?name:profile)(p.output?.[key])).filter(Boolean)).size>1))return 'Vendors returned different identities for this entry. Review names and profiles.';
return '';
},
summaryFields(r){if(r.state!=='hit')return [];return this.primaryFields.filter(k=>!(k==='verified'&&r.verification)&&!(k==='valid'&&this.run?.capability==='people.email.verify'&&r.output.status)&&r.output[k]!==null&&r.output[k]!==undefined&&r.output[k]!=='');},
toggleResultRow(event,id){if(event.target.closest('button,a,input,select,textarea,summary'))return;this.toggleDetails(id);},
toggleDetails(id){this.expandedResults=this.expandedResults.includes(id)?this.expandedResults.filter(x=>x!==id):[...this.expandedResults,id];},
searchMatches(r){return Array.isArray(r.output?.people)?r.output.people:Array.isArray(r.output?.companies)?r.output.companies:[];},
discoveryRun(){return ['people.search','people.company.search','companies.similar'].includes(this.run?.capability);},
displayValue(v){if(Array.isArray(v))return v.length+' matches';return v===null||v===undefined||v===''?'Not returned':v===true?'Yes':v===false?'No':String(v);},
emailVerdict(output={}){
const status=String(output.status||'').trim().toLowerCase().replace(/[\s-]+/g,'_');
if(['accept_all','catch_all','catchall','risky'].includes(status))return {label:'Risky',tone:'risky'};
if(['valid','ok','deliverable'].includes(status))return {label:'Valid',tone:'valid'};
if(['invalid','undeliverable','disposable'].includes(status))return {label:'Invalid',tone:'invalid'};
if(status)return {label:'Unknown',tone:'unknown'};
return output.valid===true?{label:'Valid',tone:'valid'}:output.valid===false?{label:'Invalid',tone:'invalid'}:{label:'Unknown',tone:'unknown'};
},
outcomeClass(r){return r.state==='hit'&&this.run?.capability==='people.email.verify'?'verdict-'+this.emailVerdict(r.output).tone:r.state;},
verificationClass(r){const v=r.verification;return v?.state==='hit'&&v.capability!=='people.phone.verify'?'verdict-'+this.emailVerdict(v.output).tone:'';},
outcomeLabel(r){if(r.state==='hit')return this.run?.capability==='people.email.verify'?'Verdict: '+this.emailVerdict(r.output).label:this.run?.capability==='people.phone.verify'?'Verdict returned':'Found';return ({miss:'No match',error:'Error',timeout:'Timed out',not_attempted:'Not attempted',skipped:'Skipped',running:'Running',queued:'Queued',interrupted:'Interrupted',cancelled:'Cancelled'})[r.state]||r.state;},
emptyLabel(r){return ({miss:'No matching data returned.',error:r.upstream_status===402?'This vendor’s credits or lookup allowance are exhausted. Try another vendor.':'This service could not complete the lookup.',timeout:'This service did not finish within the deadline.',not_attempted:'',skipped:'This step was skipped.',queued:'Waiting for its turn.',running:'Looking for an answer…',cancelled:'The attempt was stopped.',interrupted:'No complete result was recorded.'})[r.state]??'No fields returned.';},
timeLeft(r){return (r.started_ms||0)/this.timelineEnd*100;},timeWidth(r){return Math.max(.5,(r.duration_ms||0)/this.timelineEnd*100);},
openVendorRequest(){
if(this.requestBusy)return;
this.requestDone=false;this.requestError='';this.vendorPromptCopied=false;this.vendorPromptError='';
this.requestQuery='Enrich Arena: '+this.taskId;
this.$refs.vendorRequestDialog.showModal();
},
async copyVendorListingPrompt(){
this.vendorPromptCopied=false;this.vendorPromptError='';
try{await navigator.clipboard.writeText(this.vendorListingPrompt);this.vendorPromptCopied=true;}
catch{this.vendorPromptError='Could not copy automatically. Select and copy the prompt above.';}
},
closeVendorRequest(){if(!this.requestBusy)this.$refs.vendorRequestDialog.close();},
async submitVendorRequest(){
if(this.requestBusy)return;
const capability=this.requestForm.capability.trim();
if(!capability){this.requestError='Say what tool or capability you need.';return;}
this.requestBusy=true;this.requestError='';
try{
await this.api('/tool-requests',{method:'POST',body:JSON.stringify({capability,query:this.requestQuery,note:this.requestForm.note,contact:this.user?'':this.requestForm.contact,source:'web'})},null);
this.requestDone=true;this.requestForm={capability:'',note:'',contact:''};
}catch(e){this.requestError=e.status===429?'Too many requests from here — try again later.':'Could not send: '+(e.message||'Please try again.');}
finally{this.requestBusy=false;}
},
async api(path,options={},teamOverride){
const headers={'Content-Type':'application/json',...(options.headers||{})};
const active=teamOverride===undefined?this.team:teamOverride;
if(active)headers['X-Treg-Org']=active;
const response=await fetch(path,{credentials:'same-origin',...options,headers});
let body;try{body=await response.json();}catch{body={detail:'The server returned an unreadable response.'};}
if(!response.ok){const d=body.detail;const e=new Error(typeof d==='string'?d:d?.message||body.message||'The request could not be completed.');e.status=response.status;throw e;}
return body;
},
identity(){return Object.fromEntries(this.inputKeys.map(k=>[k,(this.inputs[k]||'').trim()]));},
identities(){return this.inputRows.map(row=>Object.fromEntries(this.inputKeys.map(k=>[k,(row[k]||'').trim()])));},
saveDraft(pending=false){if(this.leaderboard||this.benchmark)return;write(DRAFT,{taskId:this.taskId,variant:this.variant,inputs:this.inputs,extraInputs:this.extraInputs,mode:this.mode,autoVerify:this.autoVerify,customServices:this.customServices,services:this.services,pending,at:Date.now()});},
restoreDraft(){const d=read(DRAFT);if(!d||Date.now()-d.at>600000){remove(DRAFT);return false;}if(!this.tasks.some(t=>t.id===d.taskId))return false;this.draftRestored=true;this.taskId=d.taskId;this.autoVerify=typeof d.autoVerify==='boolean'?d.autoVerify:['people.email.find','people.phone.find'].includes(d.taskId);this.variant=d.variant;this.inputs=d.inputs||{};this.extraInputs=Array.isArray(d.extraInputs)?d.extraInputs.slice(0,49):[];this.mode=d.mode==='compare'?'compare':'waterfall';this.customServices=!!d.customServices;this.services=d.services||[];return !!d.pending;},
fillExampleInputs(){
const rows=this.leaderboard||this.benchmark?[]:(this.currentTask.examples?.[this.variant]||[]);
this.inputs={...(rows[0]||{})};this.extraInputs=rows.slice(1,2).map(row=>({...row}));
},
resetTaskInputs(){
this.fillExampleInputs();this.quote=null;this.customServices=false;this.services=[];
if(this.booted){this.run=null;this.linkedRunPending=false;clearTimeout(this.pollTimer);if(!this.leaderboard)remove(ACTIVE);this.saveDraft(false);this.syncUrl();}
},
chooseTask(id){
if(this.busy||this.running||!this.tasks.some(t=>t.id===id))return;
this.taskId=id;if(this.statsView==='verified'&&!['people.email.find','people.phone.find'].includes(id))this.statsView='rate';
this.autoVerify=['people.email.find','people.phone.find'].includes(id);this.chartFocus=null;this.variant=0;this.resetTaskInputs();
},
chooseVariant(i){
if(this.busy||this.running||!Number.isInteger(i)||i<0||i>=this.currentTask.variants.length)return;
this.variant=i;this.resetTaskInputs();
},setMode(m){if(this.busy||this.running||this.mode===m)return;this.mode=m;this.quote=null;},
toggleProvider(provider){
if(this.busy||this.running||!this.availableProviders.some(p=>p.provider===provider))return;
const selected=this.enabledProviders;
this.services=selected.includes(provider)?selected.filter(p=>p!==provider):[...selected,provider];
this.customServices=true;this.quote=null;this.error='';this.saveDraft(false);
},
track(event,props={}){window.TregTracking?.capture(event,props);},
identify(){window.TregTracking?.identify(this.user?.email||'',this.team);},
async loadIdentity(){
try{this.user=await this.api('/auth/me',{},'');}catch(e){if(e.status!==401)throw e;this.user=null;this.teams=[];this.team='';this.balance=null;this.history=[];this.historySequence++;this.identify();return;}
this.identify();
const orgs=await this.api('/orgs');this.teams=orgs.filter(t=>!t.demo);
let saved='';try{saved=localStorage.getItem('treg.arena.team')||'';}catch{}
this.team=this.teams.find(t=>t.slug===this.team)?.slug||this.teams.find(t=>t.slug===saved)?.slug||this.teams[0]?.slug||'';
this.identify();
if(this.team){await this.loadBalance();if(!this.leaderboard&&!this.benchmark)await this.refreshHistory();}
},
async loadBalance(){const t=this.teams.find(t=>t.slug===this.team);if(!t)return;const b=await this.api('/orgs/'+t.org_id+'/balance?limit=1');this.balance=b.balance_micro;},
async changeTeam(){this.quote=null;this.run=null;this.history=[];this.historySequence++;clearTimeout(this.pollTimer);remove(ACTIVE);this.linkedRunPending=false;this.syncUrl();try{localStorage.setItem('treg.arena.team',this.team);this.identify();await this.loadBalance();await this.refreshHistory();}catch(e){this.error=e.message;}},
setupIcon(icon){return TregAgentSetup.iconUrl(icon);},
async openSetup(){
this.setupStep=this.user&&!this.team?0:1;this.setupTeamName='';this.setupToken=null;this.setupShowToken=false;this.setupCopied=false;this.setupError='';this.setupLoading=false;this.setupSequence++;
try{const saved=localStorage.getItem('treg-agent');if([...TregAgentSetup.agents,...TregAgentSetup.moreAgents].some(a=>a.id===saved))this.setupAgentId=saved;}catch{}
await this.$nextTick();this.$refs.setupDialog.showModal();
},
closeSetup(){if(this.setupStep===0&&this.setupLoading)return;this.setupSequence++;this.setupToken=null;this.setupShowToken=false;this.setupCopied=false;this.setupLoading=false;this.$refs.setupDialog.close();},
async createSetupTeam(){
if(this.setupLoading||!this.user)return;
const name=this.setupTeamName.trim();
if(!name){this.setupError='Give your team a name.';return;}
this.setupLoading=true;this.setupError='';
try{
const created=await this.api('/orgs',{method:'POST',body:JSON.stringify({name})},'');
this.team=created.org;this.setupStep=1;
await this.loadIdentity();
}catch(e){this.setupError=e.message;}
finally{this.setupLoading=false;}
},
async prepareSetup(){
if(this.setupLoading)return;
if(this.user&&!this.team){this.setupStep=0;return;}
const sequence=++this.setupSequence,team=this.team,user=this.user;
this.setupLoading=true;this.setupError='';this.setupToken=null;this.setupShowToken=false;this.setupCopied=false;
try{
try{localStorage.setItem('treg-agent',this.setupAgentId);}catch{}
if(user&&team){
const result=await this.api('/auth/cli-token',{},team);
if(sequence!==this.setupSequence||team!==this.team||user!==this.user)return;
if(!result.token)throw new Error('Could not load your setup key. Please try again.');
this.setupToken=result.token;
}
if(sequence===this.setupSequence)this.setupStep=2;
}catch(e){if(sequence===this.setupSequence)this.setupError=e.message;}
finally{if(sequence===this.setupSequence)this.setupLoading=false;}
},
showSetupExamples(){this.setupStep=3;this.setupShowToken=false;this.setupExampleCopied='';this.setupError='';},
openSetupCatalog(service){this.closeSetup();location.assign(service?'/app/marketplace/'+encodeURIComponent(service):'/app#connections');},
async copySetup(text,exampleKey=''){
const sequence=this.setupSequence;this.setupError='';
try{await navigator.clipboard.writeText(text);if(sequence!==this.setupSequence)return;this.setupCopied=!exampleKey;this.setupExampleCopied=exampleKey;setTimeout(()=>{if(sequence===this.setupSequence){this.setupCopied=false;this.setupExampleCopied='';}},1400);}
catch{if(sequence===this.setupSequence)this.setupError='Could not copy. Select the setup text and copy it manually.';}
},
async openLogin(pending){this.track('arena_signup_opened');this.pendingSubmit=pending;this.authError='';this.emailStep='email';this.code='';this.devCode='';this.saveDraft(pending);await this.$nextTick();this.$refs.loginDialog.showModal();},
closeLogin(){remove(SIGNUP_SETUP);this.pendingSubmit=false;this.saveDraft(false);this.$refs.loginDialog.close();},
socialLogin(provider){this.saveDraft(this.pendingSubmit);write(SIGNUP_SETUP,{at:Date.now()});const target=(location.pathname||'/enrich-arena')+(location.search||'');location.assign('/auth/'+provider+'?return_to='+encodeURIComponent(target));},
async finishSignup(){remove(SIGNUP_SETUP);this.pendingSubmit=false;this.saveDraft(false);await this.openSetup();},
async resumeSignupSetup(){
const pending=read(SIGNUP_SETUP);
if(!pending)return false;
if(!Number.isFinite(pending.at)||Date.now()-pending.at>600000||pending.at>Date.now()){remove(SIGNUP_SETUP);return false;}
if(!this.user)return false;
await this.finishSignup();return true;
},
async sendCode(){this.authBusy=true;this.authError='';try{const r=await this.api('/auth/email/start',{method:'POST',body:JSON.stringify({email:this.email})},'');this.emailStep='code';this.devCode=r.dev_code||'';}catch(e){this.authError=e.message;}finally{this.authBusy=false;}},
async verifyEmail(){this.authBusy=true;this.authError='';try{await this.api('/auth/email/verify',{method:'POST',body:JSON.stringify({email:this.email,code:this.code})},'');this.$refs.loginDialog.close();await this.loadIdentity();await this.restoreLinkedRun();await this.finishSignup();}catch(e){this.authError=e.message;this.error=e.message;}finally{this.authBusy=false;}},
async createTeam(){this.authBusy=true;this.authError='';try{await this.api('/orgs',{method:'POST',body:JSON.stringify({name:this.newTeamName})},'');this.$refs.teamDialog.close();await this.loadIdentity();await this.prepare();}catch(e){this.authError=e.message;}finally{this.authBusy=false;}},
inputError(){
if(this.inputRows.length>this.maxEntries)return 'Use at most '+this.maxEntries+' entries for this task.';
const seen=new Set();
for(const [i,row] of this.inputRows.entries()){
const prefix=this.inputRows.length>1?'Entry '+(i+1)+': ':'';
if(!this.inputKeys.length||this.inputKeys.some(k=>!(row[k]||'').trim()))return prefix+'Complete the query fields.';
if(this.inputKeys.includes('country')&&!/^[A-Za-z]{2}$/.test(row.country.trim()))return prefix+'Use a two-letter country code, such as US or GB.';
if(this.inputKeys.includes('full_name')){const parts=row.full_name.trim().split(/\s+/);if(parts.length<2||![parts[0],parts.at(-1)].every(p=>/\p{L}/u.test(p)))return prefix+'Enter both a first and last name for a name-based comparison, or use a LinkedIn URL.';}
const fingerprint=JSON.stringify(this.inputKeys.map(k=>row[k].trim().toLowerCase()));
if(seen.has(fingerprint))return prefix+'Duplicate entry. Remove it before running.';
seen.add(fingerprint);
}
if(this.customServices&&!this.services.length)return 'Enable at least one vendor by clicking its avatar.';
return '';
},
scheduleQuote(){
clearTimeout(this.quoteTimer);this.quoteSequence++;this.quote=null;this.pricedKey='';this.pricing=false;
if(this.leaderboard||this.benchmark||this.run&&!this.showProviderPreview||!this.booted||!this.user||!this.team||this.running||this.inputError())return;
this.quoteTimer=setTimeout(()=>this.prepare(true),800);
},
async submit(){
if(this.busy||this.running||this.pricing)return;
this.error=this.inputError();if(this.error)return;
this.saveDraft(false);
if(!this.user){await this.openLogin(true);return;}
if(!this.team){this.$refs.teamDialog.showModal();return;}
if(!this.readyQuote){await this.prepare();return;}
if(!this.readyQuote.affordable){this.topUp();return;}
if(Date.parse(this.readyQuote.expires_at)<=Date.now()){await this.prepare();return;}
await this.startRun();
},
async prepare(quiet=false){
if(this.leaderboard||this.benchmark)return;
clearTimeout(this.quoteTimer);
if(!this.user||!this.team||this.running)return;
const problem=this.inputError();if(problem){if(!quiet)this.error=problem;return;}
const key=this.quoteKey,sequence=++this.quoteSequence,team=this.team;
this.pricing=true;this.quote=null;this.error='';this.saveDraft(false);
try{
const q=await this.api('/arena/plans',{method:'POST',body:JSON.stringify({capability:this.taskId,...(this.extraInputs.length?{identities:this.identities()}:{identity:this.identity()}),mode:this.mode,auto_verify:this.autoVerify&&!!this.verificationTask,providers:this.customServices?this.services:null,max_cost_micro:10_000_000})},team);
if(sequence!==this.quoteSequence||key!==this.quoteKey)return;
this.quote=q;this.pricedKey=key;this.balance=q.balance_micro;
}catch(e){if(sequence!==this.quoteSequence||key!==this.quoteKey)return;if(e.status===401){this.user=null;this.quote=null;if(!quiet)await this.openLogin(true);}else this.error=e.message;}
finally{if(sequence===this.quoteSequence)this.pricing=false;}
},
topUp(){this.saveDraft(false);try{localStorage.setItem('treg-active',this.team);}catch{}this.track('arena_topup_clicked');location.assign('/app?from=enrich-arena#billing');},
async startRun(){
if(this.busy||this.running||!this.readyQuote)return;
const id=this.readyQuote.id;this.busy=true;this.error='';this.selectedEntry=null;this.resultFilter='all';this.expandedResults=[];this.runTeam=this.team;clearTimeout(this.quoteTimer);
try{await this.api('/arena/runs/'+id+'/start',{method:'POST'},this.runTeam);this.quote=null;await this.pollRun(id);this.syncUrl(id);if(this.running)await this.refreshHistory();}
catch(e){if(e.status===401){this.user=null;this.quote=null;await this.openLogin(true);}else if(e.status===402){this.topUp();}else{this.error=e.message;if(e.status===409){this.quote=null;await this.prepare(true);}}}
finally{this.busy=false;}
},
async pollRun(id){
clearTimeout(this.pollTimer);
try{this.run=await this.api('/arena/runs/'+id,{},this.runTeam);this.pollFailures=0;
if(this.run.state==='running')this.pollTimer=setTimeout(()=>this.pollRun(id),1500);
else{await this.loadBalance();await this.refreshHistory();}
}catch(e){this.error=e.message;this.pollFailures++;if(this.pollFailures<5&&e.status!==401&&e.status!==403&&e.status!==404)this.pollTimer=setTimeout(()=>this.pollRun(id),3000);}
},
verificationEstimate(task){const prices=(this.tasks.find(t=>t.id===task)?.provider_previews?.[0]||[]).filter(p=>Number.isFinite(p.estimate_micro));return prices.length?Math.min(...prices.map(p=>p.estimate_micro)):null;},
verificationLabel(r){const task=({'people.email.find':'people.email.verify','people.phone.find':'people.phone.verify'})[this.run?.capability],q=this.verificationQuotes[r.id],price=q?.estimate_micro??this.verificationEstimate(task);return (this.verificationPending[r.id]?'Verifying…':q?.affordable===false?'Top up':task==='people.phone.verify'?'Verify phone':'Verify email')+(price==null?'':' · '+this.usd(price));},
verificationVerdict(r){const v=r.verification;if(!v)return '';if(['queued','running'].includes(v.state))return 'Verifying…';if(v.state!=='hit')return 'Verification unavailable';const o=v.output||{};if(v.capability==='people.phone.verify')return o.valid===true?'Valid phone format':o.valid===false?'Invalid phone number':'Unknown';return this.emailVerdict(o).label;},
async verifyResult(r){
if(!r.can_verify||this.verificationPending[r.id])return;
const id=this.run.id,team=this.runTeam,task=({'people.email.find':'people.email.verify','people.phone.find':'people.phone.verify'})[this.run.capability];
this.verificationPending[r.id]=true;this.error='';
try{
let q=this.verificationQuotes[r.id];const displayed=q?.estimate_micro??this.verificationEstimate(task);
if(!q||Date.parse(q.expires_at)<=Date.now()){
q=await this.api('/arena/runs/'+id+'/attempts/'+r.id+'/verification/plan',{method:'POST'},team);
if(this.run?.id!==id||this.runTeam!==team)return;
this.verificationQuotes[r.id]=q;this.balance=q.balance_micro;
if(displayed==null||q.estimate_micro>displayed){this.error='Review the verification price and click Verify again.';return;}
}
if(!q.affordable){this.topUp();return;}
await this.api('/arena/runs/'+id+'/attempts/'+r.id+'/verification/start',{method:'POST',body:JSON.stringify({quote_id:q.id})},team);
if(this.run?.id!==id||this.runTeam!==team)return;
r.can_verify=false;this.run.state='running';delete this.verificationQuotes[r.id];await this.pollRun(id);
}catch(e){if(e.status===402)this.topUp();else this.error=e.message;}finally{delete this.verificationPending[r.id];}
},
manualPrice(r){const q=this.manualQuotes[r.id];return q?.required_micro??((q?.estimate_micro??r.estimate_micro)+(this.run?.auto_verify?.estimate_micro||0));},
manualUnaffordable(r){const q=this.manualQuotes[r.id],price=this.manualPrice(r);return price>0&&((Number.isFinite(this.balance)&&this.balance<price)||(q?.affordable===false));},
manualLabel(r){const q=this.manualQuotes[r.id];return (this.manualPending[r.id]?'Starting…':this.manualUnaffordable(r)?'Top up':'Try')+' · '+this.usd(this.manualPrice(r));},
ratingValue(r){return r.rating?.value || (r.report?'down':'');},
openReport(r){if(this.isBatch&&!this.selectedVendor)this.selectedEntry=r.entry_index;this.reporting=r.id;this.reportDrafts[r.id] ||= {reason:r.report?.reason||'',comment:r.report?.comment||''};},
async rateResult(r,value){
if(this.busy||this.running)return;
if(value==='down')this.openReport(r);else this.reporting='';
if(r.rating?.value===value)return;
const previous=r.rating;this.busy=true;this.error='';r.rating={value};
try{const saved=await this.api('/arena/runs/'+this.run.id+'/attempts/'+r.id+'/rating',{method:'POST',body:JSON.stringify({value})},this.runTeam);r.rating=saved.rating;}
catch(e){r.rating=previous;this.reporting='';this.error='Could not save your rating. '+e.message;}
finally{this.busy=false;}
},
async sendReport(r){
if(this.busy||this.running||r.report||this.ratingValue(r)!=='down')return;
this.busy=true;this.error='';
try{const saved=await this.api('/arena/runs/'+this.run.id+'/attempts/'+r.id+'/report',{method:'POST',body:JSON.stringify(this.reportDrafts[r.id])},this.runTeam);r.report=saved.report;this.reporting='';}
catch(e){this.error=e.message;}finally{this.busy=false;}
},
async tryVendor(r){
if(this.busy||this.manualPending[r.id]||!r.can_try)return;
if(this.manualUnaffordable(r)){this.topUp();return;}
const runId=this.run.id,team=this.runTeam;this.manualPending[r.id]=true;this.error='';
try{
let q=this.manualQuotes[r.id];const displayed=this.manualPrice(r);
if(!q||Date.parse(q.expires_at)<=Date.now()){
q=await this.api('/arena/runs/'+runId+'/attempts/'+r.id+'/plan',{method:'POST'},team);
if(this.run?.id!==runId||this.runTeam!==team)return;
this.manualQuotes[r.id]=q;this.balance=q.balance_micro;
if(this.manualPrice(r)>displayed){this.error='This service’s price changed. Review the updated price and click Try again.';return;}
}
if(!q.affordable){this.topUp();return;}
await this.api('/arena/runs/'+runId+'/attempts/'+r.id+'/start',{method:'POST',body:JSON.stringify({quote_id:q.id})},team);
if(this.run?.id!==runId||this.runTeam!==team)return;
r.can_try=false;r.state='queued';r.manual=true;this.run.state='running';
delete this.manualQuotes[r.id];this.quote=null;this.reporting='';
await this.pollRun(runId);await this.refreshHistory();
}catch(e){delete this.manualQuotes[r.id];if(e.status===402)this.topUp();else this.error=e.message;}
finally{delete this.manualPending[r.id];}
},
async cancelRun(){this.busy=true;try{await this.api('/arena/runs/'+this.run.id+'/cancel',{method:'POST'},this.runTeam);}catch(e){this.error=e.message;}finally{this.busy=false;}},
historyDate(value){return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value));},
async refreshHistory(){
const team=this.team,sequence=++this.historySequence;
if(!this.user||!team){this.history=[];this.historyHasMore=false;this.historyLoading=false;return;}
this.historyLoading=true;
try{const rows=await this.api('/arena/runs?limit=31',{},team);if(sequence===this.historySequence&&team===this.team&&this.user){this.history=rows.slice(0,30);this.historyHasMore=rows.length>30;}}
catch(e){if(sequence===this.historySequence&&team===this.team)this.error=e.message;}
finally{if(sequence===this.historySequence)this.historyLoading=false;}
},
async loadMoreHistory(){
if(this.historyLoading||!this.historyHasMore||!this.history.length||!this.user||!this.team||this.busy||this.running)return;
const team=this.team,sequence=++this.historySequence,before=this.history.at(-1).id;
this.historyLoading=true;
try{
const rows=await this.api('/arena/runs?limit=31&before='+encodeURIComponent(before),{},team);
if(sequence!==this.historySequence||team!==this.team||!this.user)return;
const seen=new Set(this.history.map(row=>row.id));
this.history.push(...rows.slice(0,30).filter(row=>!seen.has(row.id)));
this.historyHasMore=rows.length>30;
}catch(e){if(sequence===this.historySequence&&team===this.team)this.error=e.message;}
finally{if(sequence===this.historySequence)this.historyLoading=false;}
},
async loadHistory(id,{replace=false}={}){
if(this.busy||this.running)return;
this.busy=true;this.error='';clearTimeout(this.pollTimer);
try{
const result=await this.api('/arena/runs/'+encodeURIComponent(id),{},this.team);
this.runTeam=this.team;this.run=result;this.expandedResults=[];this.manualQuotes={};this.reporting='';this.pollFailures=0;
this.taskId=result.capability;this.autoVerify=!!result.auto_verify;this.mode=result.mode;this.inputs={...result.identity};this.extraInputs=(result.identities||[]).slice(1).map(r=>({...r}));this.selectedEntry=null;this.resultFilter='all';this.showAllEntries=false;
this.variant=Math.max(0,this.currentTask.variants.findIndex(v=>v.every(k=>k in this.inputs)));
this.customServices=true;this.services=[...new Set(result.results.map(r=>r.provider))];this.quote=null;this.saveDraft(false);
this.syncUrl(id,replace);
if(this.running)this.pollTimer=setTimeout(()=>this.pollRun(id),1500);
}catch(e){this.error=[403,404,410].includes(e.status)?'This saved run is unavailable, expired, or belongs to another account.':e.message;}finally{this.busy=false;}
},
async newSession(){
if(this.busy||this.running)return;
this.selectedEntry=null;this.variant=0;this.fillExampleInputs();this.customServices=false;this.services=[];this.error='';
await this.newQuery();this.saveDraft(false);document.querySelector('#composer input')?.focus();
},
async newQuery(){this.manualQuotes={};this.reporting='';this.expandedResults=[];this.run=null;this.quote=null;this.linkedRunPending=false;clearTimeout(this.pollTimer);remove(ACTIVE);this.syncUrl();this.scheduleQuote();await this.$nextTick();document.querySelector('#composer')?.scrollIntoView({behavior:'smooth'});},
tryWaterfall(){const previous=this.run;this.taskId=previous.capability;this.inputs={...previous.identity};this.extraInputs=(previous.identities||[]).slice(1).map(r=>({...r}));this.variant=Math.max(0,this.currentTask.variants.findIndex(v=>v.every(k=>k in previous.identity)));this.mode='waterfall';this.customServices=false;this.services=[];this.newQuery();},
exportResult(){const b=new Blob([JSON.stringify(this.run,null,2)],{type:'application/json'});const url=URL.createObjectURL(b);const a=document.createElement('a');a.href=url;a.download='enrich-arena-'+this.run.id+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);},
async logout(){try{await this.api('/auth/logout',{method:'POST'});this.user=null;this.teams=[];this.team='';this.balance=null;this.run=null;this.quote=null;this.history=[];this.historySequence++;remove(ACTIVE);remove(DRAFT);}catch(e){this.error=e.message;}}
},
async mounted(){
this.track('arena_page_viewed');
this.booted=false;this.urlPopHandler=()=>location.reload();window.addEventListener?.('popstate',this.urlPopHandler);
if(this.benchmark){
document.title='People Search Bench — treg';
await this.loadBenchmark();
try{this.meta=await this.api('/meta',{},'');await this.loadIdentity();await this.resumeSignupSetup();}catch(e){this.error=e.message;}
this.booted=true;return;
}
this.loadInsights();
this.insightsTimer=setInterval(()=>{if(!document.hidden)this.loadInsights();},120000);
try{
const [tasks,meta]=await Promise.all([this.api('/arena/tasks',{},''),this.api('/meta',{},'')]);this.tasks=this.leaderboard?tasks.filter(t=>!t.discovery):tasks;this.meta=meta;
const params=new URLSearchParams(location.search),explicitTask=params.has('capability'),linkedRun=params.has('run'),pending=this.leaderboard||linkedRun?false:this.restoreDraft();
const preset=params.get('capability');if(this.tasks.some(t=>t.id===preset)&&this.taskId!==preset)this.chooseTask(preset);
if(explicitTask&&!params.has('variant')&&this.variant!==0)this.chooseVariant(0);
const variant=Number(params.get('variant'));if(params.has('variant')&&Number.isInteger(variant)&&variant>=0&&variant<this.currentTask.variants.length&&this.variant!==variant)this.chooseVariant(variant);
if(!this.leaderboard&&!linkedRun&&!this.draftRestored)this.fillExampleInputs();
const presetMode=params.get('mode');if(['compare','waterfall'].includes(presetMode))this.mode=presetMode;
await this.loadIdentity();this.booted=true;
if(this.leaderboard){document.title='Enrichment Leaderboard — treg';await this.resumeSignupSetup();return;}
const restored=await this.restoreLinkedRun();
if(await this.resumeSignupSetup()||restored)return;
if(pending&&this.user&&!explicitTask){this.saveDraft(false);if(this.team)await this.prepare();else this.$refs.teamDialog.showModal();}
if(!pending||explicitTask)this.scheduleQuote();
}catch(e){this.error=e.message;}
},
beforeUnmount(){window.removeEventListener?.('popstate',this.urlPopHandler);clearInterval(this.insightsTimer);clearTimeout(this.pollTimer);clearTimeout(this.quoteTimer);this.quoteSequence++;this.historySequence++;}
}).mount('#arena');
})();
+30
View File
@@ -0,0 +1,30 @@
/* Read the published benchmark from the existing landing page; keep scores in one place. */
(function(root){
const systems={
treg:{id:'treg',label:'Claude Code + treg',icon:'/favicon.svg',color:'#548A7B'},
lessie:{id:'lessie',label:'Lessie',icon:'/media/people-search/lessie.png',color:'#A5B49A'},
exa:{id:'exa',label:'Exa',icon:'/media/people-search/exa.png',color:'#99AAC5'},
claude:{id:'claude',label:'Claude Code alone',icon:'/media/people-search/claude-code.png',color:'#BEA4A0'}
};
function parseDocument(doc){
const groups=Array.from(doc.querySelectorAll('#bench .bgroup'));
if(groups.length!==4)throw new Error('The published benchmark format has changed.');
const categories=groups.map(group=>{
const name=group.querySelector('.bglab')?.textContent.trim();
if(!name)throw new Error('Benchmark category is missing.');
const rows=Array.from(group.querySelectorAll('.bcol')).map(column=>{
const src=column.querySelector('img')?.getAttribute('src')||'';
const key=src.endsWith('/lessie.png')?'lessie':src.endsWith('/exa.png')?'exa':src.endsWith('/claude-code.png')?'claude':column.querySelector('svg')&&column.querySelector('.win')?'treg':null;
const value=column.querySelector('em')?.textContent.trim()||'';
const score=Number(value);
if(!key||!/^\d+(\.\d+)?$/.test(value)||!Number.isFinite(score)||score<0||score>100)throw new Error('Benchmark scores are unavailable.');
return {...systems[key],score};
});
if(rows.length!==4||new Set(rows.map(row=>row.id)).size!==4)throw new Error('Benchmark systems are incomplete.');
return {id:name.toLowerCase().replace(/[^a-z0-9]+/g,'-'),name,rows};
});
if(new Set(categories.map(category=>category.id)).size!==4)throw new Error('Benchmark categories are incomplete.');
return categories;
}
root.ArenaBench={parseDocument};
})(typeof window==='undefined'?globalThis:window);
+3 -1
View File
@@ -65,7 +65,9 @@ anything confusing or wrong. Keep going with your task after rating.
Phase 1 invites only direct catalog calls served on treg's own platform key. Routed and own-key
catalog calls can still be reviewed uninvited.
CLI invitations appear on stderr; plain HTTP invitations use `X-Treg-Review: requested`.
CLI invitations appear on stderr; plain HTTP invitations use `X-Treg-Hint: review` (with
`X-Treg-Review: requested` alongside for older clients). A sampled `X-Treg-Hint: feedback` is a
lighter nudge to report friction and asks for nothing.
MCP invitations appear in the call result's hint. Use the call ID in the response:
```bash
+33 -77
View File
@@ -15,6 +15,7 @@
cannot reach used to leave the whole signed-in dashboard blank (issue #137). Version lives in
the filename — bump it here and in src/treg/web/vendor/ together. -->
<script src="/vendor/vue-3.5.41.global.prod.js"></script>
<script src="/agent-setup.js"></script>
<script>
// Fail loudly. `[v-cloak]{display:none}` hides the un-compiled template until Vue mounts, so
// anything that stops Vue mounting turns the dashboard into a silent blank page with nothing in
@@ -1594,7 +1595,7 @@ a:hover{text-decoration-color:var(--muted)}
<span v-if="r.pillsMore" class="pchip more" :title="r.pillsMoreTitle">+{{r.pillsMore}}</span>
</div>
<span v-else class="lpath mono"><b>{{r.endpoints[0].provider_display||r.endpoints[0].provider}}</b>
<span class="cat-m">{{r.endpoints[0].method}}</span>{{r.endpoints[0].path}}<!--
<span class="chip" v-if="r.endpoints[0].platform_eligible===false">{{endpointAccessLabel(r.endpoints[0])}}</span><span class="cat-m">{{r.endpoints[0].method}}</span>{{r.endpoints[0].path}}<!--
--><span v-if="r.mgmt" class="chip lkind"
:title="r.endpoints[0].kind==='account' ? 'Manages the provider account itself — lists, campaigns, webhooks' : 'A helper route: token exchange, enum lookups, format cleanup'">{{r.endpoints[0].kind}}</span></span>
</td>
@@ -1617,6 +1618,7 @@ a:hover{text-decoration-color:var(--muted)}
<span class="lcar">▸</span>
<span class="plogo-tile"><img class="plogo" :src="'/logos/'+e.provider+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>
<b>{{e.provider_display||e.provider}}</b>
<span class="chip">{{endpointAccessLabel(e)}}</span>
<span class="lsub-price" :title="costTitle(e.cost)">{{costShort(e.cost)}}</span>
<span v-if="e.verified" class="vmark" :title="'Called for real on '+e.verified">✓</span>
<span v-else class="xmark" title="Documented, but treg has not called it with a live key yet">·</span>
@@ -1636,6 +1638,7 @@ a:hover{text-decoration-color:var(--muted)}
<!-- The short form even here: the chip is a label, and "per result ·
price in provider dashboard" is a sentence. It rides in the facts
list below, where a sentence belongs. -->
<span class="chip">{{endpointAccessLabel(e)}}</span>
<span class="chip" :title="costTitle(e.cost)">{{costShort(e.cost)}}<span
v-if="costNative(e.cost)" class="cost-nat">({{costNative(e.cost)}})</span></span>
<span v-if="e.verified" class="chip ver" :title="'Called for real on '+e.verified+' and the response captured'">verified {{e.verified}}</span>
@@ -3387,57 +3390,21 @@ product, including per-customer usage tracking and billing.</pre>
<div style="color:var(--accent);font-size:15px;letter-spacing:.5px;margin-bottom:12px">▚ treg</div>
<h2 style="margin:0 0 8px;font-size:20px">Which agent are you using?</h2>
<p class="sub" style="margin:0 0 18px">Choose your agent for the best setup instructions.</p>
<div class="agent-grid">
<button v-for="a in welcomeAgents" :key="a.id" type="button" class="agent-card" :class="{on:welcome.agent===a.id}" @click="welcome.agent=a.id; welcome.moreOpen=false">
<img :src="agentIcon(a.icon)" alt="" onerror="this.style.visibility='hidden'"/><span>{{a.name}}</span>
</button>
<button type="button" class="agent-card" :class="{on:welcome.moreOpen || welcomeIsMore}" @click="welcome.moreOpen=!welcome.moreOpen">
<span style="font-size:15px;width:20px;text-align:center;flex:none">☰</span><span>{{welcomeIsMore && !welcome.moreOpen ? welcomeAgent.name : 'More'}}</span><span style="margin-left:auto;color:var(--muted)">▾</span>
</button>
</div>
<div v-if="welcome.moreOpen" class="agent-grid" style="margin-top:10px">
<button v-for="a in welcomeMoreAgents" :key="a.id" type="button" class="agent-card" :class="{on:welcome.agent===a.id}" @click="welcome.agent=a.id; welcome.moreOpen=false">
<img v-if="a.icon" :src="agentIcon(a.icon)" alt="" onerror="this.style.visibility='hidden'"/><span v-else style="font-size:15px;width:20px;text-align:center;flex:none">✳</span><span>{{a.name}}</span>
</button>
</div>
<treg-agent-picker v-model="welcome.agent" :icon="agentIcon"></treg-agent-picker>
<div class="wc-foot">
<a href="#" class="sub" @click.prevent="welcomeFinish">Skip</a>
<button class="btn primary" @click="track('onboarding_agent_picked',{agent:welcome.agent}); welcome.step=2">Next →</button>
</div>
</template>
<template v-else-if="welcome.step===2">
<p class="sub" style="display:flex;align-items:center;gap:8px;margin:0 0 16px"><img v-if="welcomeAgent.icon" :src="agentIcon(welcomeAgent.icon)" alt="" style="width:18px;height:18px" onerror="this.style.visibility='hidden'"/>Setting up treg for <b style="color:var(--ink)">{{welcomeAgent.name}}</b></p>
<template v-if="welcomeAgent.plugin">
<h2 style="margin:0 0 10px;font-size:19px">1. Install the treg plugin</h2>
<a class="btn" :href="welcomeAgent.plugin" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:8px;margin-bottom:18px" @click="track('onboarding_plugin_install_clicked',{agent:welcome.agent})"><img :src="agentIcon(welcomeAgent.icon)" alt="" style="width:16px;height:16px"/>Install plugin in {{welcomeAgent.name}} ↗</a>
<h2 style="margin:0 0 14px;font-size:19px">2. In your Bot's chat, send:</h2>
</template>
<h2 v-else style="margin:0 0 14px;font-size:19px">In your agent's chat, send:</h2>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart(welcomeSetupFull,'wc')">{{startCopied==='wc'?'✓ copied':'copy'}}</button><pre style="white-space:pre-wrap;word-break:break-all">{{welcomeSetupMasked}}</pre></div>
<a v-if="myToken" href="#" class="sub" style="font-size:12px;display:inline-block;margin-top:6px" @click.prevent="startTokenShow=!startTokenShow">{{startTokenShow?'Hide':'Show'}} key</a>
<p class="sub" style="font-size:12px;margin:12px 0 0">{{welcomeAgent.plugin ? 'Your Bot reads that file and signs in with your team & token — then it can call every tool in the catalog.' : 'Your agent reads that file and signs in with your team & token — it installs the CLI and starts calling tools.'}}</p>
<treg-setup-instructions :agent="welcomeAgent" :icon="agentIcon" :command="welcomeSetupCmd" :team="activeSlugNow || '<team-slug>'" :token="myToken" :show-token="startTokenShow" :copied="startCopied==='wc'" @copy="copyStart($event,'wc')" @toggle-token="startTokenShow=!startTokenShow" @plugin="track('onboarding_plugin_install_clicked',{agent:welcome.agent})"></treg-setup-instructions>
<div class="wc-foot">
<a href="#" class="sub" @click.prevent="welcome.step=1">← Back</a>
<button class="btn primary" @click="welcome.step=3">Next →</button>
</div>
</template>
<template v-else>
<h2 style="margin:0 0 6px;font-size:19px">Try it out</h2>
<div class="wc-wait" style="margin:0 0 16px"><span class="wc-waitdot"></span>Waiting for your agent — copy an example below and send it to get your first result.</div>
<div class="try-grid">
<button v-for="ex in tryExamples" :key="ex.k" type="button" class="try-card" @click="track('tryit_prompt_copied',{key:ex.k,cat:ex.cat,from:'onboarding'}); copyStart(ex.prompt,'wtry-'+ex.k)">
<span class="try-cat"><span style="display:inline-flex;align-items:center;gap:7px"><img class="try-ico" :src="'/logos/platforms/'+ex.logo+'.svg'" alt=""/><img v-if="ex.avatar" class="try-ico avatar" :src="ex.avatar" alt=""/>{{ex.cat}}</span><span class="try-copy" :class="{done:startCopied==='wtry-'+ex.k}">{{startCopied==='wtry-'+ex.k ? '✓ copied' : '⧉ copy'}}</span></span>
<span class="try-txt">{{ex.prompt}}</span>
</button>
</div>
<div class="oauth-div"><span>also connect OAuth to unlock new agent capabilities</span></div>
<div v-for="g in tryOauth" :key="g.label" style="margin-top:12px">
<div class="oauth-grp">{{g.label}}</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button v-for="p in g.items" :key="p.s" type="button" class="prov-chip" @click="track('tryit_oauth_clicked',{service:p.s,group:g.label,from:'onboarding'}); welcome.on=false; openProvider(p.s)"><img :src="'/logos/'+p.s+'.svg'" alt=""/>{{p.n}}</button>
<span v-if="g.soon.length" class="soon-note" :data-tip="g.soon.map(p=>p.n).join(', ')">{{g.soon.length}} coming soon</span>
</div>
</div>
<treg-try-it-out :copied="startCopied.startsWith('wtry-')?startCopied.slice(5):''" @example="track('tryit_prompt_copied',{key:$event.k,cat:$event.cat,from:'onboarding'}); copyStart($event.prompt,'wtry-'+$event.k)" @provider="welcomeTryProvider"></treg-try-it-out>
<div class="wc-foot">
<a href="#" class="sub" @click.prevent="welcomeFinish">Skip</a>
<button class="btn primary" @click="track('tryit_browse_catalog',{from:'onboarding'}); welcome.on=false; go('connections')">Browse all catalog →</button>
@@ -3818,6 +3785,7 @@ const { createApp } = Vue;
const LS='treg-dash';
createApp({
components:{TregTryItOut:TregAgentSetup.TryItOut,TregAgentPicker:TregAgentSetup.AgentPicker,TregSetupInstructions:TregAgentSetup.SetupInstructions},
data(){
let cfg={active:null,orgs:{}}; try{ cfg=JSON.parse(localStorage.getItem(LS))||cfg; }catch(e){}
return {
@@ -4229,21 +4197,8 @@ createApp({
// instructions, so this prompt only has to name the repo, the page, and the contact email.
vendorPromptText(){ return `Help me create a PR to treg (https://github.com/superdesigndev/treg) that adds our API to its tool catalog. Follow the instructions at ${this.proxy}/vendor-listing — and include our contact email in the PR description so the maintainers can reach us to arrange live verification.`; },
// first-run welcome: the agent picker (step 1) and the per-agent setup line (step 2)
welcomeAgents(){ return [
{id:'openclaw', name:'OpenClaw', icon:'openclaw-color'},
{id:'grokbot', name:'Grok Bot', icon:'/logos/agents/grokbot.png', plugin:'https://x.ai/bot/plugin/55647425'}, // bundled mark (no lobehub icon for the Bot product); `plugin` = the install step shown before the setup line
{id:'hermes', name:'Hermes Agent', icon:'hermesagent'},
{id:'claudeai', name:'Claude.ai', icon:'claude-color'},
{id:'claude-code', name:'Claude Code', icon:'claudecode-color'},
{id:'codex', name:'Codex', icon:'codex-color'},
]; },
welcomeMoreAgents(){ return [
{id:'opencode', name:'opencode', icon:'opencode'},
{id:'pi', name:'pi', icon:'pi'},
{id:'cursor', name:'Cursor', icon:'cursor'},
{id:'gemini-cli', name:'Gemini CLI', icon:'gemini-color'},
{id:'other', name:'Other', icon:null},
]; },
welcomeAgents(){ return TregAgentSetup.agents; },
welcomeMoreAgents(){ return TregAgentSetup.moreAgents; },
welcomeAgent(){ return this.welcomeAgents.concat(this.welcomeMoreAgents).find(a=>a.id===this.welcome.agent) || this.welcomeAgents[0]; },
welcomeIsMore(){ return this.welcomeMoreAgents.some(a=>a.id===this.welcome.agent); },
welcomeSetupCmd(){ return this.buildAgentPrompt('agent', true); },
@@ -4288,18 +4243,8 @@ createApp({
const what=this.epTry.summary ? this.epTry.summary.replace(/\.$/,'') : this.epTry.id;
const auth=this.epTryAuthMethod ? ` Use authorization_method=${this.epTryAuthMethod}.` : '';
return `Use treg to call ${this.epTry.id} — ${what}.${auth}`; },
tryExamples(){ return [
{k:'trend',cat:'Trending videos pattern', logo:'tiktok', prompt:'Use treg to pull today\'s trending TikTok videos (video links included)'},
{k:'enr', cat:'Get contact emails', logo:'people', avatar:'https://pbs.twimg.com/profile_images/1131851609774985216/OcsssQ9J_400x400.png', prompt:'Use treg to find the work email of Peter Steinberger'},
{k:'serp', cat:'Keyword volume', logo:'google', prompt:'Use treg to pull real monthly search volume and top related keywords worth targeting for my business'},
{k:'soc', cat:'Scrape linkedin', logo:'linkedin',prompt:'Use treg to look up linkedin.com/in/jasonzhoudesign'},
]; },
tryOauth(){ return [
{label:'Post on social', items:[{s:'x',n:'X (Twitter)'},{s:'youtube',n:'YouTube'},{s:'tiktok',n:'TikTok'},{s:'linkedin',n:'LinkedIn'},{s:'facebook',n:'Facebook Pages'},{s:'instagram',n:'Instagram'}],
soon:[]},
{label:'Manage ad campaigns', items:[{s:'google-ads',n:'Google Ads'},{s:'meta-ads',n:'Meta Ads'}], soon:[]},
{label:'SEO on your own site',items:[{s:'google-analytics',n:'Google Analytics'},{s:'google-search-console',n:'Search Console'},{s:'google-business-profile',n:'Business Profile'}], soon:[]},
]; },
tryExamples(){ return TregAgentSetup.examples; },
tryOauth(){ return TregAgentSetup.oauthGroups; },
// What to actually DO with a freshly minted agent token, in the three shapes people need.
agentSnippet(){ const a=this.newAgent||this.snipAgent; if(!a) return '';
const base=this.proxy||location.origin, org=a.org||this.activeSlugNow;
@@ -4762,7 +4707,7 @@ createApp({
// Named differently from the server's topup_started (same click, two vantage points —
// a shared name would double-count in trends); this one exists to link session replays.
this.track('topup_checkout_opened',{amount_usd:amount});
try{ const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:amount})});
try{ const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:amount,checkout_source:window.TregTracking?.checkoutSource()||'app'})});
// Stripe's own hosted page does the payment; the balance moves when its webhook lands, never
// on the return redirect (which a payer could simply type). Same tab, so the return lands here.
window.location.href=out.url; }
@@ -4793,7 +4738,7 @@ createApp({
this.billing=await this.api('/billing/autotopup',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({enabled:true, consent:true, amount_usd:this.autoAmount, threshold_usd:this.autoThreshold, monthly_cap_usd:this.autoCapUsd, setup_url:false})});
}
const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:usd})});
const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:usd,checkout_source:window.TregTracking?.checkoutSource()||'app'})});
window.location.href=out.url; }
catch(e){ this.topupErr='Could not start the payment: '+(e.detail||e.status); this.billingBusy=false; } },
// The toggle itself never arms auto top-up - it can only DISARM (off is safe) or open the
@@ -5091,7 +5036,7 @@ createApp({
this.skillResults=r.results; await this.loadAll();
if(r.results.length && r.results.every(x=>x.ok)){ setTimeout(()=>{ this.newSkill=false; }, 1000); } }
catch(e){ this.skillErr='Import failed: '+(e.detail||e.message||(e.status?('HTTP '+e.status):'request failed - try registering fewer skills at once')); } finally{ this.skillBusy=false; } },
logout(){ try{ if(window.Intercom) window.Intercom('shutdown'); }catch(e){} // drop the Intercom cookie so the next user on this machine can't read these conversations
logout(){ window.TregTracking?.identify('',''); try{ if(window.Intercom) window.Intercom('shutdown'); }catch(e){} // drop the Intercom cookie so the next user on this machine can't read these conversations
if(this.sessionMode){ fetch('/auth/logout',{method:'POST',credentials:'include'}).finally(()=>{localStorage.removeItem('treg-active');location.reload();}); } else { this.cfg={active:null,orgs:{}}; this.save(); location.reload(); } },
dot(s){ return s==='unknown'||s==='-'?'○':'●'; },
short(e){ return (e||'').split('@')[0]; },
@@ -5183,7 +5128,7 @@ createApp({
session_recording:{maskAllInputs:true, maskTextSelector:'pre, .lc-codewrap, .agent-copy'}});
this.analyticsIdentify();
},
analyticsIdentify(){ if(!window.posthog || !window.posthog.identify || !this.me) return;
analyticsIdentify(){ if(!this.me)return; if(window.TregTracking){window.TregTracking.identify(this.me,this.activeSlugNow);return;} if(!window.posthog || !window.posthog.identify || !this.me) return;
try{ window.posthog.identify(this.me, {email:this.me}); if(this.activeSlugNow) window.posthog.group('team', this.activeSlugNow); }catch(e){} },
track(name, props){ try{ if(window.posthog && window.posthog.capture) window.posthog.capture(name, props||{}); }catch(e){} },
// Support chat (Intercom) — only when this deployment opted in (meta.intercom_app_id present);
@@ -5234,6 +5179,7 @@ createApp({
if(this.sessionMode){ this.activeSlug=o.slug; localStorage.setItem('treg-active',o.slug); this.loadAll(); this.intercomUpdate(); return; }
if(!this.connected(o.slug)){ this.addOrg=true; return; }
this.cfg.active=o.slug; this.save(); this.loadAll(); this.intercomUpdate(); },
welcomeTryProvider(service,group){this.track('tryit_oauth_clicked',{service,group,from:'onboarding'});this.welcome.on=false;this.openProvider(service);},
copyStart(text, tag){ navigator.clipboard.writeText(text).then(()=>{ this.startCopied=tag; setTimeout(()=>{ if(this.startCopied===tag) this.startCopied=''; },1400); }).catch(()=>{}); },
// ---- connections (registry OAuth) ----
async loadConnections(){
@@ -5447,7 +5393,7 @@ createApp({
// token, the team, permission to run things, and "do it now". `kind` is ignored (call sites keep
// their argument) so there is a single text to keep true.
buildAgentPrompt(kind, inclToken){ // ONE line — llms.txt carries the whole setup flow (auth included)
return `set up treg — ${this.proxy}/llms.txt`;
return TregAgentSetup.command(this.proxy);
},
async copyAgentPrompt(){ if(!(await this.toClipboard(this.agentPromptText))) return; this.agentGuideCopied=true; setTimeout(()=>this.agentGuideCopied=false,1500); },
async copyVendorPrompt(){ if(!(await this.toClipboard(this.vendorPromptText))) return; this.vendorCopied=true; setTimeout(()=>this.vendorCopied=false,1500); },
@@ -5594,7 +5540,7 @@ createApp({
const sym={CNY:'¥'}[c.currency];
const num=n>=0.01 ? n.toFixed(2) : n.toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
return sym ? sym+num : num+' '+c.currency; },
costNative(c){ return this.nativeAmount(c); },
costNative(c){ return c && c.display_unit ? '' : this.nativeAmount(c); },
// The card's starting price. `price_from` arrives as null OR as an empty object, and an empty
// one must read as "no price on the row" — otherwise it short-circuits the OAuth-free branch
// below and an OAuth-only platform silently loses its "free with your account".
@@ -5621,6 +5567,14 @@ createApp({
return ['The cheapest published rate across this platform’s endpoints',
p.native ? 'billed as '+p.native+' / '+this.priceUnit(pf.type)+', converted at the catalog’s FX rate' : '',
pf.note].filter(Boolean).join(' — '); },
endpointAccessLabel(e){
if(e.kind==='routed') return 'Routed platform call';
const p=this.providers.find(p=>p.service===e.provider)
|| (this.platData&&this.platData.providers||{})[e.provider] || {};
if(p.auth_kind==='oauth') return p.metered ? 'OAuth · metered' : 'OAuth connection';
if(p.auth_kind==='key') return e.platform_eligible ? 'Platform + BYOK' : 'BYOK only';
return e.platform_eligible ? 'Platform access' : 'Own connection required';
},
catConnected(service){ return !!this.connCount[service]; },
endpointAuthMethods(e){ return [...new Set([e&&e.authorization_method,
...((e&&e.authorization_methods)||[]), ...Object.keys((e&&e.authorization_paths)||{})].filter(Boolean))]; },
@@ -5684,7 +5638,7 @@ createApp({
const free=this.capFree(e), n=free?0:this.costUsd(e.cost);
if(n==null) continue;
if(best===null || n<best.n)
best={n, label:(free||n===0)?'free':this.costLabel(e.cost), native:(free||n===0)?'':this.nativeAmount(e.cost)};
best={n, label:(free||n===0)?'free':this.costLabel(e.cost), native:(free||n===0||e.cost.display_unit)?'':this.nativeAmount(e.cost)};
}
return best; },
// "See Zhihu, Toutiao, and 6 more" — two names so the row says what KIND of thing is hiding,
@@ -5713,16 +5667,16 @@ createApp({
const by=new Map();
for(const e of eps){
const cur=by.get(e.provider), n=this.costUsd(e.cost);
if(!cur) by.set(e.provider, {name:e.provider_display||e.provider, cost:e.cost, n, verified:!!e.verified});
if(!cur) by.set(e.provider, {name:e.provider_display||e.provider, cost:e.cost, n, endpoint:e, verified:!!e.verified});
else { cur.verified = cur.verified || !!e.verified;
if(n!=null && (cur.n==null || n<cur.n)){ cur.cost=e.cost; cur.n=n; } }
if(n!=null && (cur.n==null || n<cur.n)){ cur.cost=e.cost; cur.n=n; cur.endpoint=e; } }
}
// Cheapest first, then verified: only three of these are ever shown, so the three that
// survive have to be the ones worth seeing. Unpriced providers sort to the tail — they are
// exactly the ones whose pill would say nothing but a name.
return [...by.values()]
.sort((a,b)=>(a.n==null)-(b.n==null) || (a.n-b.n) || (b.verified-a.verified))
.map(p=>({name:p.name, verified:p.verified, price:this.pillPrice(p.cost)})); },
.map(p=>({name:p.name, verified:p.verified, price:p.endpoint.platform_eligible ? this.pillPrice(p.cost) : this.endpointAccessLabel(p.endpoint)})); },
// A pill prices an endpoint only when there IS a price: a published number, or "free". A
// credit-metered or dashboard-only rate has no number to show, and saying so at length is what
// broke the row — the pill just drops it, and the sub-row below carries the full story.
@@ -5792,6 +5746,7 @@ createApp({
// "free", "—". The provider's own figure follows as a muted suffix (see `costNative`).
costLabel(c){
if(!c || !c.type) return '—';
if(c.display_unit && typeof c.display_usd==='number') return '$'+this.usdNum(c.display_usd)+(c.display_suffix||'')+'/'+c.display_unit;
if(c.type==='free') return 'free';
// Known billing unit, unpublished number: say which, and where the number lives. A bare
// "per success" reads as free, and "—" hides that we do know how it's metered.
@@ -5809,6 +5764,7 @@ createApp({
if(typeof c.usd_min==='number' && c.usd_min<c.usd) return '$'+this.usdNum(c.usd_min)+'-$'+this.usdNum(c.usd)+'/'+unit;
return '$'+this.usdNum(c.usd)+'/'+unit; },
costTitle(c){ if(!c) return 'The catalog has no price for this endpoint';
if(c.display_unit) return this.costLabel(c)+(c.note ? ' — '+c.note : '');
const nat=this.nativeAmount(c);
return [c.note,
nat ? 'billed as '+nat+'/'+this.priceUnit(c.type)+', converted at the catalog’s FX rate' : '',
+1 -1
View File
@@ -883,7 +883,7 @@ footer{margin-top:150px;border-top:1px solid var(--line);position:relative;overf
</div>
<nav class="foot-cols" aria-label="Site">
<div class="foot-col"><div class="lab">Explore</div>
<a href="/catalog">Catalog</a><!--hosted--><a href="/use-cases">Use cases</a><a href="/workflows">Workflows</a><a href="/agents">Agents</a><!--/hosted--></div>
<a href="/catalog">Catalog</a><a href="/enrich-arena">Enrich Arena</a><!--hosted--><a href="/use-cases">Use cases</a><a href="/workflows">Workflows</a><a href="/agents">Agents</a><!--/hosted--></div>
<div class="foot-col"><div class="lab">Build</div>
<a href="/tutorial">Docs</a><a href="/docs">API</a><a href="/llms.txt">llms.txt</a><a href="https://github.com/superdesigndev/treg" target="_blank" rel="noopener">GitHub ↗</a></div>
<div class="foot-col"><div class="lab">Company</div>

Some files were not shown because too many files have changed in this diff Show More