Metadata-Version: 2.5
Name: hci-atrium
Version: 0.8.0
Summary: Python SDK for the Atrium framework — multimodal asset library, semantic + cross-modal search, and capability routing.
Author: Anders Petruschke
License: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2
Requires-Dist: websockets>=13
Provides-Extra: test
Requires-Dist: pytest; extra == 'test'
Requires-Dist: pytest-asyncio; extra == 'test'
Description-Content-Type: text/markdown

# hci-atrium

The Python SDK for the Atrium framework — a multimodal asset library with
semantic + cross-modal search and capability routing.

> **Status:** built, in active internal use — auth, library search, external-source search/import,
> embeddings, chat/streaming/tools, sessions/rooms, surveys, dashboards, and the CLI are all in
> place. The API may still evolve pre-1.0.

See the bundled `GUIDE.md` for a complete, task-oriented reference with runnable examples.

## Install

```sh
uv add hci-atrium     # in a uv project
```

Or `pip install hci-atrium`.

### For coding agents

`atrium guide` prints the complete, task-oriented SDK guide — the primary way to get the real API
into an agent's context. Tell the agent to run it in your **first message** ("run `atrium guide`
and read it before writing any Atrium code"), or pipe it in yourself
(`uv run atrium guide > atrium-guide.md`). The deployed instance serves the same docs at
`/llms.txt` for agents that browse.

Say it explicitly each session — agents don't reliably read a standing instruction file on their
own. As a backstop, add these lines to your project's `CLAUDE.md` / `AGENTS.md`:

```markdown
This project uses the hci-atrium SDK. Before writing Atrium code, run
`atrium guide` and follow it — it is the authoritative API reference
(`uv run atrium guide` in Python projects, `pnpm exec atrium guide` in
TypeScript projects).
```

The npm package ships the same guide behind `atrium guide` too, so the snippet works in either
ecosystem.

## Use

Zero-config — the first call opens a browser to sign in, then later runs are silent:

```python
from hci_atrium import Atrium

client = Atrium()                       # defaults to the institute instance

for hit in client.library.search("calm rainy afternoon", modality="audio"):
    print(round(hit.score, 3), hit.asset.title or hit.asset.filename)
    print("   ", hit.asset.content_url)
```

### Configuration

| What | Argument | Environment | Default |
|------|----------|-------------|---------|
| Instance URL | `Atrium(base_url=…)` | `ATRIUM_BASE_URL` | the deployed instance |
| Credential | `Atrium(api_key="atr_…")` | `ATRIUM_API_KEY` | cached OAuth → browser sign-in |

An API key skips the browser entirely (CI / headless). Otherwise OAuth tokens are cached in
`~/.atrium/credentials.json` (mode `0600`) and refreshed silently. A project-scoped key is a
*service* credential with no user behind it, so `client.me()` (and `atrium whoami`) has no person
to report; the account-wide keys on the app's **Account API keys** page act as your user and do.

### Projects & collections

Scope a client to a project or collection — either a reusable view or a per-call argument:

```python
photos = client.collection("c_7Bq3Xk")   # a scoped view; every call below is scoped
photos.library.search("rain")
photos.assets.list()

client.library.search("rain", project="p_4mD2qX")   # or per-call
client = Atrium(project="p_4mD2qX")                  # or a default for the whole client
```

A project-scoped API key (`atr_…` bound to one project) scopes everything automatically, so leave
`project=` off entirely with one: naming a `p_…` short id makes the SDK resolve it through a
projects read that a scoped key is forbidden from (403). Pass the project's UUID if you must name
it. Only a UUID or a `p_…` short id is a project reference; a bare name like `"my-study"` never is.

Collections (`c_…`), assets (`a_…`) and sessions (`s_…`) carry the same kind of short id — read one
off the object (`asset.short_id`, `session.short_id`) and pass it anywhere its UUID goes. Only the
*project* short id costs the lookup above.

### Direct models

Register a model **name** that lives at an OpenAI-compatible endpoint (a local Ollama / LM Studio,
or a remote server). Chat/agent calls naming it are sent **directly** to that endpoint, bypassing
Atrium — while everything else (embeddings, library search, sessions, rooms) still goes to Atrium.
The mental model is *local brain, hosted context*: the agent reasons on a local model, but its
`library.search` tool still calls Atrium.

```python
client = Atrium()
client.add_direct_model("gemma3", base_url="http://localhost:11434/v1")   # → local Ollama

client.chat("Write a haiku about rain.", model="gemma3")   # served locally, bypasses Atrium
client.chat("Write a haiku about rain.", model="gpt-oss")  # unregistered → goes to Atrium

# Everything non-LLM still hits Atrium — embeddings never route to a direct endpoint (a stored
# vector's model is a data contract with the library's vector space), even on a name collision.
client.embed("rain on a tin roof", model="clap-fusion")
```

