Jarvis2345 commited on
Commit
a1fcfb8
Β·
verified Β·
1 Parent(s): f694ed4

deploy(S4): Blender headless pipeline + WebAR client + backend fixes

Browse files
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
- app.include_router(automation_router, prefix="/automation", dependencies=[Depends(verify_token)])
 
 
 
 
 
 
 
 
 
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
- SUBSCRIBER_SECRET_PURPOSES: tuple[str, ...] = (
 
 
 
 
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
- # This router only ever serves the *cloud* path (on the LAN, Guardian hits
218
- # phone/local_server.py:7474, which executes for real against the PC). The
219
- # cloud container has no PC to drive, so the honest behaviour is to run the
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
- payload = await _sketchfab_get("/models", token, {
 
 
 
 
 
 
 
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
- TOOL_REGISTRY = {
18
- "backup_vault": backup_vault_tool,
19
- "web_search": web_search_tool,
20
- "read_file": read_file_tool,
21
- "write_file": write_file_tool,
22
- "run_shell": run_shell_tool,
23
- "github_search": github_search_tool,
24
- "github_commit": github_commit_tool,
25
- "github_pull": github_pull_tool,
26
- "memory_store": memory_store_tool,
27
- "memory_search": memory_search_tool,
28
- "take_screenshot": screenshot_tool,
29
- "open_app": open_app_tool,
30
- "usb_get_devices": usb_devices_tool,
31
- "send_notification": notification_tool,
32
- "get_system_stats": system_stats_tool,
33
- "calendar_query": calendar_tool,
34
- "email_send": email_tool,
35
- "xr_save_anchor": xr_anchor_tool,
36
- "gesture_context": gesture_context_tool,
37
- "ar_control": ar_control_tool,
38
- "automation_list": list_automations_tool,
39
- "automation_pause": pause_automation_tool,
40
- "automation_resume": resume_automation_tool,
41
- "automation_delete": delete_automation_tool,
42
- "automation_trigger": trigger_automation_tool,
43
- "browser_login": login_to_socials,
44
- "browser_post_x": post_to_x,
45
- "browser_yt_scrape": scrape_youtube_transcript,
46
- "browser_yt_search": search_youtube,
47
- "browser_close": close_browser,
48
- "browser_navigate": navigate,
49
- "search_audio_logs": search_audio_logs,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:f2fdc7163b87f3cec94f9d8da14892e61fe8217072a32f55a360167a30edeb77
3
- size 66085001
 
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