Add sandbox authoring effects and character/composite creation, improve creator submission handling, and extend publishing/search metadata. Update importer conversions and the asset UI integration. Validation: 31 store search tests, ten composite tests and two author-policy tests passed in release mode.
8.7 KiB
AI backends: one broker, six providers, one lock
The asset server's chat broker (libs/asset/chat + libs/asset/store/src/host/chat.rs)
is the ONLY place a model is reached from. Every app — the sandbox game, the
VJ, the Asset UI — is a thin client of /v1/chat/*: it opens a session on a
named provider, sends text, and renders the event stream. No app holds a
key, a fleet address, or a CLI.
Providers (GET /v1/chat/providers)
| slug | what runs | locality | tools contract |
|---|---|---|---|
fleet-qwen |
Qwen on our asset-ai fleet (LAN beacons) | local |
<<tool>> text markers |
openai |
OpenAI Responses API (OPENAI_API_KEY on the server) |
cloud |
native functions |
grok |
xAI Responses API (XAI_API_KEY on the server) |
cloud |
native functions |
claude-cli |
the claude CLI logged in on the server host |
cloud |
<<tool>> text markers |
codex-cli |
the codex CLI on the server host |
cloud |
<<tool>> text markers |
grok-cli |
the grok CLI on the server host |
cloud |
<<tool>> text markers |
Each row is {kind, locality, state: available{model} | unavailable{reason}}.
locality is the server's word: local means our own fleet, cloud means a
vendor — by key or by a logged-in CLI, the client does not care which. A
frontend that promises "Local AI only" filters on that field and nothing
else, so adding a provider never needs a client release to stay honest.
The CLI providers (cli.rs, claude.rs, codex_cli.rs, grok_cli.rs)
- One process per turn, in its own process group, cwd = an empty scratch
dir under the temp dir; cancel kills the group. Conversation continuity
is the CLI's own session id (
--resume/codex exec resume), so only the new tail of the history is sent on later turns. - Chat-only by construction: claude
--tools ""+ empty strict MCP set; grok--disallowed-tools <all it advertises>+--permission-mode dontAsk--max-turns 1; codex--sandbox read-only, user config and rules ignored,shell_environment_policy.inherit=none. The only tools any of them can express are the broker's content tools, executed by the dispatcher against the asset server (same as the fleet lane).
- Reasoning is forwarded inside
<think>…</think>text like the fleet lane, so every client renders it one way and none of it enters the history. - Availability = "is the executable there" (
CLAUDE_CODE_PATH/CODEX_CLI_PATH/GROK_CLI_PATHoverride, then$PATH, then the usual install dirs). The reason string never carries a path to the wire. - Verified live (2026-08-26, macOS): claude 2.1.246 needs the prompt on
stdin (a trailing positional after
--tools ""is swallowed as a tool name); grok 1.0.5 ignores--tools ""/nonebut honours--disallowed-tools; codex 0.149.1exec --jsonemits wholeagent_messageitems, no deltas.
Fleet discovery (fleet_discovery.rs)
The fleet lane listens for asset-ai beacons on UDP 41830 and keeps only the
fleet named by the server config, else MAKEPAD_AI_FLEET, else default.
The embedded server inside the Asset UI pins this to the fleet the UI's own
panel shows (gen); an empty name used to force default and silently
drop every beacon ("no fleet nodes configured" while the panel showed 2/2
up). The listener starts with the broker and a probe in its first seconds
waits for the first beacon instead of reporting an empty fleet.
Durable per-game conversations (keyed sessions)
The server keeps the chat STATE for its clients. A session created with
both client_key and context_key is one durable conversation per
(principal, client_key, context_key):
client_key= who is talking — an opaque, display-safe id the app chooses (the sandbox sendsip:<lan-ip>today; a multiplayer player id later).context_key= what it is about — the GAME asset id. One conversation per (client, game), never shared across games: a game's context is context we cannot pollute.- Key shape (enforced identically by the client crate,
wire::chat_key_ok, and the route): 1..=64 bytes of[A-Za-z0-9._:@-]with at least one alnum. Never a secret — the bearer token is the authorization.
Wire
POST /v1/chat/sessionsbody gains the optionalclient_keyandcontext_key(both or neither; one alone is a 400). With both it is CREATE-OR-RESUME: a LIVE session for that key answers200with itself (samesessionid, state as it is); a PERSISTED one is rebuilt from its transcript under the SAME id on a fresh provider and answers200; nothing known answers201with a fresh session. Without keys: today's behaviour (201, ephemeral). A live keyed session asked for on a different provider/namespace/profile is rebuilt on the requested one while idle (its transcript kept), and is409 busymid-turn.- The session document (
POST/GET/list/cancel) carriesclient_keyandcontext_keyon keyed sessions, omitted otherwise. GET /v1/chat/sessions/{id}/transcript→{"session":…, "provider":"<slug>", "turn":N, "truncated":bool, "messages":[{"role":"user|assistant|system|tool","text":"…"}]}— the conversation as the client should render it: what the session feeds its provider, minus the prompt plumbing (thinking stripped, the tool reminder and trained call text folded away); each executed tool is onetoolrow whosetextis a short chip title (world.set_source · ok) withtool(dotted name) andoutcome(ok|unavailable|denied|refused|failed) alongside. Bounded: the LAST 128 rows within 192 KiB of text, each row's text clipped to 8 KiB;truncatedsays older rows were dropped. Works for unkeyed sessions too (memory only).404for a session that is not live — a client always create-or-resumes first, then reads.DELETE /v1/chat/sessions/{id}on a keyed session is the client's Clear: the worker AND the persisted transcript go, synchronously; the next create-or-resume is a fresh conversation (new id).- Retiring an asset (
DELETE /v1/assets/{id}) drops every keyed conversation whosecontext_keyis that asset — live or on disk, whoever's — and logs the count.
Persistence (libs/asset/store/src/host/chat_store.rs)
<root>/chat/<principal>/<client_key>/<context_key>.jsonl, keys path-encoded ([A-Za-z0-9._-]verbatim, else%XX:ip:10.0.0.7isip%3A10.0.0.7). Header line{"k":"h","v":1,"session","provider", "namespace","profile","client_key","context_key","created_ms"}, then one{"k":"m","role","text","turn"}per history row exactly as the provider sees it (the transcript is rendered on read), and an optional{"k":"p","resume":…}slot for a provider-native resume id (unused today: the threaded provider wrapper does not expose one; a resumed session simply replays its history to a fresh provider, which every lane — CLIs included — accepts).- The session worker appends the new tail after every publish
(
write_all+sync_data); a crash costs at most one torn last line, whichloaddrops. A resume rewrites the file (temp + rename) with the current binding and the history trimmed to the newest 64 rows, so a resumed conversation always has room to continue. - Workers are bounded: a keyed session idle 30 min is evicted to disk; when an owner's cap (or the server's) is full of idle keyed sessions, the longest-idle one is evicted to make room. Unkeyed sessions are never evicted (nowhere to go) and refuse as before.
- One writer per file (the worker) and Clear wipes under the same lock, so a late append can never resurrect a cleared transcript.
world.new_level (game profile)
Advertised next to world.set_source, client-executed: {"title", "source", "note"?} — "create a NEW game from this source and switch the
player to it". The game publishes, switches, and answers the tool result
{asset_id, alias, title}; the broker records the round and ends the
turn with done (no further model round — the player is in another game,
which has its own conversation). world.set_source stays the in-place
edit of the CURRENT game.
The sandbox (apps/sandbox)
fleet_chat.rsholdsChatPrefs { provider, local_only }, persisted inlocal/sandbox/ai-prefs, default{fleet-qwen, true}. The lock is enforced in the worker, not just hidden in the UI: selecting acloudprovider while locked is refused with a chat line; turning the lock on while on a cloud provider drops back to the fleet.- Settings = one dropdown of what the server listed (cloud rows removed while locked; unavailable rows shown as such) + the "Local AI only" checkbox. No keys, no pairing, no device-local backends.