| Method | Purpose |
|--------|---------|
| `Atrium(direct_models={"gemma3": {"base_url": …, "api_key": None}})` | register at construction |
| `add_direct_model(name, *, base_url, api_key=None, timeout=None)` | register one name |
| `add_direct_models(names, *, base_url, api_key=None, timeout=None)` | register a list sharing one endpoint |
| `remove_direct_model(name)` | stop routing it directly (calls fall back to Atrium) |
| `list_direct_models()` | audit — `{name: DirectEndpoint}` |
| `discover_models(base_url, *, api_key=None)` | read-only `GET /models`; **never** registers |

- **Explicit per name.** Registration is never auto-enumerated — a registered name intentionally
  *shadows* an Atrium model of the same name. Use `discover_models` to see an endpoint's names, then
  register the ones you want.
- **Per-endpoint auth.** `api_key` rides the endpoint, shared by every name on it; many local
  servers need none. The Atrium token is never sent to a direct endpoint.
- **Timeout.** A direct endpoint defaults to the client's `timeout=`; raise it per endpoint with
  `add_direct_model(…, timeout=120)` (or a `"timeout"` in the construction dict) — local models can
  be slow to return a non-streamed completion.
- **No silent fallback.** An unreachable direct endpoint raises (a connection error / `APIError`) —
  it never quietly falls back to Atrium, which would mask a misconfigured endpoint.
- **Tool-calling is the endpoint's job.** `agent(model=<direct>)` works only if the endpoint
  implements OpenAI function-calling; one that doesn't just never emits `tool_calls`.

`AsyncAtrium` has the same surface (`discover_models` is `async`).

### Playback & capabilities

Every `Asset` exposes three read-only views that discriminate on **capability**, not on a
`kind` string — bytes and link capabilities are independent flags, and an asset can hold both:

```python
asset = client.assets.get("a_123")

asset.capabilities        # {"bytes"} / {"link"} / {"bytes", "link"} / set()
asset.link                # AssetLink(provider, entity, uri) — present iff "link" ⊆ capabilities
asset.playback.as_signal()  # {"source": ..., "ref": ..., "mode": ...} — always present
```

`asset.playback` normalizes the asset into a play intent, preferring `bytes` when both
capabilities hold; it mirrors the room `play` signal's `{source, ref, mode?}` shape 1:1, so a
host can forward it as-is. A recognized streaming provider link (currently Spotify) resolves to
its own `source` (`"spotify"`) and a `provider:entity:id` `ref`; an unrecognized link falls back
to `source="link"` with the raw URL — old SDKs degrade gracefully in front of a provider they
don't know about yet.

### Home Assistant bridge

`hci_atrium.ha.HomeAssistantBridge` runs near your Home Assistant instance, joins a room by
connect code, and declaratively wires room signals to HA services (and HA state back into the
room). `websockets` (its HA leg) rides along as a core dependency — nothing extra to install:

```python
from hci_atrium import async_join_room
from hci_atrium.ha import HomeAssistantBridge

room = await async_join_room(code)
bridge = (
    HomeAssistantBridge(ha_url, ha_token)
    .media_sink("media_player.living_room")  # play `play` intents; also forwards their state
    .on_signal(                               # any other signal -> any other HA service
        "plate.pulse", domain="number", service="set_value",
        entity_id="number.plate_intensity", data=lambda p: {"value": p["strength"]},
    )
)
await bridge.run(room)
```

`media_sink(...)` forwards those entities' state back as `media.state` by default
(`forward_state=False` opts out); call `forward_state(...)` yourself for entities outside the
media sink, or to override the `as_type`/`debounce`. `atrium ha-bridge` wraps the
zero-code standard case (media sink, state-forwarding included) as a CLI — see below.

## CLI

Installing the package puts an `atrium` command on your `PATH` (or use `python -m hci_atrium`):

```sh
atrium login                       # sign in (opens a browser)
atrium whoami
atrium search "calm rainy afternoon" --modality audio
atrium assets --modality audio --limit 10
atrium enrich "rain on a tin roof" audio --preview
atrium chat "Write a haiku about rain" --model rwth/gpt-oss-120b
atrium ha-bridge --code ABC123 --ha-url ws://homeassistant.local:8123/api/websocket \
    --ha-token *** --media-player media_player.living_room
atrium logout                      # forget the cached session — sign in again next time
```

### Other runtimes

Because the server is OpenAI-API-compatible, point any OpenAI-compatible client at this instance:

```python
client.base_url   # the OpenAI-compatible endpoint
client.token()    # a live, auto-refreshed bearer token
```
