Spaces:
Running
Running
deploy(S4): Blender headless pipeline + WebAR client + backend fixes
Browse files- backend/main.py +10 -1
- backend/provisioning/space_provisioner.py +133 -1
- backend/routes/internet_routes.py +10 -2
- backend/routes/mobile_bridge_routes.py +34 -6
- backend/routes/model_proxy_routes.py +8 -2
- backend/tools/media_viewer_tools.py +198 -0
- backend/tools/tool_registry.py +65 -49
- backend/ws/agent_ws.py +52 -0
- dist/app-release.apk +2 -2
- modules/assistant_identity.py +42 -0
backend/main.py
CHANGED
|
@@ -173,7 +173,16 @@ app.include_router(agent_router, prefix="/agent", dependencies=[Depend
|
|
| 173 |
app.include_router(memory_router, prefix="/memory", dependencies=[Depends(verify_token)])
|
| 174 |
app.include_router(voice_router, prefix="/voice", dependencies=[Depends(verify_token)])
|
| 175 |
app.include_router(system_router, prefix="/system", dependencies=[Depends(verify_token)])
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
app.include_router(security_router, prefix="/security", dependencies=[Depends(verify_token)])
|
| 178 |
app.include_router(github_router, prefix="/github", dependencies=[Depends(verify_token)])
|
| 179 |
app.include_router(xr_router, prefix="/xr", dependencies=[Depends(verify_token)])
|
|
|
|
| 173 |
app.include_router(memory_router, prefix="/memory", dependencies=[Depends(verify_token)])
|
| 174 |
app.include_router(voice_router, prefix="/voice", dependencies=[Depends(verify_token)])
|
| 175 |
app.include_router(system_router, prefix="/system", dependencies=[Depends(verify_token)])
|
| 176 |
+
# v22: AUTOMATION is a paid feature in the plan catalog (plus/pro only β see
|
| 177 |
+
# billing/plans.py), but no automation route enforced it: a caller on the `free` plan got
|
| 178 |
+
# 200 + real scheduler data from /automation/list and /automation/history. The catalog
|
| 179 |
+
# declared the boundary and nothing collected on it. Gated at the mount so all ten routes
|
| 180 |
+
# are covered, rather than per-route where the next new route would silently miss it.
|
| 181 |
+
from backend.billing.gating import require_feature as _require_feature
|
| 182 |
+
from backend.billing.plans import Feature as _Feature
|
| 183 |
+
app.include_router(automation_router, prefix="/automation",
|
| 184 |
+
dependencies=[Depends(verify_token),
|
| 185 |
+
Depends(_require_feature(_Feature.AUTOMATION))])
|
| 186 |
app.include_router(security_router, prefix="/security", dependencies=[Depends(verify_token)])
|
| 187 |
app.include_router(github_router, prefix="/github", dependencies=[Depends(verify_token)])
|
| 188 |
app.include_router(xr_router, prefix="/xr", dependencies=[Depends(verify_token)])
|
backend/provisioning/space_provisioner.py
CHANGED
|
@@ -58,13 +58,43 @@ log = logging.getLogger(__name__)
|
|
| 58 |
# The purposes a subscriber's own Space needs its own value for. One namespaced scheme,
|
| 59 |
# per credentials-and-secrets.md:43 β adding a purpose here is the ONLY thing needed to
|
| 60 |
# give every subscriber their own isolated value for it.
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"JARVIS_CLOUD_TOKEN", # the subscriber's own API/WS bearer for their Space
|
| 63 |
"VAULT_MASTER_PASSWORD", # derives that Space's vault key; never shared
|
| 64 |
"PBKDF2_SALT", # per-subscriber salt, so no two vaults share a derivation
|
| 65 |
"WEBHOOK_SIGNING_SECRET", # payment-webhook HMAC, scoped to this subscriber
|
| 66 |
)
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
OWNER_USER_ID = "owner"
|
| 69 |
|
| 70 |
|
|
@@ -115,6 +145,83 @@ def subscriber_secrets(user_id: str, version: int = 1) -> dict[str, str]:
|
|
| 115 |
return {p: derive_secret(user_id, p, version) for p in SUBSCRIBER_SECRET_PURPOSES}
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
def space_id_for(user_id: str, owner_namespace: str) -> str:
|
| 119 |
"""Stable, collision-free Space id for a subscriber.
|
| 120 |
|
|
@@ -179,6 +286,11 @@ class ProvisionResult:
|
|
| 179 |
dry_run: bool = True
|
| 180 |
skipped_reason: str | None = None
|
| 181 |
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
def provision_subscriber_space(
|
|
@@ -212,6 +324,26 @@ def provision_subscriber_space(
|
|
| 212 |
values = subscriber_secrets(user_id, version)
|
| 213 |
result.secrets_set = sorted(values)
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
if dry_run:
|
| 216 |
return result
|
| 217 |
|
|
|
|
| 58 |
# The purposes a subscriber's own Space needs its own value for. One namespaced scheme,
|
| 59 |
# per credentials-and-secrets.md:43 β adding a purpose here is the ONLY thing needed to
|
| 60 |
# give every subscriber their own isolated value for it.
|
| 61 |
+
#
|
| 62 |
+
# v22-2: these are the SYSTEM-DERIVED secrets. Every one is a value this system invents
|
| 63 |
+
# for itself, so HMAC derivation from a root secret works: the value has no meaning to
|
| 64 |
+
# any third party and nobody outside has to agree to it.
|
| 65 |
+
DERIVED_SECRET_PURPOSES: tuple[str, ...] = (
|
| 66 |
"JARVIS_CLOUD_TOKEN", # the subscriber's own API/WS bearer for their Space
|
| 67 |
"VAULT_MASTER_PASSWORD", # derives that Space's vault key; never shared
|
| 68 |
"PBKDF2_SALT", # per-subscriber salt, so no two vaults share a derivation
|
| 69 |
"WEBHOOK_SIGNING_SECRET", # payment-webhook HMAC, scoped to this subscriber
|
| 70 |
)
|
| 71 |
|
| 72 |
+
# v22-2: OWNER-SUPPLIED per-subscriber credentials β a genuinely different provisioning
|
| 73 |
+
# pattern the derivation scheme above cannot express, and previously had no place at all.
|
| 74 |
+
#
|
| 75 |
+
# The distinction that matters: a derived secret is one WE choose, so we can compute it.
|
| 76 |
+
# An owner-supplied secret is issued by a THIRD PARTY and the third party must agree it
|
| 77 |
+
# is valid. No amount of HMAC produces a Sketchfab key that Sketchfab will honour. These
|
| 78 |
+
# can therefore only ever be STORED AND LOOKED UP, never computed β which means
|
| 79 |
+
# provisioning has to be able to *block* on one being absent instead of silently
|
| 80 |
+
# producing a Space that 401s the first time a subscriber opens the model browser.
|
| 81 |
+
#
|
| 82 |
+
# Each entry: purpose -> (human description, where the owner obtains it).
|
| 83 |
+
OWNER_SUPPLIED_PURPOSES: dict[str, tuple[str, str]] = {
|
| 84 |
+
"SKETCHFAB_API_KEY": (
|
| 85 |
+
"That subscriber's own Sketchfab API token, used for model search/download in "
|
| 86 |
+
"the AR connector drawer.",
|
| 87 |
+
"The owner creates a separate Sketchfab account/token per subscriber at "
|
| 88 |
+
"https://sketchfab.com/settings/password and records it against that user id.",
|
| 89 |
+
),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Backwards-compatible alias. Everything that existed before v22-2 (verify_no_collisions,
|
| 93 |
+
# rotate_only_this_user, the Part 29 collision proof) means the derived set specifically,
|
| 94 |
+
# so this name keeps pointing at exactly what it always pointed at rather than silently
|
| 95 |
+
# growing to include credentials that cannot be derived.
|
| 96 |
+
SUBSCRIBER_SECRET_PURPOSES: tuple[str, ...] = DERIVED_SECRET_PURPOSES
|
| 97 |
+
|
| 98 |
OWNER_USER_ID = "owner"
|
| 99 |
|
| 100 |
|
|
|
|
| 145 |
return {p: derive_secret(user_id, p, version) for p in SUBSCRIBER_SECRET_PURPOSES}
|
| 146 |
|
| 147 |
|
| 148 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 149 |
+
# v22-2 β owner-supplied per-subscriber credentials
|
| 150 |
+
#
|
| 151 |
+
# These route through the SAME vault the rest of the system uses, namespaced per
|
| 152 |
+
# (user, purpose), rather than a new bespoke store. credentials-and-secrets.md:43 is
|
| 153 |
+
# explicit about this: "resist the pull to bolt on a bespoke storage/rotation mechanism
|
| 154 |
+
# for each new kind of secret β extend the existing vault/credential-management path
|
| 155 |
+
# with a clean per-user namespace." A parallel store for one connector is exactly the
|
| 156 |
+
# unauditable-mess pattern that rule exists to prevent.
|
| 157 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 158 |
+
|
| 159 |
+
def owner_supplied_vault_key(user_id: str, purpose: str) -> str:
|
| 160 |
+
"""The vault key one subscriber's owner-supplied credential is stored under.
|
| 161 |
+
|
| 162 |
+
Namespaced by both user and purpose so two subscribers' Sketchfab keys can never
|
| 163 |
+
alias to each other, per the no-collisions requirement that governs the derived
|
| 164 |
+
secrets too.
|
| 165 |
+
"""
|
| 166 |
+
if purpose not in OWNER_SUPPLIED_PURPOSES:
|
| 167 |
+
raise ValueError(f"{purpose!r} is not an owner-supplied purpose")
|
| 168 |
+
if not user_id or not user_id.strip():
|
| 169 |
+
raise ValueError("user_id is required")
|
| 170 |
+
return f"SUBSCRIBER::{user_id.strip()}::{purpose}"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def set_owner_supplied_secret(user_id: str, purpose: str, value: str) -> None:
|
| 174 |
+
"""Record the key the owner obtained for this specific subscriber."""
|
| 175 |
+
if not value or not value.strip():
|
| 176 |
+
raise ValueError("refusing to store an empty credential")
|
| 177 |
+
from backend.services.usb_vault import set_secret
|
| 178 |
+
set_secret(owner_supplied_vault_key(user_id, purpose), value.strip())
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def get_owner_supplied_secret(user_id: str, purpose: str) -> str | None:
|
| 182 |
+
from backend.services.usb_vault import get_secret
|
| 183 |
+
try:
|
| 184 |
+
v = get_secret(owner_supplied_vault_key(user_id, purpose))
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
log.warning("vault read failed for %s/%s: %s", user_id, purpose, exc)
|
| 187 |
+
return None
|
| 188 |
+
return v.strip() if v and v.strip() else None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def missing_owner_supplied(user_id: str) -> list[str]:
|
| 192 |
+
"""Which owner-supplied credentials this subscriber does NOT yet have.
|
| 193 |
+
|
| 194 |
+
This is the list provisioning must refuse to proceed past, because none of them can
|
| 195 |
+
be generated β only the owner can obtain them.
|
| 196 |
+
"""
|
| 197 |
+
return [p for p in OWNER_SUPPLIED_PURPOSES if not get_owner_supplied_secret(user_id, p)]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def all_subscriber_credentials(user_id: str, version: int = 1) -> dict[str, Any]:
|
| 201 |
+
"""The complete credential picture for one subscriber: derived AND owner-supplied.
|
| 202 |
+
|
| 203 |
+
Deliberately reports the two categories separately, and names what is missing, rather
|
| 204 |
+
than merging them into one dict where an absent owner-supplied key would look
|
| 205 |
+
identical to a present one.
|
| 206 |
+
"""
|
| 207 |
+
derived = subscriber_secrets(user_id, version)
|
| 208 |
+
supplied = {p: get_owner_supplied_secret(user_id, p) for p in OWNER_SUPPLIED_PURPOSES}
|
| 209 |
+
missing = [p for p, v in supplied.items() if not v]
|
| 210 |
+
return {
|
| 211 |
+
"user_id": user_id,
|
| 212 |
+
"derived": derived,
|
| 213 |
+
"owner_supplied_present": sorted(p for p, v in supplied.items() if v),
|
| 214 |
+
"owner_supplied_missing": missing,
|
| 215 |
+
"complete": not missing,
|
| 216 |
+
"action_required_by_owner": [
|
| 217 |
+
{"purpose": p,
|
| 218 |
+
"what": OWNER_SUPPLIED_PURPOSES[p][0],
|
| 219 |
+
"how": OWNER_SUPPLIED_PURPOSES[p][1]}
|
| 220 |
+
for p in missing
|
| 221 |
+
],
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
def space_id_for(user_id: str, owner_namespace: str) -> str:
|
| 226 |
"""Stable, collision-free Space id for a subscriber.
|
| 227 |
|
|
|
|
| 286 |
dry_run: bool = True
|
| 287 |
skipped_reason: str | None = None
|
| 288 |
errors: list[str] = field(default_factory=list)
|
| 289 |
+
# v22-2: owner-supplied credentials this subscriber is still missing. Non-empty means
|
| 290 |
+
# a human step is outstanding β the Space can be created, but the connectors those
|
| 291 |
+
# keys serve will not work until the owner supplies them.
|
| 292 |
+
owner_supplied_missing: list[str] = field(default_factory=list)
|
| 293 |
+
owner_action_required: list[dict[str, str]] = field(default_factory=list)
|
| 294 |
|
| 295 |
|
| 296 |
def provision_subscriber_space(
|
|
|
|
| 324 |
values = subscriber_secrets(user_id, version)
|
| 325 |
result.secrets_set = sorted(values)
|
| 326 |
|
| 327 |
+
# v22-2: report owner-supplied credentials BEFORE the dry-run return, so a dry run β
|
| 328 |
+
# which is how this path is normally exercised β surfaces the outstanding human step
|
| 329 |
+
# rather than reporting a clean provision that would be incomplete in reality.
|
| 330 |
+
credentials = all_subscriber_credentials(user_id, version)
|
| 331 |
+
result.owner_supplied_missing = credentials["owner_supplied_missing"]
|
| 332 |
+
result.owner_action_required = credentials["action_required_by_owner"]
|
| 333 |
+
for purpose in result.owner_supplied_missing:
|
| 334 |
+
what, how = OWNER_SUPPLIED_PURPOSES[purpose]
|
| 335 |
+
log.warning(
|
| 336 |
+
"subscriber %s has no %s. It cannot be derived β %s Until then the features "
|
| 337 |
+
"it serves will fail for this subscriber.", user_id, purpose, how,
|
| 338 |
+
)
|
| 339 |
+
# Owner-supplied values are injected alongside the derived ones where present, so a
|
| 340 |
+
# subscriber who HAS been given a key gets it in their own Space's secrets.
|
| 341 |
+
for purpose in OWNER_SUPPLIED_PURPOSES:
|
| 342 |
+
supplied = get_owner_supplied_secret(user_id, purpose)
|
| 343 |
+
if supplied:
|
| 344 |
+
values[purpose] = supplied
|
| 345 |
+
result.secrets_set = sorted(values)
|
| 346 |
+
|
| 347 |
if dry_run:
|
| 348 |
return result
|
| 349 |
|
backend/routes/internet_routes.py
CHANGED
|
@@ -2,12 +2,20 @@
|
|
| 2 |
backend/routes/internet_routes.py
|
| 3 |
Β§2.4 β Internet Layer: live OSINT, web search, URL fetch, scrape
|
| 4 |
"""
|
| 5 |
-
from fastapi import HTTPException, APIRouter
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from typing import Optional
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
router = APIRouter()
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
class SearchRequest(BaseModel):
|
| 13 |
query: str
|
|
@@ -53,7 +61,7 @@ async def fetch_url(req: FetchRequest):
|
|
| 53 |
|
| 54 |
|
| 55 |
@router.post("/osint")
|
| 56 |
-
async def osint_recon(req: OsintRequest):
|
| 57 |
"""
|
| 58 |
Elite Defensive OSINT recon endpoint.
|
| 59 |
Performs web reconnaissance, digital footprint analysis,
|
|
|
|
| 2 |
backend/routes/internet_routes.py
|
| 3 |
Β§2.4 β Internet Layer: live OSINT, web search, URL fetch, scrape
|
| 4 |
"""
|
| 5 |
+
from fastapi import HTTPException, APIRouter, Depends
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from typing import Optional
|
| 8 |
|
| 9 |
+
from backend.billing.gating import require_feature
|
| 10 |
+
from backend.billing.plans import Feature
|
| 11 |
+
|
| 12 |
router = APIRouter()
|
| 13 |
|
| 14 |
+
# v22: OSINT is a paid feature in the plan catalog (plus/pro), WEB_SEARCH is not (free
|
| 15 |
+
# grants it). So the gate goes on /osint specifically, NOT on the router β gating the
|
| 16 |
+
# whole router would take /search away from the free tier that is entitled to it.
|
| 17 |
+
_OSINT_GATE = Depends(require_feature(Feature.OSINT))
|
| 18 |
+
|
| 19 |
|
| 20 |
class SearchRequest(BaseModel):
|
| 21 |
query: str
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@router.post("/osint")
|
| 64 |
+
async def osint_recon(req: OsintRequest, _user: str = _OSINT_GATE):
|
| 65 |
"""
|
| 66 |
Elite Defensive OSINT recon endpoint.
|
| 67 |
Performs web reconnaissance, digital footprint analysis,
|
backend/routes/mobile_bridge_routes.py
CHANGED
|
@@ -212,14 +212,42 @@ async def command(request: Request) -> dict[str, Any]:
|
|
| 212 |
return {"ok": False, "error": "missing cmd"}
|
| 213 |
device_name = str(plaintext.get("device_name") or "").strip()[:80]
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
try:
|
| 216 |
from modules.max_autonomy import execute_task
|
| 217 |
-
#
|
| 218 |
-
#
|
| 219 |
-
#
|
| 220 |
-
# command through the safety-gated coordinator in record+sync mode: it is
|
| 221 |
-
# logged, risk-assessed and pushed to the AR HUD, without faking hardware
|
| 222 |
-
# control. Real desktop actions stay on the paired-PC channel.
|
| 223 |
result = execute_task(
|
| 224 |
cmd,
|
| 225 |
source="guardian_command",
|
|
|
|
| 212 |
return {"ok": False, "error": "missing cmd"}
|
| 213 |
device_name = str(plaintext.get("device_name") or "").strip()[:80]
|
| 214 |
|
| 215 |
+
# RELAY TO THE PC FIRST, if one is connected.
|
| 216 |
+
#
|
| 217 |
+
# The cloud container has no desktop of its own, which is why this used to go
|
| 218 |
+
# straight to record-only mode. But it is not the only machine involved:
|
| 219 |
+
# pc_relay_client.py runs on the user's PC, holds an outbound WebSocket to
|
| 220 |
+
# this Space, and executes `system:execute` for real. Everything needed for
|
| 221 |
+
# phone -> cloud -> PC control existed except this hop β the Space simply
|
| 222 |
+
# never forwarded, so commands sent from anywhere in the world were logged
|
| 223 |
+
# and dropped while the desktop sat connected and idle.
|
| 224 |
+
#
|
| 225 |
+
# Falls through to record-only when no PC is listening, which is the honest
|
| 226 |
+
# answer when the machine is off rather than a failure.
|
| 227 |
+
try:
|
| 228 |
+
from backend.ws.agent_ws import ws_manager as _ws_manager
|
| 229 |
+
delivered = await _ws_manager.send_to_pcs({
|
| 230 |
+
"event": "system:execute",
|
| 231 |
+
"payload": {"cmd": cmd, "source": "guardian", "device_name": device_name},
|
| 232 |
+
})
|
| 233 |
+
except Exception as exc:
|
| 234 |
+
log.warning("PC relay unavailable: %s", exc)
|
| 235 |
+
delivered = 0
|
| 236 |
+
|
| 237 |
+
if delivered:
|
| 238 |
+
return {
|
| 239 |
+
"ok": True,
|
| 240 |
+
"spoken": [f"Sent to your PC."],
|
| 241 |
+
"status": "relayed",
|
| 242 |
+
"action": "pc_relay",
|
| 243 |
+
"pc_count": delivered,
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
try:
|
| 247 |
from modules.max_autonomy import execute_task
|
| 248 |
+
# No PC connected: run through the safety-gated coordinator in
|
| 249 |
+
# record+sync mode β logged, risk-assessed and pushed to the AR HUD,
|
| 250 |
+
# without faking hardware control.
|
|
|
|
|
|
|
|
|
|
| 251 |
result = execute_task(
|
| 252 |
cmd,
|
| 253 |
source="guardian_command",
|
backend/routes/model_proxy_routes.py
CHANGED
|
@@ -134,10 +134,16 @@ async def sketchfab_search(q: str = "", count: int = 12):
|
|
| 134 |
"message": "No Sketchfab token in the OMEGA vault."}
|
| 135 |
count = max(1, min(int(count or 12), 24))
|
| 136 |
try:
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
"q": str(q or "").strip(),
|
| 139 |
"downloadable": "true",
|
| 140 |
-
"type": "models",
|
| 141 |
"count": str(count * 2), # headroom: license filter trims below
|
| 142 |
"archives_flavours": "true",
|
| 143 |
})
|
|
|
|
| 134 |
"message": "No Sketchfab token in the OMEGA vault."}
|
| 135 |
count = max(1, min(int(count or 12), 24))
|
| 136 |
try:
|
| 137 |
+
# /v3/search, NOT /v3/models. Verified live 2026-08-04: `q` is not a
|
| 138 |
+
# supported filter on /v3/models, so it was silently ignored and the
|
| 139 |
+
# endpoint returned an arbitrary page of downloadable models β every
|
| 140 |
+
# search this proxy has ever served came back topically unrelated to the
|
| 141 |
+
# query. Caught by searching "movie theater interior" and getting
|
| 142 |
+
# "MaszynkaDoMielenia" and "farmerhouse" back with ok=True.
|
| 143 |
+
payload = await _sketchfab_get("/search", token, {
|
| 144 |
+
"type": "models",
|
| 145 |
"q": str(q or "").strip(),
|
| 146 |
"downloadable": "true",
|
|
|
|
| 147 |
"count": str(count * 2), # headroom: license filter trims below
|
| 148 |
"archives_flavours": "true",
|
| 149 |
})
|
backend/tools/media_viewer_tools.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assistant tool surface for the local photo/video immersive viewer.
|
| 2 |
+
|
| 3 |
+
The viewer itself lives in the WebAR client (`ar-core/ar-media-viewer.js`), which
|
| 4 |
+
is what actually holds the decoded frame and the projection geometry. These tools
|
| 5 |
+
are the assistant's way in: each one broadcasts a `media:control` event over the
|
| 6 |
+
existing agent WebSocket, which the client picks up and applies to the live
|
| 7 |
+
session β the same mechanism `ar_control_tool` already uses for AR state, rather
|
| 8 |
+
than a second, parallel channel invented for this feature.
|
| 9 |
+
|
| 10 |
+
Honesty rules encoded here rather than left to the model's discretion, because
|
| 11 |
+
the governing brief requires them and a prompt is easier to drift from than code:
|
| 12 |
+
|
| 13 |
+
- `experimental_surround` is never selected implicitly. Asking for "360" gets the
|
| 14 |
+
real projection path; the generative mode has to be named explicitly, and the
|
| 15 |
+
return text says plainly that what it produces is invented.
|
| 16 |
+
- Spatial audio is never called "Dolby Atmos". If a caller passes that string the
|
| 17 |
+
tool corrects it in the response instead of quietly accepting the wording.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
PROJECTIONS = ("flat", "180", "360", "fisheye")
|
| 23 |
+
PACKINGS = ("mono", "sbs-full", "sbs-half", "tb-full", "tb-half")
|
| 24 |
+
ENVIRONMENTS = ("passthrough", "mixed", "theater", "inox", "imax", "pvr")
|
| 25 |
+
|
| 26 |
+
# What each environment actually changes, so the assistant can answer truthfully
|
| 27 |
+
# when asked. These are room and screen presets -- they never touch decode
|
| 28 |
+
# quality, resolution, or bitrate.
|
| 29 |
+
ENVIRONMENT_FACTS = {
|
| 30 |
+
"passthrough": "the real room stays visible; the picture is composited into it as a screen",
|
| 31 |
+
"mixed": "passthrough plus a virtual bezel and ambient light response",
|
| 32 |
+
"theater": "a standard cinema hall, 36 degrees subtended from the seat, gently curved screen",
|
| 33 |
+
"pvr": "a mainstream multiplex auditorium, 34 degrees, further back, flatter screen",
|
| 34 |
+
"inox": "a premium large-screen auditorium, 40 degrees, between Theater and IMAX in scale",
|
| 35 |
+
"imax": "large-format geometry, 70 degrees subtended, curvature radius 1.5x the screen width",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
_DOLBY_WORDS = ("dolby", "atmos")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def _dispatch(action: str, data: dict) -> None:
|
| 42 |
+
from backend.ws.agent_ws import ws_manager
|
| 43 |
+
|
| 44 |
+
await ws_manager.broadcast({
|
| 45 |
+
"event": "media:control",
|
| 46 |
+
"payload": {"action": action, "data": data},
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def media_open_tool(file: str, projection: str = "", packing: str = "",
|
| 51 |
+
environment: str = "", *args, **kwargs):
|
| 52 |
+
"""Open a local photo or video file in the immersive viewer.
|
| 53 |
+
|
| 54 |
+
`file` is a path or name on the user's own device. Nothing is uploaded.
|
| 55 |
+
`projection` (flat/180/360/fisheye) and `packing` are optional overrides --
|
| 56 |
+
left blank, the viewer auto-detects them from the file and tells the user why.
|
| 57 |
+
"""
|
| 58 |
+
if projection and projection not in PROJECTIONS:
|
| 59 |
+
return f"Unknown projection '{projection}'. Valid: {', '.join(PROJECTIONS)}."
|
| 60 |
+
if packing and packing not in PACKINGS:
|
| 61 |
+
return f"Unknown stereo packing '{packing}'. Valid: {', '.join(PACKINGS)}."
|
| 62 |
+
if environment and environment not in ENVIRONMENTS:
|
| 63 |
+
return f"Unknown environment '{environment}'. Valid: {', '.join(ENVIRONMENTS)}."
|
| 64 |
+
|
| 65 |
+
await _dispatch("open", {
|
| 66 |
+
"file": file,
|
| 67 |
+
"projection": projection or None,
|
| 68 |
+
"packing": packing or None,
|
| 69 |
+
"environment": environment or None,
|
| 70 |
+
})
|
| 71 |
+
detail = []
|
| 72 |
+
if projection:
|
| 73 |
+
detail.append(f"as {projection}")
|
| 74 |
+
if packing:
|
| 75 |
+
detail.append(f"packed {packing}")
|
| 76 |
+
if environment:
|
| 77 |
+
detail.append(f"in {environment} ({ENVIRONMENT_FACTS[environment]})")
|
| 78 |
+
suffix = (" " + ", ".join(detail)) if detail else " with format auto-detected"
|
| 79 |
+
return f"Opening {file} in the immersive viewer{suffix}."
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def media_set_environment_tool(environment: str, *args, **kwargs):
|
| 83 |
+
"""Switch the viewing mode: passthrough, mixed, theater, inox, imax or pvr."""
|
| 84 |
+
environment = (environment or "").strip().lower()
|
| 85 |
+
if environment not in ENVIRONMENTS:
|
| 86 |
+
return (f"'{environment}' is not one of the viewing modes. "
|
| 87 |
+
f"Valid: {', '.join(ENVIRONMENTS)}.")
|
| 88 |
+
await _dispatch("environment", {"environment": environment})
|
| 89 |
+
return (f"Switched to {environment} β {ENVIRONMENT_FACTS[environment]}. "
|
| 90 |
+
"This changes the room and screen only; the picture is decoded exactly the same way.")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def media_set_format_tool(projection: str = "", packing: str = "", *args, **kwargs):
|
| 94 |
+
"""Override the projection format and/or the stereo packing."""
|
| 95 |
+
out = []
|
| 96 |
+
if projection:
|
| 97 |
+
if projection not in PROJECTIONS:
|
| 98 |
+
return f"Unknown projection '{projection}'. Valid: {', '.join(PROJECTIONS)}."
|
| 99 |
+
await _dispatch("projection", {"projection": projection})
|
| 100 |
+
out.append(f"format set to {projection}")
|
| 101 |
+
if packing:
|
| 102 |
+
if packing not in PACKINGS:
|
| 103 |
+
return f"Unknown stereo packing '{packing}'. Valid: {', '.join(PACKINGS)}."
|
| 104 |
+
await _dispatch("packing", {"packing": packing})
|
| 105 |
+
out.append(f"stereo packing set to {packing}")
|
| 106 |
+
if not out:
|
| 107 |
+
return "Nothing to change β give a projection, a packing, or both."
|
| 108 |
+
return "Done: " + " and ".join(out) + "."
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def media_adjust_screen_tool(width: float = 0, distance: float = 0,
|
| 112 |
+
elevation: float = 0, curvature_radius: float = 0,
|
| 113 |
+
*args, **kwargs):
|
| 114 |
+
"""Resize, move or re-curve the virtual screen. Values are in metres."""
|
| 115 |
+
patch = {}
|
| 116 |
+
for key, value in (("width", width), ("distance", distance),
|
| 117 |
+
("elevation", elevation), ("curvatureRadius", curvature_radius)):
|
| 118 |
+
try:
|
| 119 |
+
v = float(value)
|
| 120 |
+
except (TypeError, ValueError):
|
| 121 |
+
continue
|
| 122 |
+
if v:
|
| 123 |
+
patch[key] = v
|
| 124 |
+
if not patch:
|
| 125 |
+
return "Nothing to adjust β give a width, distance, elevation or curvature radius in metres."
|
| 126 |
+
await _dispatch("adjust_screen", patch)
|
| 127 |
+
parts = ", ".join(f"{k} {v}m" for k, v in patch.items())
|
| 128 |
+
return f"Screen adjusted: {parts}. It moves live in the running session."
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def media_spatial_audio_tool(enabled: bool = True, *args, **kwargs):
|
| 132 |
+
"""Turn head-tracked spatial audio on or off.
|
| 133 |
+
|
| 134 |
+
This is real HRTF binaural rendering. It is NOT Dolby Atmos, and must never be
|
| 135 |
+
described as such -- that is a licensed technology this product does not carry.
|
| 136 |
+
"""
|
| 137 |
+
want = str(enabled).strip().lower() not in ("false", "0", "off", "no", "")
|
| 138 |
+
await _dispatch("spatial_audio", {"enabled": want})
|
| 139 |
+
if not want:
|
| 140 |
+
return "Spatial audio off β back to normal stereo output."
|
| 141 |
+
return ("Spatial audio on. This is real HRTF binaural rendering: a genuinely ambisonic "
|
| 142 |
+
"source is decoded as recorded, and an ordinary stereo track is widened. "
|
| 143 |
+
"It is not Dolby Atmos β that is a separate licensed technology this product "
|
| 144 |
+
"does not include. It also only applies to headphones, so it stays off on speakers.")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
async def media_ai_depth_tool(enabled: bool = True, *args, **kwargs):
|
| 148 |
+
"""Toggle the AI depth/stereo enhancement for flat video."""
|
| 149 |
+
want = str(enabled).strip().lower() not in ("false", "0", "off", "no", "")
|
| 150 |
+
await _dispatch("ai_depth", {"enabled": want})
|
| 151 |
+
if not want:
|
| 152 |
+
return "AI 3D depth off."
|
| 153 |
+
return ("AI 3D depth requested. This estimates per-pixel depth and synthesises a second eye "
|
| 154 |
+
"from a flat video β the small regions revealed behind foreground objects are filled "
|
| 155 |
+
"in, not captured. Note: the depth model is not installed on the Space yet, so the "
|
| 156 |
+
"control will report that rather than pretending to work.")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
async def media_experimental_surround_tool(enabled: bool = True, confirm: bool = False,
|
| 160 |
+
*args, **kwargs):
|
| 161 |
+
"""Toggle the EXPERIMENTAL generative surround mode.
|
| 162 |
+
|
| 163 |
+
Deliberately requires being named explicitly. Asking to "play this in 360" must
|
| 164 |
+
never land here -- that request is served by the real 360 projection path.
|
| 165 |
+
"""
|
| 166 |
+
want = str(enabled).strip().lower() not in ("false", "0", "off", "no", "")
|
| 167 |
+
if not want:
|
| 168 |
+
await _dispatch("experimental_surround", {"enabled": False})
|
| 169 |
+
return "Experimental AI surround off."
|
| 170 |
+
await _dispatch("experimental_surround", {"enabled": True})
|
| 171 |
+
return ("Experimental AI-generated surround requested. Be clear with the user about what this "
|
| 172 |
+
"is: it invents the roughly 300 degrees of scene the camera never captured. Most of "
|
| 173 |
+
"what they would see in this mode is generated, not filmed, and it must never be "
|
| 174 |
+
"described as a real 360 video or as 'flawless'. A persistent on-screen label saying "
|
| 175 |
+
"'Experimental: AI-generated surround β not real footage' stays visible the whole "
|
| 176 |
+
"time. Note: the generative model is not installed on the Space yet, so the control "
|
| 177 |
+
"will say so rather than pretending to work.")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def media_describe_tool(topic: str = "", *args, **kwargs):
|
| 181 |
+
"""Answer honestly about what the viewer can and cannot do."""
|
| 182 |
+
t = (topic or "").strip().lower()
|
| 183 |
+
if any(w in t for w in _DOLBY_WORDS):
|
| 184 |
+
return ("That is not Dolby Atmos. Dolby Atmos is a specific licensed technology, and this "
|
| 185 |
+
"product does not carry that licence. What it does have is real HRTF binaural "
|
| 186 |
+
"spatial audio β the same underlying mechanism, head-tracked β which I can turn on.")
|
| 187 |
+
if "360" in t and ("convert" in t or "make" in t or "turn" in t):
|
| 188 |
+
return ("A normal video cannot honestly be made into a real 360 video β a flat camera never "
|
| 189 |
+
"recorded the other 300 degrees. What I can do is play it at full original quality "
|
| 190 |
+
"on a virtual screen inside an immersive environment (theater, IMAX, passthrough "
|
| 191 |
+
"and so on), and optionally add an AI depth effect for a stereo sense of depth. "
|
| 192 |
+
"There is also a clearly-labelled experimental mode that generates the surround, "
|
| 193 |
+
"but that content is invented, not filmed.")
|
| 194 |
+
return ("The immersive viewer plays local photos and videos in four formats (flat, 180, 360, "
|
| 195 |
+
"fisheye), five stereo packings, and six viewing modes (" + ", ".join(ENVIRONMENTS) +
|
| 196 |
+
"). Format and packing are auto-detected and overridable. Switching viewing mode "
|
| 197 |
+
"changes the room and screen geometry only β never the decode path, resolution or "
|
| 198 |
+
"bitrate.")
|
backend/tools/tool_registry.py
CHANGED
|
@@ -1,50 +1,66 @@
|
|
| 1 |
-
from backend.tools.web_search_tools import search_web as web_search_tool
|
| 2 |
-
from backend.tools.filesystem_tools import read_file as read_file_tool, write_file as write_file_tool
|
| 3 |
-
from backend.tools.terminal_tools import run_command as run_shell_tool
|
| 4 |
-
from backend.tools.github_tools import github_search_tool, github_commit_tool, github_pull_tool
|
| 5 |
-
from backend.tools.memory_tools import memory_store_tool, memory_search_tool
|
| 6 |
-
from backend.tools.system_tools import screenshot_tool, open_app_tool, usb_devices_tool, notification_tool, system_stats_tool
|
| 7 |
-
from backend.tools.calendar_tools import list_events as calendar_tool
|
| 8 |
-
from backend.tools.email_tools import send_email as email_tool
|
| 9 |
-
from backend.tools.xr_tools import xr_anchor_tool, gesture_context_tool, ar_control_tool
|
| 10 |
-
from backend.tools.automation_tools import list_automations_tool, pause_automation_tool, resume_automation_tool, delete_automation_tool, trigger_automation_tool
|
| 11 |
-
from backend.tools.system_tools import backup_vault_tool
|
| 12 |
-
from backend.tools.browser_tools import close_browser, navigate
|
| 13 |
-
from backend.tools.youtube_tools import scrape_youtube_transcript, search_youtube
|
| 14 |
-
from backend.tools.social_tools import login_to_socials, post_to_x
|
| 15 |
-
from backend.tools.audio_tools import search_audio_logs
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
|
|
|
| 1 |
+
from backend.tools.web_search_tools import search_web as web_search_tool
|
| 2 |
+
from backend.tools.filesystem_tools import read_file as read_file_tool, write_file as write_file_tool
|
| 3 |
+
from backend.tools.terminal_tools import run_command as run_shell_tool
|
| 4 |
+
from backend.tools.github_tools import github_search_tool, github_commit_tool, github_pull_tool
|
| 5 |
+
from backend.tools.memory_tools import memory_store_tool, memory_search_tool
|
| 6 |
+
from backend.tools.system_tools import screenshot_tool, open_app_tool, usb_devices_tool, notification_tool, system_stats_tool
|
| 7 |
+
from backend.tools.calendar_tools import list_events as calendar_tool
|
| 8 |
+
from backend.tools.email_tools import send_email as email_tool
|
| 9 |
+
from backend.tools.xr_tools import xr_anchor_tool, gesture_context_tool, ar_control_tool
|
| 10 |
+
from backend.tools.automation_tools import list_automations_tool, pause_automation_tool, resume_automation_tool, delete_automation_tool, trigger_automation_tool
|
| 11 |
+
from backend.tools.system_tools import backup_vault_tool
|
| 12 |
+
from backend.tools.browser_tools import close_browser, navigate
|
| 13 |
+
from backend.tools.youtube_tools import scrape_youtube_transcript, search_youtube
|
| 14 |
+
from backend.tools.social_tools import login_to_socials, post_to_x
|
| 15 |
+
from backend.tools.audio_tools import search_audio_logs
|
| 16 |
+
from backend.tools.media_viewer_tools import (
|
| 17 |
+
media_open_tool, media_set_environment_tool, media_set_format_tool,
|
| 18 |
+
media_adjust_screen_tool, media_spatial_audio_tool, media_ai_depth_tool,
|
| 19 |
+
media_experimental_surround_tool, media_describe_tool,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
TOOL_REGISTRY = {
|
| 23 |
+
"backup_vault": backup_vault_tool,
|
| 24 |
+
"web_search": web_search_tool,
|
| 25 |
+
"read_file": read_file_tool,
|
| 26 |
+
"write_file": write_file_tool,
|
| 27 |
+
"run_shell": run_shell_tool,
|
| 28 |
+
"github_search": github_search_tool,
|
| 29 |
+
"github_commit": github_commit_tool,
|
| 30 |
+
"github_pull": github_pull_tool,
|
| 31 |
+
"memory_store": memory_store_tool,
|
| 32 |
+
"memory_search": memory_search_tool,
|
| 33 |
+
"take_screenshot": screenshot_tool,
|
| 34 |
+
"open_app": open_app_tool,
|
| 35 |
+
"usb_get_devices": usb_devices_tool,
|
| 36 |
+
"send_notification": notification_tool,
|
| 37 |
+
"get_system_stats": system_stats_tool,
|
| 38 |
+
"calendar_query": calendar_tool,
|
| 39 |
+
"email_send": email_tool,
|
| 40 |
+
"xr_save_anchor": xr_anchor_tool,
|
| 41 |
+
"gesture_context": gesture_context_tool,
|
| 42 |
+
"ar_control": ar_control_tool,
|
| 43 |
+
"automation_list": list_automations_tool,
|
| 44 |
+
"automation_pause": pause_automation_tool,
|
| 45 |
+
"automation_resume": resume_automation_tool,
|
| 46 |
+
"automation_delete": delete_automation_tool,
|
| 47 |
+
"automation_trigger": trigger_automation_tool,
|
| 48 |
+
"browser_login": login_to_socials,
|
| 49 |
+
"browser_post_x": post_to_x,
|
| 50 |
+
"browser_yt_scrape": scrape_youtube_transcript,
|
| 51 |
+
"browser_yt_search": search_youtube,
|
| 52 |
+
"browser_close": close_browser,
|
| 53 |
+
"browser_navigate": navigate,
|
| 54 |
+
"search_audio_logs": search_audio_logs,
|
| 55 |
+
# Local photo/video immersive viewer (v28 flagship). Environment is a real
|
| 56 |
+
# named choice, never a free-text field, so the assistant can answer honestly
|
| 57 |
+
# about what each preset actually changes.
|
| 58 |
+
"media_open": media_open_tool,
|
| 59 |
+
"media_set_environment": media_set_environment_tool,
|
| 60 |
+
"media_set_format": media_set_format_tool,
|
| 61 |
+
"media_adjust_screen": media_adjust_screen_tool,
|
| 62 |
+
"media_spatial_audio": media_spatial_audio_tool,
|
| 63 |
+
"media_ai_depth": media_ai_depth_tool,
|
| 64 |
+
"media_experimental_surround": media_experimental_surround_tool,
|
| 65 |
+
"media_describe": media_describe_tool,
|
| 66 |
}
|
backend/ws/agent_ws.py
CHANGED
|
@@ -24,6 +24,15 @@ class ConnectionManager:
|
|
| 24 |
def __init__(self):
|
| 25 |
self.restricted_mode = False
|
| 26 |
self.active_connections: list[WebSocket] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
from backend.agent.react_agent import Tool
|
| 28 |
self.tools = []
|
| 29 |
for name, func in TOOL_REGISTRY.items():
|
|
@@ -48,8 +57,28 @@ class ConnectionManager:
|
|
| 48 |
def disconnect(self, websocket: WebSocket):
|
| 49 |
if websocket in self.active_connections:
|
| 50 |
self.active_connections.remove(websocket)
|
|
|
|
|
|
|
| 51 |
logging.info("Client disconnected from Agent WebSocket.")
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
async def broadcast(self, message: dict):
|
| 54 |
for connection in self.active_connections:
|
| 55 |
try:
|
|
@@ -59,6 +88,29 @@ class ConnectionManager:
|
|
| 59 |
|
| 60 |
async def handle_client_event(self, websocket: WebSocket | None, data: dict):
|
| 61 |
global _jarvis_auto_switched
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
# ββ WAKE WORD β PERSONA SWITCH (Cloud + Local path) ββββββββββββββββββββββ
|
| 63 |
# Mobile app / local PC sends: {"event": "voice:wake_word", "payload": {"agent": "hey jarvis"}}
|
| 64 |
# The server auto-switches the active persona and broadcasts UI + TTS confirmation.
|
|
|
|
| 24 |
def __init__(self):
|
| 25 |
self.restricted_mode = False
|
| 26 |
self.active_connections: list[WebSocket] = []
|
| 27 |
+
# Sockets that announced themselves as a PC (pc_relay_client.py sends
|
| 28 |
+
# {"event": "client:identify", "payload": {"type": "pc"}} on connect).
|
| 29 |
+
#
|
| 30 |
+
# Until now that message was received and DROPPED β nothing in the
|
| 31 |
+
# backend handled "client:identify" at all β so the Space had a live,
|
| 32 |
+
# authenticated, command-capable connection to the user's desktop and no
|
| 33 |
+
# idea which socket it was. That is why phone -> cloud -> PC control did
|
| 34 |
+
# not work: not a missing channel, an unread introduction.
|
| 35 |
+
self.pc_connections: list[WebSocket] = []
|
| 36 |
from backend.agent.react_agent import Tool
|
| 37 |
self.tools = []
|
| 38 |
for name, func in TOOL_REGISTRY.items():
|
|
|
|
| 57 |
def disconnect(self, websocket: WebSocket):
|
| 58 |
if websocket in self.active_connections:
|
| 59 |
self.active_connections.remove(websocket)
|
| 60 |
+
if websocket in self.pc_connections:
|
| 61 |
+
self.pc_connections.remove(websocket)
|
| 62 |
logging.info("Client disconnected from Agent WebSocket.")
|
| 63 |
|
| 64 |
+
async def send_to_pcs(self, message: dict) -> int:
|
| 65 |
+
"""Deliver to every connected PC relay. Returns how many received it.
|
| 66 |
+
|
| 67 |
+
The count is the point: the caller can tell the difference between "sent
|
| 68 |
+
to your desktop" and "nothing was listening", instead of reporting
|
| 69 |
+
success for a command that reached no machine.
|
| 70 |
+
"""
|
| 71 |
+
delivered = 0
|
| 72 |
+
for connection in list(self.pc_connections):
|
| 73 |
+
try:
|
| 74 |
+
await connection.send_json(message)
|
| 75 |
+
delivered += 1
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logging.warning(f"PC relay send failed, dropping connection: {e}")
|
| 78 |
+
if connection in self.pc_connections:
|
| 79 |
+
self.pc_connections.remove(connection)
|
| 80 |
+
return delivered
|
| 81 |
+
|
| 82 |
async def broadcast(self, message: dict):
|
| 83 |
for connection in self.active_connections:
|
| 84 |
try:
|
|
|
|
| 88 |
|
| 89 |
async def handle_client_event(self, websocket: WebSocket | None, data: dict):
|
| 90 |
global _jarvis_auto_switched
|
| 91 |
+
|
| 92 |
+
# ββ WHO ARE YOU? ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
# pc_relay_client.py has always sent this the moment it connects, and
|
| 94 |
+
# nothing ever read it. Registering the socket here is what makes
|
| 95 |
+
# phone -> cloud -> PC control possible: the Space can now address the
|
| 96 |
+
# desktop specifically instead of shouting at every client.
|
| 97 |
+
if data.get("event") == "client:identify":
|
| 98 |
+
kind = str((data.get("payload") or {}).get("type") or "").lower()
|
| 99 |
+
if kind == "pc" and websocket is not None:
|
| 100 |
+
if websocket not in self.pc_connections:
|
| 101 |
+
self.pc_connections.append(websocket)
|
| 102 |
+
logging.info(
|
| 103 |
+
"PC relay registered (%d PC connection(s) now available)",
|
| 104 |
+
len(self.pc_connections),
|
| 105 |
+
)
|
| 106 |
+
try:
|
| 107 |
+
await websocket.send_json({
|
| 108 |
+
"event": "backend:pc_registered",
|
| 109 |
+
"payload": {"ok": True},
|
| 110 |
+
})
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
return
|
| 114 |
# ββ WAKE WORD β PERSONA SWITCH (Cloud + Local path) ββββββββββββββββββββββ
|
| 115 |
# Mobile app / local PC sends: {"event": "voice:wake_word", "payload": {"agent": "hey jarvis"}}
|
| 116 |
# The server auto-switches the active persona and broadcasts UI + TTS confirmation.
|
dist/app-release.apk
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3768e22e18bbcc3cb6721d89727731626d6a1c675e227383dca5197d37915519
|
| 3 |
+
size 116538395
|
modules/assistant_identity.py
CHANGED
|
@@ -89,6 +89,27 @@ SYSTEM AWARENESS (NEW CAPABILITIES):
|
|
| 89 |
- Android God-Tier Automation: You are aware that the mobile guardian runs an autonomous Screen Agent via Accessibility Services. If the user asks for complex phone tasks, the mobile app handles it autonomously via AUTOMATE_MOBILE_TASK.
|
| 90 |
- AR/VR Omnipresence: You are aware that the user has an XR Headset connected to the OMEGA network. You can spawn 3D models, launch AR browsers, and perform Telekinesis (manipulate 3D objects) via the AR Lab.
|
| 91 |
- OSINT & Cybersecurity Protocol: You are an Elite Defensive Cybersecurity and OSINT agent. You perform advanced web reconnaissance, threat modeling, and digital footprint analysis natively via voice/chat using your Internet Layer routing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"""
|
| 93 |
|
| 94 |
|
|
@@ -134,6 +155,27 @@ SYSTEM AWARENESS (NEW CAPABILITIES):
|
|
| 134 |
- Android God-Tier Automation: You are aware that the mobile guardian runs an autonomous Screen Agent via Accessibility Services. If the user asks for complex phone tasks, the mobile app handles it autonomously via AUTOMATE_MOBILE_TASK.
|
| 135 |
- AR/VR Omnipresence: You are aware that the user has an XR Headset connected to the OMEGA network. You can spawn 3D models, launch AR browsers, and perform Telekinesis (manipulate 3D objects) via the AR Lab.
|
| 136 |
- OSINT & Cybersecurity Protocol: You are an Elite Defensive Cybersecurity and OSINT agent. You perform advanced web reconnaissance, threat modeling, and digital footprint analysis natively via voice/chat using your Internet Layer routing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
"""
|
| 138 |
|
| 139 |
|
|
|
|
| 89 |
- Android God-Tier Automation: You are aware that the mobile guardian runs an autonomous Screen Agent via Accessibility Services. If the user asks for complex phone tasks, the mobile app handles it autonomously via AUTOMATE_MOBILE_TASK.
|
| 90 |
- AR/VR Omnipresence: You are aware that the user has an XR Headset connected to the OMEGA network. You can spawn 3D models, launch AR browsers, and perform Telekinesis (manipulate 3D objects) via the AR Lab.
|
| 91 |
- OSINT & Cybersecurity Protocol: You are an Elite Defensive Cybersecurity and OSINT agent. You perform advanced web reconnaissance, threat modeling, and digital footprint analysis natively via voice/chat using your Internet Layer routing.
|
| 92 |
+
- Immersive Media Viewer: You can open a LOCAL photo or video from the user's own device and show it
|
| 93 |
+
immersively, via media_open / media_set_format / media_set_environment / media_adjust_screen /
|
| 94 |
+
media_spatial_audio. Four projection formats (flat, 180, 360, fisheye), five stereo packings
|
| 95 |
+
(mono, SBS full/half, top-bottom full/half), and six viewing modes: passthrough, mixed,
|
| 96 |
+
theater, inox, imax, pvr. Nothing is uploaded β the file never leaves the device.
|
| 97 |
+
* The six viewing modes are real room-and-screen presets with different geometry and acoustics
|
| 98 |
+
(IMAX subtends 70 degrees with a curvature radius 1.5x the screen width; theater 36 degrees).
|
| 99 |
+
They change WHAT THE PICTURE IS DRAWN ON β never the decode path, resolution, or bitrate.
|
| 100 |
+
Say that plainly if asked. PVR and INOX are named scale presets, not licensed cinema formats.
|
| 101 |
+
* BE HONEST ABOUT WHAT CANNOT BE DONE. A normal flat video cannot be turned into a real 360
|
| 102 |
+
video β the camera never recorded the other 300 degrees. What you can do is play it at full
|
| 103 |
+
original quality on a virtual screen inside an immersive environment. Never claim otherwise.
|
| 104 |
+
* There is an optional AI depth effect (media_ai_depth) that synthesises a second eye from a
|
| 105 |
+
flat video; the small regions revealed behind foreground objects are filled in, not captured.
|
| 106 |
+
* There is a separate EXPERIMENTAL generative surround mode (media_experimental_surround) that
|
| 107 |
+
invents the unseen part of the scene. NEVER select it just because the user said "360" β that
|
| 108 |
+
request means the real 360 projection path. Only use it when the user names it explicitly, and
|
| 109 |
+
always state that what it produces is AI-generated, not filmed.
|
| 110 |
+
* Spatial audio is real HRTF binaural rendering (ambisonic decode for genuinely spatial sources,
|
| 111 |
+
HRTF widening for ordinary stereo). It is NOT Dolby Atmos. If the user calls it Dolby Atmos,
|
| 112 |
+
correct them politely and offer the real thing instead of agreeing to their wording.
|
| 113 |
"""
|
| 114 |
|
| 115 |
|
|
|
|
| 155 |
- Android God-Tier Automation: You are aware that the mobile guardian runs an autonomous Screen Agent via Accessibility Services. If the user asks for complex phone tasks, the mobile app handles it autonomously via AUTOMATE_MOBILE_TASK.
|
| 156 |
- AR/VR Omnipresence: You are aware that the user has an XR Headset connected to the OMEGA network. You can spawn 3D models, launch AR browsers, and perform Telekinesis (manipulate 3D objects) via the AR Lab.
|
| 157 |
- OSINT & Cybersecurity Protocol: You are an Elite Defensive Cybersecurity and OSINT agent. You perform advanced web reconnaissance, threat modeling, and digital footprint analysis natively via voice/chat using your Internet Layer routing.
|
| 158 |
+
- Immersive Media Viewer: You can open a LOCAL photo or video from the user's own device and show it
|
| 159 |
+
immersively, via media_open / media_set_format / media_set_environment / media_adjust_screen /
|
| 160 |
+
media_spatial_audio. Four projection formats (flat, 180, 360, fisheye), five stereo packings
|
| 161 |
+
(mono, SBS full/half, top-bottom full/half), and six viewing modes: passthrough, mixed,
|
| 162 |
+
theater, inox, imax, pvr. Nothing is uploaded β the file never leaves the device.
|
| 163 |
+
* The six viewing modes are real room-and-screen presets with different geometry and acoustics
|
| 164 |
+
(IMAX subtends 70 degrees with a curvature radius 1.5x the screen width; theater 36 degrees).
|
| 165 |
+
They change WHAT THE PICTURE IS DRAWN ON β never the decode path, resolution, or bitrate.
|
| 166 |
+
Say that plainly if asked. PVR and INOX are named scale presets, not licensed cinema formats.
|
| 167 |
+
* BE HONEST ABOUT WHAT CANNOT BE DONE. A normal flat video cannot be turned into a real 360
|
| 168 |
+
video β the camera never recorded the other 300 degrees. What you can do is play it at full
|
| 169 |
+
original quality on a virtual screen inside an immersive environment. Never claim otherwise.
|
| 170 |
+
* There is an optional AI depth effect (media_ai_depth) that synthesises a second eye from a
|
| 171 |
+
flat video; the small regions revealed behind foreground objects are filled in, not captured.
|
| 172 |
+
* There is a separate EXPERIMENTAL generative surround mode (media_experimental_surround) that
|
| 173 |
+
invents the unseen part of the scene. NEVER select it just because the user said "360" β that
|
| 174 |
+
request means the real 360 projection path. Only use it when the user names it explicitly, and
|
| 175 |
+
always state that what it produces is AI-generated, not filmed.
|
| 176 |
+
* Spatial audio is real HRTF binaural rendering (ambisonic decode for genuinely spatial sources,
|
| 177 |
+
HRTF widening for ordinary stereo). It is NOT Dolby Atmos. If the user calls it Dolby Atmos,
|
| 178 |
+
correct them politely and offer the real thing instead of agreeing to their wording.
|
| 179 |
"""
|
| 180 |
|
| 181 |
|