Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
aaa5d4e
Publish to mindshub domain
tino097 Jun 23, 2026
02485f7
Merge remote-tracking branch 'origin/main' into feature/desktop-app-p…
tino097 Jun 23, 2026
7f86684
feat(select_path): agent tool for mid-turn file/folder disambiguation
hazemahmedx0 Jun 21, 2026
3d83d71
feat(select_path): add browse mode for unspecified paths
hazemahmedx0 Jun 21, 2026
4f6972c
feat(select_path): inject elicitor via config, resolve browse picks a…
hazemahmedx0 Jun 21, 2026
247077c
refactor(select_path): address review feedback
hazemahmedx0 Jun 22, 2026
986dd38
fix(settings): host-aware minds→openai base URL derivation (ENG-436)
alecantu7 Jun 23, 2026
c9e32a8
fix(scratchpad): host-aware minds base URL in scratchpad boot env (EN…
alecantu7 Jun 23, 2026
3966952
fix(read_image): resolve relative paths against the workspace, not cwd
torrmal Jun 23, 2026
1cab321
Merge pull request #208 from mindsdb/eng-436/minds-base-url-host-aware
torrmal Jun 23, 2026
15d903a
chore: add ~/.cowork/.env to the env-loading chain
torrmal Jun 23, 2026
aa184b5
Merge pull request #210 from mindsdb/chore/cowork-home
torrmal Jun 24, 2026
9ce79ac
Update download URLs from downloads.mindsdb.com to downloads.mindshub.ai
pnewsam Jun 24, 2026
ff7a03a
Merge pull request #197 from mindsdb/feat/inline-path-selector
hazemahmedx0 Jun 24, 2026
07cc581
Merge pull request #209 from mindsdb/fix/read-image-workspace-path
tino097 Jun 24, 2026
5fd902f
Merge remote-tracking branch 'origin/staging' into feature/desktop-ap…
tino097 Jun 24, 2026
cd08443
fix(security): guard web_fetch against SSRF (CWE-918)
torrmal Jun 24, 2026
43509c3
Merge pull request #215 from mindsdb/fix/ssrf-web-fetch
torrmal Jun 25, 2026
b3c6da5
Merge pull request #212 from mindsdb/eng-437/migrate-downloads-to-min…
pnewsam Jun 25, 2026
65a8541
fix(scratchpad): correct vision format for Gemini (gate anthropic on …
alecantu7 Jun 25, 2026
e3670a2
fix(llm): gate openai-compatible vision format on host in from_settin…
alecantu7 Jun 25, 2026
f0d3ed3
feat(analytics): tag CI traffic with is_ci on every event (ENG-385)
tino097 Jun 25, 2026
a4fbb33
send trace data
SailingSF Jun 25, 2026
6f0b91c
fix(analytics): drop CI traffic + explicit ANTON_IS_CI contract (ENG-…
tino097 Jun 26, 2026
f889c9f
fix(scratchpad): correct Gemini vision format (gate anthropic on host…
alecantu7 Jun 26, 2026
0898f4f
Merge branch 'main' into staging
pnewsam Jun 26, 2026
a5a35d3
Merge pull request #217 from mindsdb/tino097/eng-385-free-tier-funnel…
tino097 Jun 26, 2026
3c93855
Merge pull request #206 from mindsdb/feature/desktop-app-publish-url
tino097 Jun 29, 2026
861b78d
deterministic zip bundle so md5 is time-independent
StpMax Jun 29, 2026
608d456
Merge pull request #222 from mindsdb/fix/deterministic-zip-bundle
StpMax Jun 30, 2026
907d9e0
Add list and activate versions to publisher
ZoranPandovski Jun 30, 2026
77db0d7
fix(scrub): redact provider API keys from tool output before it reach…
tino097 Jun 30, 2026
5e483e5
fix(tracing): sanitize caller-supplied Langfuse tags (#218 review)
SailingSF Jun 30, 2026
3f04b35
chore(release): bump version to 2.26.6.30.1
SailingSF Jun 30, 2026
14193f3
Merge pull request #218 from mindsdb/feat/use-headers
SailingSF Jun 30, 2026
2921fc2
ENG-513: validate stale training-data assumptions before blocking tasks
torrmal Jun 30, 2026
f1f3e6d
feat(datasources): surface non-secret connection identity in the agen…
alecantu7 Jun 30, 2026
cbf4511
Merge pull request #228 from mindsdb/jorge/eng-513-anton-validate-sta…
torrmal Jul 1, 2026
85e21b8
Merge pull request #226 from mindsdb/tino097/eng-463-scrub-provider-k…
tino097 Jul 1, 2026
759bbb8
Refresh the Anton README — position as the agent powering Cowork
C0staTin Jun 26, 2026
f6e3e82
ENG-520: add HasData datasource + document all endpoints in system pr…
torrmal Jul 1, 2026
9037a5b
ENG-520: revert prompts.py HasData section — endpoint docs belong in …
torrmal Jul 1, 2026
8315b96
ENG-520: fix HasData datasource — two-step async pattern + field guards
torrmal Jul 1, 2026
f3486d4
style: replace 'Never' with 'Do not' in HasData docs
torrmal Jul 1, 2026
b996122
Merge pull request #229 from mindsdb/jorge/eng-520-hasdata-integratio…
torrmal Jul 1, 2026
b4bf296
Merge pull request #225 from mindsdb/feature/artifacts-versioning
ZoranPandovski Jul 2, 2026
88291fb
Merge pull request #231 from mindsdb/main
ZoranPandovski Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion anton/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.26.6.22.1"
__version__ = "2.26.6.30.1"
36 changes: 36 additions & 0 deletions anton/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from __future__ import annotations

import hashlib
import os
import threading
import time
import urllib.parse
Expand All @@ -51,6 +52,34 @@
# a process, so computing it once is sufficient.
_cached_aid: str | None = None

# Cached CI detection. Env-derived (no PII), consistent with this module's
# anonymous design. CI/automation traffic is dropped entirely (see send_event)
# rather than tagged, so it can't pollute the product funnel. Driven by an
# explicit Anton-owned signal (ANTON_IS_CI) with known provider markers as a
# convenience fallback; the bare ``CI`` var is intentionally not consulted —
# it's frequently set to "false" or leaks into local dev shells (ENG-385).
_cached_is_ci: bool | None = None


def _env_true(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}


def _is_ci() -> bool:
"""Return True for Anton automation/CI traffic (cached, env-only)."""
global _cached_is_ci
if _cached_is_ci is None:
_cached_is_ci = (
_env_true("ANTON_IS_CI")
or _env_true("GITHUB_ACTIONS")
or _env_true("GITLAB_CI")
or _env_true("BUILDKITE")
or _env_true("CIRCLECI")
or _env_true("TF_BUILD")
or bool(os.environ.get("JENKINS_URL"))
)
return _cached_is_ci


def get_installation_id() -> str:
"""Return a deterministic, anonymous machine fingerprint.
Expand Down Expand Up @@ -97,10 +126,17 @@ def send_event(settings: "AntonSettings", action: str, **extra: str) -> None:
settings: Resolved AntonSettings (checked for analytics_enabled / analytics_url).
action: Event name, e.g. ``"anton_started"``.
**extra: Additional key=value pairs appended as query parameters.

CI/automation traffic (ANTON_IS_CI, or a known CI provider) is dropped
rather than sent, so it can't pollute the product funnel (no PII either way).
"""
try:
if not settings.analytics_enabled:
return
# Drop CI/automation traffic entirely — no value in product analytics
# from CI runs, and dropping avoids a per-query exclusion filter.
if _is_ci():
return
url = settings.analytics_url
if not url:
return
Expand Down
23 changes: 20 additions & 3 deletions anton/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@


def _build_env_files() -> list[str]:
"""Build .env loading chain: cwd/.env -> .anton/.env -> ~/.anton/.env"""
"""Build .env loading chain: cwd/.env -> .anton/.env -> ~/.anton/.env
-> ~/.cowork/.env. Later files win, so the consolidated ~/.cowork/.env
takes precedence; ~/.anton/.env stays as a fallback for installs that
haven't migrated yet."""
files: list[str] = [".env"]
local_env = Path.cwd() / ".anton" / ".env"
if local_env.is_file():
files.append(str(local_env))
user_env = Path("~/.anton/.env").expanduser()
if user_env.is_file():
files.append(str(user_env))
cowork_env = Path("~/.cowork/.env").expanduser()
if cowork_env.is_file():
files.append(str(cowork_env))
return files


Expand Down Expand Up @@ -112,7 +118,7 @@ class AntonSettings(CoreSettings):
minds_ssl_verify: bool = True

# Publish service
publish_url: str = "https://4nton.ai"
publish_url: str = "https://view.mindshub.ai"

backend: str = "local" # local | remote

Expand All @@ -139,7 +145,18 @@ def model_post_init(self, __context) -> None:
):
self.openai_api_key = self.minds_api_key
if not self.openai_base_url:
self.openai_base_url = f"{self.minds_url.rstrip('/')}/api/v1"
# Host-aware base URL: api.mindshub.ai serves the
# OpenAI-compatible API at /v1, the legacy mdb.ai host at
# /api/v1. The previous hardcoded /api/v1 was correct only
# for mdb.ai and produced a wrong endpoint for mindshub
# (ENG-436). Mirrors cowork-server's minds_chat_base_url.
base = self.minds_url.rstrip("/")
if base.endswith("/v1"):
self.openai_base_url = base
elif "mdb.ai" in base:
self.openai_base_url = f"{base}/api/v1"
else:
self.openai_base_url = f"{base}/v1"

_workspace: Path = PrivateAttr(default=None)

Expand Down
12 changes: 11 additions & 1 deletion anton/core/backends/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,17 @@ async def start(self) -> None:
and "ANTON_MINDS_URL" in env
and self._coding_provider == "openai-compatible"
):
env["OPENAI_BASE_URL"] = f"{env['ANTON_MINDS_URL'].rstrip('/')}/api/v1"
# Host-aware (ENG-436): api.mindshub.ai serves /v1, legacy
# mdb.ai serves /api/v1. The previous hardcoded /api/v1 was
# wrong for mindshub. Mirrors config/settings.py +
# cowork-server minds_chat_base_url.
_minds_base = env["ANTON_MINDS_URL"].rstrip("/")
if _minds_base.endswith("/v1"):
env["OPENAI_BASE_URL"] = _minds_base
elif "mdb.ai" in _minds_base:
env["OPENAI_BASE_URL"] = f"{_minds_base}/api/v1"
else:
env["OPENAI_BASE_URL"] = f"{_minds_base}/v1"
if self._coding_api_key:
sdk_key = {
"anthropic": "ANTHROPIC_API_KEY",
Expand Down
17 changes: 10 additions & 7 deletions anton/core/backends/scratchpad_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,26 @@ def _dump_namespace(ns: dict) -> str | None:
"ANTON_OPENAI_API_KEY"
)
_llm_api_version = os.environ.get("ANTON_OPENAI_API_VERSION") or None
# Mirror LLMClient.from_settings: openai-compatible endpoints
# (Minds/MindsHub) expect image blocks in Anthropic native format,
# not OpenAI image_url data-URLs.
_llm_provider_kwargs: dict = {
"api_key": _llm_api_key or None,
"base_url": _llm_base_url or None,
"ssl_verify": _llm_ssl_verify,
"api_version": _llm_api_version,
}
# Endpoints that proxy to Minds / MindsHub / MDB.AI
# speak Anthropic content format over the OpenAI HTTP envelope,
# so image blocks must be Anthropic-shaped, not OpenAI image_url.
# Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP
# envelope, so ONLY they need image blocks in Anthropic native format.
# Gate on the host, NOT on "openai-compatible" generally: other
# OpenAI-compatible endpoints (e.g. Gemini at
# generativelanguage.googleapis.com, or a generic proxy) expect
# standard OpenAI image_url blocks. Forcing anthropic format for all
# openai-compatible mangled images for Gemini. OpenAIProvider already
# defaults to supports_vision=True / vision_format="openai", so the
# non-proxy case needs no override.
_llm_url_lower = (_llm_base_url or "").lower()
_anthropic_proxy = any(
host in _llm_url_lower for host in ("mdb.ai", "mindshub.ai")
)
if _scratchpad_provider_name == "openai-compatible" or _anthropic_proxy:
if _anthropic_proxy:
_llm_provider_kwargs["supports_vision"] = True
_llm_provider_kwargs["vision_format"] = "anthropic"
# Resolve the OpenAI "flavor" so the injected web_search() helper can
Expand Down
105 changes: 105 additions & 0 deletions anton/core/datasources/datasources.md
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,111 @@ password if your provider requires it.

---

## HasData

Single API key for 25+ structured web data APIs: Google SERP/Maps/Trends/News/Shopping/Flights/Hotels,
Amazon, Airbnb, Booking.com, Zillow, Redfin, YouTube, Instagram, Indeed, Glassdoor, Yelp,
Yellow Pages, Shopify, Bing, and general web scraping.

Base URL: `https://api.hasdata.com` · Auth: `x-api-key` header.

**⚠️ Two-step async pattern — ALWAYS follow this:**
1. `GET /scrape/<endpoint>?<params>` → response contains `requestMetadata.resultUrl` (a CDN URL)
2. Wait 2–3 s, then `GET <resultUrl>` → actual JSON results

Do not try to read results from the first response body — it only contains metadata.
Do not hit the API root (`/`) — there is no handler there.

```python
# Canonical usage pattern
import httpx, os, time

key = os.environ.get('DS_API_KEY')
base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com')
headers = {"x-api-key": key}

# Step 1 — trigger the scrape
r = httpx.get(f"{base}/scrape/google/serp", headers=headers, params={"q": "your query", "gl": "us"})
r.raise_for_status()
result_url = r.json()["requestMetadata"]["resultUrl"]

# Step 2 — wait for CDN, then fetch results
time.sleep(3)
data = httpx.get(result_url).json()

# Guard sitelinks — can be a dict OR a list depending on the result
sitelinks = data.get("sitelinks", [])
if isinstance(sitelinks, list):
sitelinks = sitelinks[:3]
```

```yaml
engine: hasdata
display_name: HasData
name_from: api_key
popular: true
fields:
- { name: api_key, required: true, secret: true, description: "HasData API key from hasdata.com dashboard" }
- { name: base_url, required: false, secret: false, description: "API base URL", default: "https://api.hasdata.com" }
test_snippet: |
import httpx, os, time
key = os.environ['DS_API_KEY']
base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com')
headers = {"x-api-key": key}
r = httpx.get(f"{base}/scrape/google/serp", headers=headers, params={"q": "test", "num": "1"})
assert r.status_code == 200, f"unexpected status {r.status_code}: {r.text[:200]}"
meta = r.json().get("requestMetadata", {})
assert "resultUrl" in meta, f"missing resultUrl in response: {list(meta.keys())}"
time.sleep(3)
results = httpx.get(meta["resultUrl"]).json()
assert isinstance(results, dict), f"unexpected results type: {type(results)}"
print("ok")
```

Endpoints (all GET, auth via `x-api-key` header):

| API | Path | Key params |
|---|---|---|
| Google SERP | `/scrape/google/serp` | `q`, `gl`, `hl`, `location`, `num`, `start`, `deviceType` |
| Google AI Mode | `/scrape/google/ai-overview` | `pageToken` (from SERP aiOverview block) |
| Google Maps Search | `/scrape/google-maps/search` | `q`, `gl`, `hl`, `ll`, `start` |
| Google Maps Reviews | `/scrape/google-maps/reviews` | `placeId`, `hl` |
| Google Maps Photos | `/scrape/google-maps/photos` | `placeId` |
| Google Maps Posts | `/scrape/google-maps/posts` | `placeId` |
| Google Trends | `/scrape/google/trends` | `q`, `geo`, `date` |
| Google News | `/scrape/google/news` | `q`, `gl`, `hl` |
| Google Shopping | `/scrape/google/shopping` | `q`, `gl`, `hl` |
| Google Images | `/scrape/google/images` | `q`, `gl`, `hl` |
| Google Flights | `/scrape/google/flights` | `departureId`, `arrivalId`, `outboundDate`, `returnDate`, `type` |
| Google Hotels | `/scrape/google/hotels` | `q`, `checkIn`, `checkOut`, `adults`, `gl`, `hl` |
| Google Rank Checker | `/scrape/google/rank-checker` | `q`, `website`, `gl`, `hl` |
| Bing Search | `/scrape/bing/serp` | `q`, `count`, `offset`, `mkt` |
| Amazon Product | `/scrape/amazon/product` | `asin`, `domain`, `deliveryZip` |
| Amazon Search | `/scrape/amazon/search` | `q`, `domain`, `page` |
| Airbnb Listings | `/scrape/airbnb/search` | `location`, `checkIn`, `checkOut`, `adults` |
| Airbnb Property | `/scrape/airbnb/listing` | `listingId`, `checkIn`, `checkOut` |
| Booking.com Search | `/scrape/booking/search` | `location`, `checkIn`, `checkOut`, `adults` |
| Google Hotels (Booking) | `/scrape/booking/place` | `placeId` |
| Glassdoor Jobs | `/scrape/glassdoor/jobs` | `q`, `location` |
| Indeed Jobs | `/scrape/indeed/jobs` | `q`, `location`, `start` |
| Instagram Profile | `/scrape/instagram/profile` | `username` |
| YouTube Search | `/scrape/youtube/search` | `q` |
| YouTube Video | `/scrape/youtube/video` | `videoId` |
| YouTube Channel | `/scrape/youtube/channel` | `channelId` |
| YouTube Transcript | `/scrape/youtube/transcript` | `videoId`, `lang` |
| Redfin Listings | `/scrape/redfin/search` | `location` |
| Redfin Property | `/scrape/redfin/property` | `url` |
| Zillow Listings | `/scrape/zillow/search` | `location` |
| Zillow Property | `/scrape/zillow/property` | `zpid` |
| Shopify Products | `/scrape/shopify/products` | `domain` |
| Yelp Search | `/scrape/yelp/search` | `q`, `location` |
| Yelp Place | `/scrape/yelp/place` | `alias` |
| Yellow Pages Search | `/scrape/yellowpages/search` | `q`, `location` |
| Yellow Pages Place | `/scrape/yellowpages/place` | `url` |
| Web Scraper | `/scrape/web` | `url`, `extractRules`, `screenshot` |

---

## Adding a new data source

Follow the YAML format above. Add to `~/.anton/datasources.md` (user overrides).
Expand Down
15 changes: 15 additions & 0 deletions anton/core/interaction/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Mid-turn human interaction primitives for the agent loop.

Currently this package hosts file/folder disambiguation (the ``select_path``
tool). The agent depends only on the abstract :class:`SelectionElicitor`
strategy; each host (CLI, cowork-server harness, …) supplies a concrete
implementation, so the core never learns how the prompt is surfaced.
"""

from anton.core.interaction.selection import (
SelectionElicitor,
SelectionOption,
SelectionRequest,
)

__all__ = ["SelectionElicitor", "SelectionOption", "SelectionRequest"]
53 changes: 53 additions & 0 deletions anton/core/interaction/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Terminal implementation of :class:`SelectionElicitor` for standalone CLI runs.

Renders a numbered picker to the console and reads the user's choice. The agent
loop already pauses the escape watcher around interactive tools, so this only
has to print the options and await a number.
"""

from __future__ import annotations

from anton.core.interaction.selection import SelectionRequest

__all__ = ["CLISelectionElicitor"]


class CLISelectionElicitor:
"""Picker that prompts on the terminal (standalone ``anton`` chat)."""

def __init__(self, console) -> None:
self._console = console

async def elicit(self, request: SelectionRequest) -> str | None:
from anton.utils.prompt import prompt_or_cancel

# Browse mode has no visual file tree on a terminal — fall back to a
# typed path (the GUI host gets a real navigable browser instead).
if request.mode == "browse":
self._console.print(f"\n[bold]{request.prompt}[/]")
if request.root:
self._console.print(f" [dim]starting at {request.root}[/]")
chosen = await prompt_or_cancel("Enter a path (Esc to cancel)")
return (chosen or "").strip() or None

options = request.options
if not options:
return None

self._console.print(f"\n[bold]{request.prompt}[/]")
for index, option in enumerate(options, start=1):
icon = "📁" if option.kind == "folder" else "📄"
detail = f" [dim]{option.detail}[/]" if option.detail else ""
self._console.print(f" [bold]{index}[/]. {icon} {option.label}{detail}")

choice = await prompt_or_cancel(
"Select a number (Esc to cancel)",
choices=[str(i) for i in range(1, len(options) + 1)],
)
if choice is None:
return None
try:
selected = int(choice) - 1
except ValueError:
return None
return options[selected].value if 0 <= selected < len(options) else None
Loading
Loading