Spaces:
Sleeping
Sleeping
deploy(S4): Blender headless pipeline + WebAR client + backend fixes
Browse files- backend/main.py +53 -1
- backend/omega/auto_upgrade.py +29 -2
- backend/omega/osint_engine.py +113 -30
- backend/routes/mobile_bridge_routes.py +53 -8
- backend/routes/voice_routes.py +12 -0
- backend/services/live_diagnostics.py +188 -0
- backend/tools/image_forensics.py +82 -20
- backend/tools/system_tools.py +54 -1
- backend/tools/tool_registry.py +10 -1
- backend/voice/engines/kokoro_engine.py +4 -4
backend/main.py
CHANGED
|
@@ -173,7 +173,20 @@ if SLOWAPI_AVAILABLE:
|
|
| 173 |
|
| 174 |
app.include_router(agent_router, prefix="/agent", dependencies=[Depends(verify_token)])
|
| 175 |
app.include_router(memory_router, prefix="/memory", dependencies=[Depends(verify_token)])
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
app.include_router(system_router, prefix="/system", dependencies=[Depends(verify_token)])
|
| 178 |
# v22: AUTOMATION is a paid feature in the plan catalog (plus/pro only — see
|
| 179 |
# billing/plans.py), but no automation route enforced it: a caller on the `free` plan got
|
|
@@ -366,6 +379,10 @@ async def global_exception_handler(request: Request, exc: Exception):
|
|
| 366 |
|
| 367 |
@app.middleware("http")
|
| 368 |
async def auth_middleware(request: Request, call_next):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
allowed_paths = ["/", "/health", "/wake", "/ping", "/android/pair", "/voice/upload_log"]
|
| 370 |
# S4: read-only static surfaces the AR client fetches without headers —
|
| 371 |
# GLTFLoader cannot attach Authorization, and the client app itself is public.
|
|
@@ -417,6 +434,41 @@ async def startup_event():
|
|
| 417 |
except Exception as scene_bus_error:
|
| 418 |
logging.warning(f"AR scene bus not started: {scene_bus_error}")
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
# Start automations scheduler
|
| 421 |
from backend.services.automation_service import init_automations
|
| 422 |
await init_automations()
|
|
|
|
| 173 |
|
| 174 |
app.include_router(agent_router, prefix="/agent", dependencies=[Depends(verify_token)])
|
| 175 |
app.include_router(memory_router, prefix="/memory", dependencies=[Depends(verify_token)])
|
| 176 |
+
# BOTH mounts stay behind verify_token.
|
| 177 |
+
#
|
| 178 |
+
# The dependency was removed from this router so the phone's MediaPlayer could
|
| 179 |
+
# fetch TTS by URL — but that opened the WHOLE voice router, not just speech
|
| 180 |
+
# synthesis: /voice/devices enumerates the host's audio hardware and
|
| 181 |
+
# /voice/set_device changes where it plays. The phone already holds a token; it
|
| 182 |
+
# simply was not sending it on that one call (AndroidAudioPlayer.playAudioUrl
|
| 183 |
+
# used the header-less setDataSource overload). Fixed on the client instead, so
|
| 184 |
+
# the endpoint does not have to be public for the app to work.
|
| 185 |
+
#
|
| 186 |
+
# /api/voice is the second mount because that is the path the Guardian app was
|
| 187 |
+
# built against; it is the same router, same auth.
|
| 188 |
+
app.include_router(voice_router, prefix="/voice", dependencies=[Depends(verify_token)])
|
| 189 |
+
app.include_router(voice_router, prefix="/api/voice", dependencies=[Depends(verify_token)])
|
| 190 |
app.include_router(system_router, prefix="/system", dependencies=[Depends(verify_token)])
|
| 191 |
# v22: AUTOMATION is a paid feature in the plan catalog (plus/pro only — see
|
| 192 |
# billing/plans.py), but no automation route enforced it: a caller on the `free` plan got
|
|
|
|
| 379 |
|
| 380 |
@app.middleware("http")
|
| 381 |
async def auth_middleware(request: Request, call_next):
|
| 382 |
+
# /voice/speak and /api/voice/speak were added here and to allowed_prefixes
|
| 383 |
+
# below, which made speech synthesis callable by anyone on the public Space.
|
| 384 |
+
# The only client that needed it was the phone's MediaPlayer, and it now
|
| 385 |
+
# sends its token (AndroidAudioPlayer), so the exemption is gone.
|
| 386 |
allowed_paths = ["/", "/health", "/wake", "/ping", "/android/pair", "/voice/upload_log"]
|
| 387 |
# S4: read-only static surfaces the AR client fetches without headers —
|
| 388 |
# GLTFLoader cannot attach Authorization, and the client app itself is public.
|
|
|
|
| 434 |
except Exception as scene_bus_error:
|
| 435 |
logging.warning(f"AR scene bus not started: {scene_bus_error}")
|
| 436 |
|
| 437 |
+
# WARM THE JARVIS VOICE BEFORE ANYONE ASKS FOR IT.
|
| 438 |
+
#
|
| 439 |
+
# This is why "JARVIS voice from PC is not working at all". The voice was
|
| 440 |
+
# never broken — it was COLD. Measured on this machine:
|
| 441 |
+
#
|
| 442 |
+
# first synthesis after start : 75.1 s (loads RVC jarvis_test.pth,
|
| 443 |
+
# Kokoro, and warms CUDA)
|
| 444 |
+
# every synthesis after that : 2.2 s (identical text and output)
|
| 445 |
+
#
|
| 446 |
+
# The models load lazily inside the first call. The phone's HTTP client
|
| 447 |
+
# gives up long before 75 s — OkHttp's read timeout on the TTS path is
|
| 448 |
+
# 60 s — so the FIRST request after every backend start was guaranteed to
|
| 449 |
+
# time out with nothing played. And since the phone then (until this pass)
|
| 450 |
+
# refused to fall back to on-device speech for JARVIS, the result was
|
| 451 |
+
# silence rather than a slow answer, which reads exactly like a dead
|
| 452 |
+
# feature. Every restart re-armed the trap.
|
| 453 |
+
#
|
| 454 |
+
# Warming here moves that one-time cost to startup, where nothing is
|
| 455 |
+
# waiting on it. Fired as a background task, never awaited: a voice that
|
| 456 |
+
# fails to warm must not stop the backend from serving everything else.
|
| 457 |
+
async def _warm_voice() -> None:
|
| 458 |
+
import time as _t
|
| 459 |
+
try:
|
| 460 |
+
from backend.voice.tts import TTSPipeline
|
| 461 |
+
t0 = _t.time()
|
| 462 |
+
# A real utterance, because that is what pulls every model in the
|
| 463 |
+
# chain into memory — Kokoro, RVC and the DSP tail.
|
| 464 |
+
await TTSPipeline().synthesize("Systems nominal.", personality="jarvis")
|
| 465 |
+
logging.info(f"JARVIS voice warmed in {_t.time() - t0:.1f}s")
|
| 466 |
+
except Exception as warm_error:
|
| 467 |
+
logging.warning(f"JARVIS voice warm-up failed: {warm_error}")
|
| 468 |
+
|
| 469 |
+
if os.environ.get("JARVIS_SKIP_VOICE_WARMUP", "").lower() not in ("1", "true", "yes"):
|
| 470 |
+
BACKGROUND_TASKS.append(asyncio.create_task(_warm_voice()))
|
| 471 |
+
|
| 472 |
# Start automations scheduler
|
| 473 |
from backend.services.automation_service import init_automations
|
| 474 |
await init_automations()
|
backend/omega/auto_upgrade.py
CHANGED
|
@@ -91,8 +91,35 @@ def log_upgrade_to_db(feature_request: str, files_modified: list, persona: str,
|
|
| 91 |
async def handle_upgrade_request(feature_request: str, persona: str):
|
| 92 |
logging.info(f"Auto-Upgrade Triggered by {persona.upper()}: {feature_request}")
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
return
|
| 97 |
|
| 98 |
# Circuit Breaker Loop Prevention
|
|
|
|
| 91 |
async def handle_upgrade_request(feature_request: str, persona: str):
|
| 92 |
logging.info(f"Auto-Upgrade Triggered by {persona.upper()}: {feature_request}")
|
| 93 |
|
| 94 |
+
# RESOLVE THE KEY NOW, NOT AT IMPORT, AND FALL BACK.
|
| 95 |
+
#
|
| 96 |
+
# `GEMINI_API_KEY` above is read ONCE when this module is first imported,
|
| 97 |
+
# from one domain-specific vault slot (AUTO_UPGRADE_PC / _CLOUD). That slot
|
| 98 |
+
# is not configured on this machine, so the constant was "" for the life of
|
| 99 |
+
# the process and this function returned on its second line, every time —
|
| 100 |
+
# while `core.brain_router.ask_router` answered a live Gemini call in 2.9 s
|
| 101 |
+
# from a key that was sitting right there. The whole self-coding capability
|
| 102 |
+
# was switched off by a gate, not by a missing model.
|
| 103 |
+
#
|
| 104 |
+
# Resolving per call also means configuring the key no longer requires a
|
| 105 |
+
# restart to take effect.
|
| 106 |
+
key = GEMINI_API_KEY
|
| 107 |
+
if not key:
|
| 108 |
+
for src in (
|
| 109 |
+
lambda: os.environ.get("GEMINI_API_KEY") or "",
|
| 110 |
+
lambda: __import__("config").GEMINI_API_KEY,
|
| 111 |
+
lambda: resolve_vault_key(KeyDomain.AUTO_UPGRADE_PC),
|
| 112 |
+
):
|
| 113 |
+
try:
|
| 114 |
+
key = (src() or "").strip()
|
| 115 |
+
except Exception:
|
| 116 |
+
key = ""
|
| 117 |
+
if key:
|
| 118 |
+
logging.info("Auto-Upgrade: using a fallback API key source.")
|
| 119 |
+
break
|
| 120 |
+
if not key:
|
| 121 |
+
logging.error("Auto-Upgrade: no usable Gemini key from any source. "
|
| 122 |
+
"Cannot write code.")
|
| 123 |
return
|
| 124 |
|
| 125 |
# Circuit Breaker Loop Prevention
|
backend/omega/osint_engine.py
CHANGED
|
@@ -68,7 +68,7 @@ log = logging.getLogger(__name__)
|
|
| 68 |
USER_AGENT = ("OMEGA-OSINT/1.0 (https://github.com/omega-assistant; "
|
| 69 |
"operator-research-tool) python-aiohttp/3")
|
| 70 |
|
| 71 |
-
HTTP_TIMEOUT =
|
| 72 |
MAX_PARALLEL = 8
|
| 73 |
|
| 74 |
|
|
@@ -824,25 +824,122 @@ async def _email_infrastructure(session, target: str, dossier: Dossier) -> None:
|
|
| 824 |
#
|
| 825 |
# Rate limiting is real and expected; holehe's own answer is "change your IP".
|
| 826 |
# A rate-limited check is reported as UNKNOWN below, never as "no account".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 827 |
_EMAIL_ACCOUNT_CHECKS: tuple[dict[str, Any], ...] = (
|
| 828 |
{
|
| 829 |
"name": "Spotify",
|
| 830 |
"method": "GET",
|
| 831 |
"url": ("https://spclient.wg.spotify.com/signup/public/v1/account"
|
| 832 |
"?validate=1&email={email}"),
|
| 833 |
-
# status 20 = "already registered", status 1 = available.
|
| 834 |
"registered_if": lambda status, body: '"status":20' in body.replace(" ", ""),
|
| 835 |
"unknown_if": lambda status, body: status != 200,
|
| 836 |
},
|
| 837 |
-
{
|
| 838 |
-
"name": "Rambler",
|
| 839 |
-
"method": "POST",
|
| 840 |
-
"url": "https://id.rambler.ru/api/v3/mail/check",
|
| 841 |
-
"json": {"login": "{email}"},
|
| 842 |
-
# The response carries an extra opaque key when the login is taken.
|
| 843 |
-
"registered_if": lambda status, body: '"__body_error"' in body and len(body) > 260,
|
| 844 |
-
"unknown_if": lambda status, body: status != 200,
|
| 845 |
-
},
|
| 846 |
{
|
| 847 |
"name": "Gravatar",
|
| 848 |
"method": "GET",
|
|
@@ -850,24 +947,12 @@ _EMAIL_ACCOUNT_CHECKS: tuple[dict[str, Any], ...] = (
|
|
| 850 |
"registered_if": lambda status, body: status == 200 and '"entry"' in body,
|
| 851 |
"unknown_if": lambda status, body: status not in (200, 404),
|
| 852 |
},
|
| 853 |
-
{
|
| 854 |
-
"name": "Duolingo",
|
| 855 |
-
"method": "GET",
|
| 856 |
-
"url": "https://www.duolingo.com/2017-06-30/users?email={email}",
|
| 857 |
-
"registered_if": lambda status, body: '"users":[{' in body.replace(" ", ""),
|
| 858 |
-
"unknown_if": lambda status, body: status != 200,
|
| 859 |
-
},
|
| 860 |
)
|
| 861 |
|
| 862 |
|
| 863 |
-
async def
|
| 864 |
-
"""
|
| 865 |
-
|
| 866 |
-
This is the collector the operator asked for by name, and it is the highest-
|
| 867 |
-
yield pivot in the whole engine: every service an address is registered with
|
| 868 |
-
is a profile, a username and often a display name.
|
| 869 |
-
"""
|
| 870 |
-
src = "email account discovery (holehe-style)"
|
| 871 |
digest = hashlib.md5(target.strip().lower().encode()).hexdigest()
|
| 872 |
semaphore = asyncio.Semaphore(4)
|
| 873 |
|
|
@@ -887,8 +972,6 @@ async def _email_accounts(session, target: str, dossier: Dossier) -> None:
|
|
| 887 |
return
|
| 888 |
try:
|
| 889 |
if check["unknown_if"](status, body):
|
| 890 |
-
# Rate-limited or broken. Saying so is the point: "no account"
|
| 891 |
-
# and "the check could not run" are different answers.
|
| 892 |
dossier.add(Finding(
|
| 893 |
target, "account-check-inconclusive", check["name"], src, url,
|
| 894 |
detail={"http_status": status}))
|
|
@@ -1116,12 +1199,12 @@ async def investigate(
|
|
| 1116 |
dossier = Dossier(target, kind)
|
| 1117 |
|
| 1118 |
started = time.time()
|
| 1119 |
-
connector = aiohttp.TCPConnector(limit=MAX_PARALLEL, ssl=False)
|
| 1120 |
# The per-request ceiling is generous because _run() imposes the real budget
|
| 1121 |
# per source. A tight ceiling here would kill the slow-but-valuable sources
|
| 1122 |
# before their own allowance ran out; a short connect timeout still stops a
|
| 1123 |
# dead host from holding a slot.
|
| 1124 |
-
session_timeout = aiohttp.ClientTimeout(total=
|
| 1125 |
headers = {"User-Agent": USER_AGENT, "Accept": "application/json,text/html,*/*"}
|
| 1126 |
|
| 1127 |
async with aiohttp.ClientSession(timeout=session_timeout, headers=headers,
|
|
|
|
| 68 |
USER_AGENT = ("OMEGA-OSINT/1.0 (https://github.com/omega-assistant; "
|
| 69 |
"operator-research-tool) python-aiohttp/3")
|
| 70 |
|
| 71 |
+
HTTP_TIMEOUT = 30
|
| 72 |
MAX_PARALLEL = 8
|
| 73 |
|
| 74 |
|
|
|
|
| 824 |
#
|
| 825 |
# Rate limiting is real and expected; holehe's own answer is "change your IP".
|
| 826 |
# A rate-limited check is reported as UNKNOWN below, never as "no account".
|
| 827 |
+
async def _email_accounts(session, target: str, dossier: Dossier) -> None:
|
| 828 |
+
"""Which services this address is registered with.
|
| 829 |
+
|
| 830 |
+
Uses the real holehe library (megadose/holehe) which probes each service's
|
| 831 |
+
own account-availability endpoint. It does NOT alert the target email —
|
| 832 |
+
these are signup-form validation calls, not password-recovery flows.
|
| 833 |
+
"""
|
| 834 |
+
src = "holehe (email account discovery)"
|
| 835 |
+
try:
|
| 836 |
+
import holehe.modules as holehe_modules
|
| 837 |
+
import holehe.core as holehe_core
|
| 838 |
+
import httpx
|
| 839 |
+
|
| 840 |
+
out: list[dict] = []
|
| 841 |
+
client = httpx.AsyncClient(timeout=20)
|
| 842 |
+
try:
|
| 843 |
+
# holehe.core.import_submodules loads every module in holehe/modules/
|
| 844 |
+
modules = holehe_core.import_submodules(holehe_modules)
|
| 845 |
+
|
| 846 |
+
# PICK THE PROBE BY NAME, NOT BY POSITION.
|
| 847 |
+
#
|
| 848 |
+
# Each holehe service module defines one async function named exactly
|
| 849 |
+
# after the module's last path segment — holehe.modules.mails.gravatar
|
| 850 |
+
# exposes `gravatar(email, client, out)`.
|
| 851 |
+
#
|
| 852 |
+
# The previous version walked `dir(module)` and took the first
|
| 853 |
+
# non-underscore callable, then broke. `dir()` is ALPHABETICAL, so
|
| 854 |
+
# what it actually picked was whatever the module happened to import
|
| 855 |
+
# first — `AsyncClient`, `BeautifulSoup`, `datetime`. Calling those
|
| 856 |
+
# with (email, client, out) returns an ordinary object rather than a
|
| 857 |
+
# coroutine, and `asyncio.gather` then raised
|
| 858 |
+
#
|
| 859 |
+
# TypeError: An asyncio.Future, a coroutine or an awaitable is required
|
| 860 |
+
#
|
| 861 |
+
# which the broad `except Exception` below turned into one logged
|
| 862 |
+
# error and ZERO findings. It never surfaced because holehe was not
|
| 863 |
+
# in any requirements file, so this whole branch died at the import
|
| 864 |
+
# and silently fell through to the four-service fallback. Two faults
|
| 865 |
+
# hiding each other.
|
| 866 |
+
#
|
| 867 |
+
# Measured against holehe 1.61: 121 of 144 entries expose a coroutine
|
| 868 |
+
# under their leaf name; the remaining 23 are package entries with no
|
| 869 |
+
# callables and are correctly skipped.
|
| 870 |
+
tasks = []
|
| 871 |
+
for module_name, module in modules.items():
|
| 872 |
+
leaf = module_name.rsplit(".", 1)[-1]
|
| 873 |
+
fn = getattr(module, leaf, None)
|
| 874 |
+
if fn is None or not asyncio.iscoroutinefunction(fn):
|
| 875 |
+
continue
|
| 876 |
+
tasks.append(fn(target, client, out))
|
| 877 |
+
|
| 878 |
+
if not tasks:
|
| 879 |
+
# Better to say so than to report "no accounts found".
|
| 880 |
+
dossier.note_error(src, "holehe exposed no usable probe modules")
|
| 881 |
+
return
|
| 882 |
+
|
| 883 |
+
log.info("holehe: probing %d services for %s", len(tasks), target)
|
| 884 |
+
# Run all probes concurrently with an overall timeout. Whatever has
|
| 885 |
+
# answered by then is kept — `out` is appended to as results land, so
|
| 886 |
+
# a timeout costs the slow services, not the whole run.
|
| 887 |
+
try:
|
| 888 |
+
await asyncio.wait_for(
|
| 889 |
+
asyncio.gather(*tasks, return_exceptions=True),
|
| 890 |
+
timeout=60,
|
| 891 |
+
)
|
| 892 |
+
except asyncio.TimeoutError:
|
| 893 |
+
dossier.note_error(
|
| 894 |
+
src, f"holehe: {len(out)} of {len(tasks)} services answered within 60s")
|
| 895 |
+
finally:
|
| 896 |
+
await client.aclose()
|
| 897 |
+
|
| 898 |
+
for result in out:
|
| 899 |
+
name = result.get("name") or result.get("domain") or "unknown"
|
| 900 |
+
exists = result.get("exists")
|
| 901 |
+
rate_limited = result.get("rateLimit", False)
|
| 902 |
+
|
| 903 |
+
if rate_limited:
|
| 904 |
+
dossier.add(Finding(
|
| 905 |
+
target, "account-check-inconclusive", name, src, "",
|
| 906 |
+
detail={"why": "rate-limited by the service"}))
|
| 907 |
+
elif exists is True:
|
| 908 |
+
dossier.add(Finding(
|
| 909 |
+
target, "registered-with", name, src, "",
|
| 910 |
+
detail={
|
| 911 |
+
"method": "holehe account-availability probe; "
|
| 912 |
+
"no mail sent to the address",
|
| 913 |
+
"email_recovery": result.get("emailrecovery"),
|
| 914 |
+
"phone_recovery": result.get("phoneNumber"),
|
| 915 |
+
"others": result.get("others"),
|
| 916 |
+
}))
|
| 917 |
+
elif exists is False:
|
| 918 |
+
dossier.add(Finding(
|
| 919 |
+
target, "account-not-found", name, src, "",
|
| 920 |
+
detail={"method": "holehe account-availability probe"}))
|
| 921 |
+
# exists=None means the check couldn't determine — skip silently
|
| 922 |
+
|
| 923 |
+
except ImportError:
|
| 924 |
+
# Holehe not installed — fall back to the minimal built-in checks
|
| 925 |
+
log.warning("holehe package not installed; falling back to built-in checks")
|
| 926 |
+
await _email_accounts_builtin(session, target, dossier)
|
| 927 |
+
except asyncio.TimeoutError:
|
| 928 |
+
dossier.note_error(src, "holehe checks timed out after 60s")
|
| 929 |
+
except Exception as exc:
|
| 930 |
+
dossier.note_error(src, f"holehe failed: {exc}")
|
| 931 |
+
|
| 932 |
+
|
| 933 |
+
# The original 4-service fallback, kept for environments where holehe isn't installed.
|
| 934 |
_EMAIL_ACCOUNT_CHECKS: tuple[dict[str, Any], ...] = (
|
| 935 |
{
|
| 936 |
"name": "Spotify",
|
| 937 |
"method": "GET",
|
| 938 |
"url": ("https://spclient.wg.spotify.com/signup/public/v1/account"
|
| 939 |
"?validate=1&email={email}"),
|
|
|
|
| 940 |
"registered_if": lambda status, body: '"status":20' in body.replace(" ", ""),
|
| 941 |
"unknown_if": lambda status, body: status != 200,
|
| 942 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 943 |
{
|
| 944 |
"name": "Gravatar",
|
| 945 |
"method": "GET",
|
|
|
|
| 947 |
"registered_if": lambda status, body: status == 200 and '"entry"' in body,
|
| 948 |
"unknown_if": lambda status, body: status not in (200, 404),
|
| 949 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
)
|
| 951 |
|
| 952 |
|
| 953 |
+
async def _email_accounts_builtin(session, target: str, dossier: Dossier) -> None:
|
| 954 |
+
"""Minimal fallback when holehe isn't installed."""
|
| 955 |
+
src = "email account discovery (built-in fallback)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
digest = hashlib.md5(target.strip().lower().encode()).hexdigest()
|
| 957 |
semaphore = asyncio.Semaphore(4)
|
| 958 |
|
|
|
|
| 972 |
return
|
| 973 |
try:
|
| 974 |
if check["unknown_if"](status, body):
|
|
|
|
|
|
|
| 975 |
dossier.add(Finding(
|
| 976 |
target, "account-check-inconclusive", check["name"], src, url,
|
| 977 |
detail={"http_status": status}))
|
|
|
|
| 1199 |
dossier = Dossier(target, kind)
|
| 1200 |
|
| 1201 |
started = time.time()
|
| 1202 |
+
connector = aiohttp.TCPConnector(limit=MAX_PARALLEL, ssl=False, ttl_dns_cache=600)
|
| 1203 |
# The per-request ceiling is generous because _run() imposes the real budget
|
| 1204 |
# per source. A tight ceiling here would kill the slow-but-valuable sources
|
| 1205 |
# before their own allowance ran out; a short connect timeout still stops a
|
| 1206 |
# dead host from holding a slot.
|
| 1207 |
+
session_timeout = aiohttp.ClientTimeout(total=90, sock_connect=15)
|
| 1208 |
headers = {"User-Agent": USER_AGENT, "Accept": "application/json,text/html,*/*"}
|
| 1209 |
|
| 1210 |
async with aiohttp.ClientSession(timeout=session_timeout, headers=headers,
|
backend/routes/mobile_bridge_routes.py
CHANGED
|
@@ -28,6 +28,7 @@ decrypt and route the command through the safety-gated autonomy engine.
|
|
| 28 |
import asyncio
|
| 29 |
import base64
|
| 30 |
import hashlib
|
|
|
|
| 31 |
import logging
|
| 32 |
import os
|
| 33 |
from backend.version import VERSION as _VERSION
|
|
@@ -429,12 +430,35 @@ async def _measure_live_state(context: dict[str, Any]) -> dict[str, Any]:
|
|
| 429 |
# and NOTHING EVER READ IT — the accessor had no callers anywhere in the app,
|
| 430 |
# so every restart met the operator as a stranger while a record of them sat
|
| 431 |
# on disk.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
learned = context.get("learned")
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
# Newest last, so the most recent is closest to the question.
|
| 436 |
state["what you have learned about this operator before today"] = (
|
| 437 |
-
" ⏐ ".join(
|
| 438 |
|
| 439 |
# THE AUDIO PATH, STATED, because the alternative is that it gets invented.
|
| 440 |
#
|
|
@@ -493,11 +517,11 @@ async def _measure_live_state(context: dict[str, Any]) -> dict[str, Any]:
|
|
| 493 |
# even though nothing had failed. Real research is: search, read what came back,
|
| 494 |
# search again for the specific thing. That is three searches minimum before a
|
| 495 |
# word is said.
|
| 496 |
-
_MAX_ROUNDS =
|
| 497 |
_MAX_SEARCHES = 4 # web lookups per turn
|
| 498 |
_MAX_PC_RUNS = 2 # desktop executions per turn
|
| 499 |
_MAX_FETCHES = 3 # pages actually opened and read per turn
|
| 500 |
-
_MAX_RESEARCH =
|
| 501 |
_MAX_OSINT = 1 # OSINT dossiers per turn — a dozen live sources, pivoted
|
| 502 |
|
| 503 |
|
|
@@ -807,7 +831,7 @@ async def _run_lookup_loop(
|
|
| 807 |
try:
|
| 808 |
from backend.omega.research_engine import research_topic
|
| 809 |
note = await asyncio.wait_for(
|
| 810 |
-
research_topic(topic, persona=persona), timeout=
|
| 811 |
findings = getattr(note, "findings", None) or getattr(note, "summary", "")
|
| 812 |
if not isinstance(findings, str):
|
| 813 |
findings = str(findings)
|
|
@@ -853,8 +877,8 @@ async def _run_lookup_loop(
|
|
| 853 |
# worth having.
|
| 854 |
report = await asyncio.wait_for(
|
| 855 |
investigate(selector, depth=2, include_web_research=False,
|
| 856 |
-
persona=persona, timeout_s=
|
| 857 |
-
timeout=
|
| 858 |
block = (report.get("brief") or "")[:7000] or "Nothing was returned."
|
| 859 |
except asyncio.TimeoutError:
|
| 860 |
block = ("The OSINT pass took too long and was stopped. You did "
|
|
@@ -906,6 +930,27 @@ async def _run_lookup_loop(
|
|
| 906 |
evidence.append(f"YOU OPENED: {url}\nWHAT THE PAGE SAYS:\n{body}")
|
| 907 |
continue
|
| 908 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
if not reply.upper().startswith("SEARCH:"):
|
| 910 |
return reply
|
| 911 |
|
|
|
|
| 28 |
import asyncio
|
| 29 |
import base64
|
| 30 |
import hashlib
|
| 31 |
+
import json
|
| 32 |
import logging
|
| 33 |
import os
|
| 34 |
from backend.version import VERSION as _VERSION
|
|
|
|
| 430 |
# and NOTHING EVER READ IT — the accessor had no callers anywhere in the app,
|
| 431 |
# so every restart met the operator as a stranger while a record of them sat
|
| 432 |
# on disk.
|
| 433 |
+
# ONE STORE, NOT THREE.
|
| 434 |
+
#
|
| 435 |
+
# What the handset sends is now ABSORBED into modules/operator_memory, the
|
| 436 |
+
# single store the desktop brain and the autonomous initiative loop also
|
| 437 |
+
# read. Before this, a fact told to him on the phone reached `/api/chat` and
|
| 438 |
+
# nowhere else: the desktop knew nothing about it, and neither did anything
|
| 439 |
+
# acting on its own. He genuinely learned — on exactly one path out of
|
| 440 |
+
# several — which is indistinguishable from not learning if you happen to
|
| 441 |
+
# ask him somewhere else.
|
| 442 |
+
#
|
| 443 |
+
# The block sent back is the UNION, so a fact learned at the desktop is
|
| 444 |
+
# available to the phone on the very next turn without the phone ever having
|
| 445 |
+
# seen it.
|
| 446 |
learned = context.get("learned")
|
| 447 |
+
merged: list[str] = []
|
| 448 |
+
try:
|
| 449 |
+
from modules.operator_memory import absorb_block, recall
|
| 450 |
+
if isinstance(learned, str) and learned.strip():
|
| 451 |
+
absorb_block(learned, source="phone")
|
| 452 |
+
merged = recall(40)
|
| 453 |
+
except Exception as mem_exc:
|
| 454 |
+
log.warning("operator memory unavailable: %s", mem_exc)
|
| 455 |
+
if isinstance(learned, str) and learned.strip():
|
| 456 |
+
merged = [ln.strip() for ln in learned.splitlines() if ln.strip()]
|
| 457 |
+
|
| 458 |
+
if merged:
|
| 459 |
# Newest last, so the most recent is closest to the question.
|
| 460 |
state["what you have learned about this operator before today"] = (
|
| 461 |
+
" ⏐ ".join(merged[-25:])[:3000])
|
| 462 |
|
| 463 |
# THE AUDIO PATH, STATED, because the alternative is that it gets invented.
|
| 464 |
#
|
|
|
|
| 517 |
# even though nothing had failed. Real research is: search, read what came back,
|
| 518 |
# search again for the specific thing. That is three searches minimum before a
|
| 519 |
# word is said.
|
| 520 |
+
_MAX_ROUNDS = 9 # hard stop on the whole turn
|
| 521 |
_MAX_SEARCHES = 4 # web lookups per turn
|
| 522 |
_MAX_PC_RUNS = 2 # desktop executions per turn
|
| 523 |
_MAX_FETCHES = 3 # pages actually opened and read per turn
|
| 524 |
+
_MAX_RESEARCH = 2 # full research-engine investigations per turn (it is slow)
|
| 525 |
_MAX_OSINT = 1 # OSINT dossiers per turn — a dozen live sources, pivoted
|
| 526 |
|
| 527 |
|
|
|
|
| 831 |
try:
|
| 832 |
from backend.omega.research_engine import research_topic
|
| 833 |
note = await asyncio.wait_for(
|
| 834 |
+
research_topic(topic, persona=persona), timeout=150)
|
| 835 |
findings = getattr(note, "findings", None) or getattr(note, "summary", "")
|
| 836 |
if not isinstance(findings, str):
|
| 837 |
findings = str(findings)
|
|
|
|
| 877 |
# worth having.
|
| 878 |
report = await asyncio.wait_for(
|
| 879 |
investigate(selector, depth=2, include_web_research=False,
|
| 880 |
+
persona=persona, timeout_s=180),
|
| 881 |
+
timeout=200)
|
| 882 |
block = (report.get("brief") or "")[:7000] or "Nothing was returned."
|
| 883 |
except asyncio.TimeoutError:
|
| 884 |
block = ("The OSINT pass took too long and was stopped. You did "
|
|
|
|
| 930 |
evidence.append(f"YOU OPENED: {url}\nWHAT THE PAGE SAYS:\n{body}")
|
| 931 |
continue
|
| 932 |
|
| 933 |
+
# DIAGNOSE: run live diagnostics on the backend subsystems.
|
| 934 |
+
#
|
| 935 |
+
# When the operator says something is broken, this checks what is
|
| 936 |
+
# actually broken — real imports, real tracebacks — rather than
|
| 937 |
+
# guessing or pretending to have fixed it.
|
| 938 |
+
if reply.upper().startswith("DIAGNOSE:"):
|
| 939 |
+
subsystem = reply.split(":", 1)[1].strip().lower()
|
| 940 |
+
log.info("chat: assistant running diagnostics: %s", subsystem or "all")
|
| 941 |
+
try:
|
| 942 |
+
from backend.services.live_diagnostics import full_report, diagnose_subsystem, quick_fix
|
| 943 |
+
if subsystem.startswith("fix "):
|
| 944 |
+
target = subsystem[4:].strip()
|
| 945 |
+
fix_result = await quick_fix(target)
|
| 946 |
+
block = json.dumps(fix_result, indent=2, default=str)[:3000]
|
| 947 |
+
else:
|
| 948 |
+
block = full_report()
|
| 949 |
+
except Exception as exc:
|
| 950 |
+
block = f"Diagnostics failed to run: {str(exc)[:300]}"
|
| 951 |
+
evidence.append(f"YOU RAN SYSTEM DIAGNOSTICS:\n{block}")
|
| 952 |
+
continue
|
| 953 |
+
|
| 954 |
if not reply.upper().startswith("SEARCH:"):
|
| 955 |
return reply
|
| 956 |
|
backend/routes/voice_routes.py
CHANGED
|
@@ -26,6 +26,18 @@ async def speak(p: SpeakPayload):
|
|
| 26 |
except Exception as e:
|
| 27 |
raise HTTPException(status_code=500, detail=str(e))
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
@router.post("/space_ack")
|
| 30 |
async def space_ack(p: SpaceAckPayload):
|
| 31 |
"""
|
|
|
|
| 26 |
except Exception as e:
|
| 27 |
raise HTTPException(status_code=500, detail=str(e))
|
| 28 |
|
| 29 |
+
@router.get("/speak")
|
| 30 |
+
async def speak_get(text: str = "", voice: str = "jarvis", agent: str = "", format: str = "wav"):
|
| 31 |
+
"""Synthesise TTS via XTTS-v2 for phone MediaPlayer GET streams."""
|
| 32 |
+
try:
|
| 33 |
+
persona = voice or agent or "jarvis"
|
| 34 |
+
from backend.voice.tts import TTSPipeline
|
| 35 |
+
tts = TTSPipeline()
|
| 36 |
+
audio_bytes: bytes = await tts.synthesize(text, personality=persona)
|
| 37 |
+
return Response(content=audio_bytes, media_type="audio/wav")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 40 |
+
|
| 41 |
@router.post("/space_ack")
|
| 42 |
async def space_ack(p: SpaceAckPayload):
|
| 43 |
"""
|
backend/services/live_diagnostics.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live diagnostics service for F.R.I.D.A.Y OMEGA.
|
| 2 |
+
|
| 3 |
+
When the operator reports a problem, this module actually checks what is
|
| 4 |
+
broken rather than guessing. It imports each subsystem, runs quick probes,
|
| 5 |
+
and returns real tracebacks and test results so the assistant can explain
|
| 6 |
+
what is wrong and attempt targeted fixes.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import importlib
|
| 13 |
+
import logging
|
| 14 |
+
import sys
|
| 15 |
+
import traceback
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# Subsystems to check and their health-probe signatures.
|
| 21 |
+
_SUBSYSTEMS: dict[str, dict[str, Any]] = {
|
| 22 |
+
"osint_engine": {
|
| 23 |
+
"module": "backend.omega.osint_engine",
|
| 24 |
+
"check_attrs": ["investigate", "classify", "COLLECTORS"],
|
| 25 |
+
"description": "OSINT investigation engine - registries, DNS, certs, pivots",
|
| 26 |
+
},
|
| 27 |
+
"research_engine": {
|
| 28 |
+
"module": "backend.omega.research_engine",
|
| 29 |
+
"check_attrs": ["research_topic"],
|
| 30 |
+
"description": "Deep web research engine - search, scrape, synthesise",
|
| 31 |
+
},
|
| 32 |
+
"image_forensics": {
|
| 33 |
+
"module": "backend.tools.image_forensics",
|
| 34 |
+
"check_attrs": ["analyse_image", "read_metadata", "find_hidden", "find_watermark"],
|
| 35 |
+
"description": "Image metadata and forensics - EXIF, XMP, steganography",
|
| 36 |
+
},
|
| 37 |
+
"web_search": {
|
| 38 |
+
"module": "backend.tools.web_search_tools",
|
| 39 |
+
"check_attrs": ["search_web", "fetch_page"],
|
| 40 |
+
"description": "Web search and page fetching",
|
| 41 |
+
},
|
| 42 |
+
"holehe": {
|
| 43 |
+
"module": "holehe.core",
|
| 44 |
+
"check_attrs": ["import_submodules"],
|
| 45 |
+
"description": "Holehe email account discovery",
|
| 46 |
+
},
|
| 47 |
+
"token_manager": {
|
| 48 |
+
"module": "backend.services.token_manager",
|
| 49 |
+
"check_attrs": ["gemini_call_with_checkpoint"],
|
| 50 |
+
"description": "Gemini API token manager",
|
| 51 |
+
},
|
| 52 |
+
"mobile_bridge": {
|
| 53 |
+
"module": "backend.routes.mobile_bridge_routes",
|
| 54 |
+
"check_attrs": ["router"],
|
| 55 |
+
"description": "Mobile bridge - phone <-> cloud communication",
|
| 56 |
+
},
|
| 57 |
+
"internet_routes": {
|
| 58 |
+
"module": "backend.routes.internet_routes",
|
| 59 |
+
"check_attrs": ["router"],
|
| 60 |
+
"description": "Internet routes - OSINT, search, image analysis endpoints",
|
| 61 |
+
},
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def diagnose_subsystem(name: str = "") -> dict[str, Any]:
|
| 66 |
+
"""Check one or all subsystems. Returns real tracebacks, not guesses."""
|
| 67 |
+
targets = {name: _SUBSYSTEMS[name]} if name and name in _SUBSYSTEMS else _SUBSYSTEMS
|
| 68 |
+
results: dict[str, Any] = {}
|
| 69 |
+
|
| 70 |
+
for sys_name, info in targets.items():
|
| 71 |
+
entry: dict[str, Any] = {
|
| 72 |
+
"module": info["module"],
|
| 73 |
+
"description": info["description"],
|
| 74 |
+
"status": "UNKNOWN",
|
| 75 |
+
"details": [],
|
| 76 |
+
}
|
| 77 |
+
try:
|
| 78 |
+
mod = importlib.import_module(info["module"])
|
| 79 |
+
entry["status"] = "LOADED"
|
| 80 |
+
entry["details"].append(f"Module imported successfully")
|
| 81 |
+
|
| 82 |
+
missing = []
|
| 83 |
+
for attr in info.get("check_attrs", []):
|
| 84 |
+
if not hasattr(mod, attr):
|
| 85 |
+
missing.append(attr)
|
| 86 |
+
if missing:
|
| 87 |
+
entry["status"] = "DEGRADED"
|
| 88 |
+
entry["details"].append(f"Missing expected attributes: {', '.join(missing)}")
|
| 89 |
+
else:
|
| 90 |
+
entry["details"].append(
|
| 91 |
+
f"All expected attributes present: {', '.join(info.get('check_attrs', []))}")
|
| 92 |
+
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
entry["status"] = "FAILED"
|
| 95 |
+
entry["error"] = str(exc)
|
| 96 |
+
entry["traceback"] = traceback.format_exc()
|
| 97 |
+
entry["details"].append(f"Import failed: {exc}")
|
| 98 |
+
|
| 99 |
+
# Classify the failure
|
| 100 |
+
err = str(exc).lower()
|
| 101 |
+
if "no module named" in err:
|
| 102 |
+
pkg = str(exc).split("'")[1] if "'" in str(exc) else str(exc)
|
| 103 |
+
entry["fix_suggestion"] = f"pip install {pkg.split('.')[0]}"
|
| 104 |
+
elif "cannot import name" in err:
|
| 105 |
+
entry["fix_suggestion"] = (
|
| 106 |
+
"A function or class was renamed or removed. "
|
| 107 |
+
"Check the import statement against the module's actual exports.")
|
| 108 |
+
|
| 109 |
+
results[sys_name] = entry
|
| 110 |
+
|
| 111 |
+
healthy = sum(1 for r in results.values() if r["status"] == "LOADED")
|
| 112 |
+
degraded = sum(1 for r in results.values() if r["status"] == "DEGRADED")
|
| 113 |
+
failed = sum(1 for r in results.values() if r["status"] == "FAILED")
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
"summary": f"{healthy} healthy, {degraded} degraded, {failed} failed out of {len(results)}",
|
| 117 |
+
"subsystems": results,
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
async def quick_fix(subsystem: str, action: str = "reload") -> dict[str, Any]:
|
| 122 |
+
"""Attempt a targeted fix on a subsystem."""
|
| 123 |
+
if subsystem not in _SUBSYSTEMS:
|
| 124 |
+
return {"error": f"Unknown subsystem '{subsystem}'. Known: {list(_SUBSYSTEMS.keys())}"}
|
| 125 |
+
|
| 126 |
+
info = _SUBSYSTEMS[subsystem]
|
| 127 |
+
module_name = info["module"]
|
| 128 |
+
result: dict[str, Any] = {"subsystem": subsystem, "action": action}
|
| 129 |
+
|
| 130 |
+
if action == "reload":
|
| 131 |
+
try:
|
| 132 |
+
if module_name in sys.modules:
|
| 133 |
+
importlib.reload(sys.modules[module_name])
|
| 134 |
+
result["status"] = "RELOADED"
|
| 135 |
+
result["message"] = f"Module {module_name} reloaded successfully."
|
| 136 |
+
else:
|
| 137 |
+
importlib.import_module(module_name)
|
| 138 |
+
result["status"] = "LOADED"
|
| 139 |
+
result["message"] = f"Module {module_name} loaded for the first time."
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
result["status"] = "FAILED"
|
| 142 |
+
result["error"] = str(exc)
|
| 143 |
+
result["traceback"] = traceback.format_exc()
|
| 144 |
+
|
| 145 |
+
elif action == "install":
|
| 146 |
+
# Try to install the missing package
|
| 147 |
+
import subprocess
|
| 148 |
+
pkg = module_name.split(".")[0]
|
| 149 |
+
try:
|
| 150 |
+
proc = await asyncio.create_subprocess_exec(
|
| 151 |
+
sys.executable, "-m", "pip", "install", pkg,
|
| 152 |
+
stdout=asyncio.subprocess.PIPE,
|
| 153 |
+
stderr=asyncio.subprocess.PIPE,
|
| 154 |
+
)
|
| 155 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
| 156 |
+
result["status"] = "INSTALLED" if proc.returncode == 0 else "INSTALL_FAILED"
|
| 157 |
+
result["output"] = (stdout or b"").decode()[-500:]
|
| 158 |
+
if proc.returncode != 0:
|
| 159 |
+
result["error"] = (stderr or b"").decode()[-500:]
|
| 160 |
+
except Exception as exc:
|
| 161 |
+
result["status"] = "INSTALL_FAILED"
|
| 162 |
+
result["error"] = str(exc)
|
| 163 |
+
|
| 164 |
+
else:
|
| 165 |
+
result["error"] = f"Unknown action '{action}'. Supported: reload, install"
|
| 166 |
+
|
| 167 |
+
return result
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def full_report() -> str:
|
| 171 |
+
"""A plain-text diagnostic report suitable for the model to read and relay."""
|
| 172 |
+
diag = diagnose_subsystem()
|
| 173 |
+
lines = [f"SYSTEM DIAGNOSTICS -- {diag['summary']}\n"]
|
| 174 |
+
|
| 175 |
+
for name, info in diag["subsystems"].items():
|
| 176 |
+
status_icon = {"LOADED": "[OK]", "DEGRADED": "[WARN]", "FAILED": "[FAIL]"}.get(
|
| 177 |
+
info["status"], "[?]")
|
| 178 |
+
lines.append(f" {status_icon} {name}: {info['status']}")
|
| 179 |
+
lines.append(f" {info['description']}")
|
| 180 |
+
for detail in info.get("details", []):
|
| 181 |
+
lines.append(f" {detail}")
|
| 182 |
+
if info.get("error"):
|
| 183 |
+
lines.append(f" ERROR: {info['error']}")
|
| 184 |
+
if info.get("fix_suggestion"):
|
| 185 |
+
lines.append(f" SUGGESTED FIX: {info['fix_suggestion']}")
|
| 186 |
+
lines.append("")
|
| 187 |
+
|
| 188 |
+
return "\n".join(lines)
|
backend/tools/image_forensics.py
CHANGED
|
@@ -99,37 +99,58 @@ def _sniff(data: bytes) -> str:
|
|
| 99 |
# Exif and GPS sub-IFDs on many files, which is where the interesting half of
|
| 100 |
# the record lives — lens, serial number, GPS, the original timestamp.
|
| 101 |
def _exif_dict(img) -> dict[str, Any]:
|
| 102 |
-
from PIL import ExifTags
|
| 103 |
|
| 104 |
out: dict[str, Any] = {}
|
|
|
|
| 105 |
try:
|
| 106 |
exif = img.getexif()
|
| 107 |
except Exception as exc:
|
| 108 |
-
|
|
|
|
| 109 |
if not exif:
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
def _put(name: str, value: Any) -> None:
|
| 113 |
out[name] = _jsonable(value)
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
#
|
| 119 |
-
|
| 120 |
-
("Exif", 0x8769, ExifTags.TAGS),
|
| 121 |
-
("GPS", 0x8825, ExifTags.GPSTAGS),
|
| 122 |
-
("Interop", 0xA005, ExifTags.TAGS),
|
| 123 |
-
):
|
| 124 |
try:
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
except Exception:
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
continue
|
| 130 |
-
for tag_id, value in sub.items():
|
| 131 |
-
name = table.get(tag_id, f"{ifd_name}Tag{tag_id}")
|
| 132 |
-
_put(f"{ifd_name}.{name}", value)
|
| 133 |
return out
|
| 134 |
|
| 135 |
|
|
@@ -1703,13 +1724,13 @@ async def analyse_image(
|
|
| 1703 |
if pivots:
|
| 1704 |
report["osint_pivots"] = pivots
|
| 1705 |
try:
|
| 1706 |
-
from backend.tools.web_search_tools import
|
| 1707 |
searches = []
|
| 1708 |
for term in pivots[:3]:
|
| 1709 |
try:
|
| 1710 |
searches.append({"query": term,
|
| 1711 |
"results": await asyncio.wait_for(
|
| 1712 |
-
|
| 1713 |
except Exception as exc:
|
| 1714 |
searches.append({"query": term, "error": str(exc)})
|
| 1715 |
report["pivot_searches"] = searches
|
|
@@ -1735,6 +1756,47 @@ def summarise(report: dict[str, Any]) -> str:
|
|
| 1735 |
f"({image.get('megapixels')} MP), {file_info.get('bytes', 0):,} bytes, "
|
| 1736 |
f"sha256 {str(file_info.get('sha256'))[:16]}...")
|
| 1737 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1738 |
for section, label in (("metadata", "METADATA"), ("hidden", "HIDDEN DATA"),
|
| 1739 |
("watermark", "WATERMARK / TAMPERING")):
|
| 1740 |
block = report.get(section) or {}
|
|
|
|
| 99 |
# Exif and GPS sub-IFDs on many files, which is where the interesting half of
|
| 100 |
# the record lives — lens, serial number, GPS, the original timestamp.
|
| 101 |
def _exif_dict(img) -> dict[str, Any]:
|
| 102 |
+
from PIL import ExifTags, Image
|
| 103 |
|
| 104 |
out: dict[str, Any] = {}
|
| 105 |
+
exif = None
|
| 106 |
try:
|
| 107 |
exif = img.getexif()
|
| 108 |
except Exception as exc:
|
| 109 |
+
pass
|
| 110 |
+
|
| 111 |
if not exif:
|
| 112 |
+
try:
|
| 113 |
+
raw_blob = img.info.get("exif")
|
| 114 |
+
if raw_blob and isinstance(raw_blob, (bytes, bytearray)):
|
| 115 |
+
exif = Image.Exif()
|
| 116 |
+
exif.load(raw_blob)
|
| 117 |
+
except Exception:
|
| 118 |
+
pass
|
| 119 |
|
| 120 |
def _put(name: str, value: Any) -> None:
|
| 121 |
out[name] = _jsonable(value)
|
| 122 |
|
| 123 |
+
if exif:
|
| 124 |
+
for tag_id, value in exif.items():
|
| 125 |
+
_put(ExifTags.TAGS.get(tag_id, f"Tag{tag_id}"), value)
|
| 126 |
+
|
| 127 |
+
# The sub-IFDs, by their real pointers.
|
| 128 |
+
for ifd_name, ifd_id, table in (
|
| 129 |
+
("Exif", 0x8769, ExifTags.TAGS),
|
| 130 |
+
("GPS", 0x8825, ExifTags.GPSTAGS),
|
| 131 |
+
("Interop", 0xA005, ExifTags.TAGS),
|
| 132 |
+
):
|
| 133 |
+
try:
|
| 134 |
+
sub = exif.get_ifd(ifd_id)
|
| 135 |
+
except Exception:
|
| 136 |
+
continue
|
| 137 |
+
if not sub:
|
| 138 |
+
continue
|
| 139 |
+
for tag_id, value in sub.items():
|
| 140 |
+
name = table.get(tag_id, f"{ifd_name}Tag{tag_id}")
|
| 141 |
+
_put(f"{ifd_name}.{name}", value)
|
| 142 |
|
| 143 |
+
# Legacy _getexif fallback if still empty
|
| 144 |
+
if not out:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
try:
|
| 146 |
+
raw_dict = getattr(img, "_getexif", lambda: None)()
|
| 147 |
+
if raw_dict and isinstance(raw_dict, dict):
|
| 148 |
+
for tag_id, value in raw_dict.items():
|
| 149 |
+
name = ExifTags.TAGS.get(tag_id, f"Tag{tag_id}")
|
| 150 |
+
_put(name, value)
|
| 151 |
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return out
|
| 155 |
|
| 156 |
|
|
|
|
| 1724 |
if pivots:
|
| 1725 |
report["osint_pivots"] = pivots
|
| 1726 |
try:
|
| 1727 |
+
from backend.tools.web_search_tools import search_web
|
| 1728 |
searches = []
|
| 1729 |
for term in pivots[:3]:
|
| 1730 |
try:
|
| 1731 |
searches.append({"query": term,
|
| 1732 |
"results": await asyncio.wait_for(
|
| 1733 |
+
search_web(term, num_results=3), timeout=40)})
|
| 1734 |
except Exception as exc:
|
| 1735 |
searches.append({"query": term, "error": str(exc)})
|
| 1736 |
report["pivot_searches"] = searches
|
|
|
|
| 1756 |
f"({image.get('megapixels')} MP), {file_info.get('bytes', 0):,} bytes, "
|
| 1757 |
f"sha256 {str(file_info.get('sha256'))[:16]}...")
|
| 1758 |
|
| 1759 |
+
# Always show the raw metadata fields — the user wants to know WHAT
|
| 1760 |
+
# metadata exists, not just whether something "notable" was found.
|
| 1761 |
+
exif = metadata.get("exif") or {}
|
| 1762 |
+
if exif:
|
| 1763 |
+
lines.append("EXIF FIELDS PRESENT:")
|
| 1764 |
+
priority_keys = [
|
| 1765 |
+
("Make", "Camera Make"), ("Model", "Camera Model"),
|
| 1766 |
+
("Software", "Software"), ("DateTime", "Date/Time"),
|
| 1767 |
+
("Exif.DateTimeOriginal", "Original Date"),
|
| 1768 |
+
("Exif.LensModel", "Lens"),
|
| 1769 |
+
("Exif.BodySerialNumber", "Serial Number"),
|
| 1770 |
+
("Orientation", "Orientation"),
|
| 1771 |
+
("Exif.FNumber", "Aperture"), ("Exif.ISOSpeedRatings", "ISO"),
|
| 1772 |
+
("Exif.ExposureTime", "Shutter Speed"),
|
| 1773 |
+
("Exif.FocalLength", "Focal Length"),
|
| 1774 |
+
]
|
| 1775 |
+
for key, label in priority_keys:
|
| 1776 |
+
if exif.get(key) not in (None, ""):
|
| 1777 |
+
lines.append(f" {label}: {exif[key]}")
|
| 1778 |
+
# Also list any GPS info
|
| 1779 |
+
gps_keys = [k for k in exif if k.startswith("GPS.")]
|
| 1780 |
+
if gps_keys:
|
| 1781 |
+
lines.append(" GPS data: present")
|
| 1782 |
+
# Count total fields
|
| 1783 |
+
lines.append(f" (Total EXIF fields: {len(exif)})")
|
| 1784 |
+
|
| 1785 |
+
xmp = metadata.get("xmp_fields") or {}
|
| 1786 |
+
if xmp:
|
| 1787 |
+
lines.append(f"XMP FIELDS PRESENT ({len(xmp)} fields):")
|
| 1788 |
+
for field, values in list(xmp.items())[:8]:
|
| 1789 |
+
lines.append(f" {field}: {', '.join(str(v) for v in values[:2])}")
|
| 1790 |
+
|
| 1791 |
+
iptc = metadata.get("iptc") or {}
|
| 1792 |
+
if iptc:
|
| 1793 |
+
lines.append(f"IPTC FIELDS PRESENT ({len(iptc)} fields):")
|
| 1794 |
+
for field, values in list(iptc.items())[:6]:
|
| 1795 |
+
lines.append(f" {field}: {', '.join(str(v) for v in values[:2])}")
|
| 1796 |
+
|
| 1797 |
+
if not exif and not xmp and not iptc:
|
| 1798 |
+
lines.append("NO METADATA: This file has been stripped of all EXIF/XMP/IPTC data.")
|
| 1799 |
+
|
| 1800 |
for section, label in (("metadata", "METADATA"), ("hidden", "HIDDEN DATA"),
|
| 1801 |
("watermark", "WATERMARK / TAMPERING")):
|
| 1802 |
block = report.get(section) or {}
|
backend/tools/system_tools.py
CHANGED
|
@@ -27,6 +27,59 @@ async def open_app_tool(app_name: str = "", *args, **kwargs):
|
|
| 27 |
except Exception as e:
|
| 28 |
return f"Failed to open app: {e}"
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
async def usb_devices_tool(*args, **kwargs):
|
| 31 |
from backend.services.usb_monitor import get_active_usb_drives
|
| 32 |
if os.environ.get('CLOUD_ENV', 'false').lower() == 'true':
|
|
@@ -99,4 +152,4 @@ async def backup_vault_tool(drive_letter: str = None, *args, **kwargs):
|
|
| 99 |
encrypt_directory(staging_dir, target)
|
| 100 |
|
| 101 |
await asyncio.to_thread(perform_vault_backup)
|
| 102 |
-
return f"Master Vault Backup ({target}) completed successfully! All code, cloud servers, and EXE databases encrypted."
|
|
|
|
| 27 |
except Exception as e:
|
| 28 |
return f"Failed to open app: {e}"
|
| 29 |
|
| 30 |
+
async def type_into_tool(text: str = "", window: str = "", *args, **kwargs):
|
| 31 |
+
"""Type TEXT into the window whose title contains WINDOW, and prove it landed.
|
| 32 |
+
|
| 33 |
+
This exists because "open the terminal and write claude" opened the terminal
|
| 34 |
+
and typed nothing, while reporting "Done. 3 steps". There was no typing tool
|
| 35 |
+
in the registry at all, so the planner expressed it as raw shell — usually a
|
| 36 |
+
PowerShell SendKeys — and omega_executor scores a shell step by
|
| 37 |
+
`returncode == 0`. SendKeys exits 0 whether or not any window received the
|
| 38 |
+
keystrokes, so a step that did nothing was indistinguishable from one that
|
| 39 |
+
worked.
|
| 40 |
+
|
| 41 |
+
`window` matters. Without it the keys go wherever focus happens to be, which
|
| 42 |
+
after launching an app is a race against that app finishing its startup.
|
| 43 |
+
With it, focus is taken deliberately and VERIFIED before a key is sent, and
|
| 44 |
+
a failure to win focus is reported as a failure rather than typed into
|
| 45 |
+
whatever was in front.
|
| 46 |
+
"""
|
| 47 |
+
if os.environ.get('CLOUD_ENV', 'false').lower() == 'true':
|
| 48 |
+
from backend.ws.agent_ws import ws_manager
|
| 49 |
+
await ws_manager.broadcast({
|
| 50 |
+
"event": "system:execute",
|
| 51 |
+
"payload": {"cmd": f"type_into::{window}::{text}"}
|
| 52 |
+
})
|
| 53 |
+
return f"Dispatched typing request to the connected PC Relay Client."
|
| 54 |
+
if not (text or "").strip():
|
| 55 |
+
return "Nothing to type."
|
| 56 |
+
try:
|
| 57 |
+
from modules.input_control import type_into
|
| 58 |
+
res = type_into(text, window=window)
|
| 59 |
+
# The word "Failed" is load-bearing: omega_executor's repair loop and the
|
| 60 |
+
# spoken summary both key off whether this reads as success.
|
| 61 |
+
return (res.message if res.ok else f"Failed: {res.message}")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
return f"Failed: typing unavailable ({e})"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def focus_window_tool(window: str = "", *args, **kwargs):
|
| 67 |
+
"""Bring a window to the front and confirm it actually came to the front."""
|
| 68 |
+
if os.environ.get('CLOUD_ENV', 'false').lower() == 'true':
|
| 69 |
+
from backend.ws.agent_ws import ws_manager
|
| 70 |
+
await ws_manager.broadcast({
|
| 71 |
+
"event": "system:execute",
|
| 72 |
+
"payload": {"cmd": f"focus_window::{window}"}
|
| 73 |
+
})
|
| 74 |
+
return "Dispatched focus request to the connected PC Relay Client."
|
| 75 |
+
try:
|
| 76 |
+
from modules.input_control import focus_window
|
| 77 |
+
res = focus_window(window)
|
| 78 |
+
return (res.message if res.ok else f"Failed: {res.message}")
|
| 79 |
+
except Exception as e:
|
| 80 |
+
return f"Failed: focus unavailable ({e})"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
async def usb_devices_tool(*args, **kwargs):
|
| 84 |
from backend.services.usb_monitor import get_active_usb_drives
|
| 85 |
if os.environ.get('CLOUD_ENV', 'false').lower() == 'true':
|
|
|
|
| 152 |
encrypt_directory(staging_dir, target)
|
| 153 |
|
| 154 |
await asyncio.to_thread(perform_vault_backup)
|
| 155 |
+
return f"Master Vault Backup ({target}) completed successfully! All code, cloud servers, and EXE databases encrypted."
|
backend/tools/tool_registry.py
CHANGED
|
@@ -3,7 +3,10 @@ from backend.tools.filesystem_tools import read_file as read_file_tool, write_fi
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -32,6 +35,12 @@ TOOL_REGISTRY = {
|
|
| 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,
|
|
|
|
| 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 (
|
| 7 |
+
screenshot_tool, open_app_tool, usb_devices_tool, notification_tool,
|
| 8 |
+
system_stats_tool, type_into_tool, focus_window_tool,
|
| 9 |
+
)
|
| 10 |
from backend.tools.calendar_tools import list_events as calendar_tool
|
| 11 |
from backend.tools.email_tools import send_email as email_tool
|
| 12 |
from backend.tools.xr_tools import xr_anchor_tool, gesture_context_tool, ar_control_tool
|
|
|
|
| 35 |
"memory_search": memory_search_tool,
|
| 36 |
"take_screenshot": screenshot_tool,
|
| 37 |
"open_app": open_app_tool,
|
| 38 |
+
# Typing and focus, with PROOF. Not a limit on what he may do — shell,
|
| 39 |
+
# python and freshly written code remain the general path — but the common
|
| 40 |
+
# "open X and type Y" case had no reliable route and was silently failing
|
| 41 |
+
# through raw SendKeys, which exits 0 whether or not anything received it.
|
| 42 |
+
"type_into": type_into_tool,
|
| 43 |
+
"focus_window": focus_window_tool,
|
| 44 |
"usb_get_devices": usb_devices_tool,
|
| 45 |
"send_notification": notification_tool,
|
| 46 |
"get_system_stats": system_stats_tool,
|
backend/voice/engines/kokoro_engine.py
CHANGED
|
@@ -222,10 +222,10 @@ def _load_profile(name: str, spec: dict):
|
|
| 222 |
logger.warning("[RVC] No index for profile %s — timbre retrieval off.", name)
|
| 223 |
rvc.load_model(model_path)
|
| 224 |
|
| 225 |
-
rvc.set_params(f0up_key=spec["f0up_key"], f0method="
|
| 226 |
index_rate=0.65, filter_radius=1, resample_sr=0,
|
| 227 |
rms_mix_rate=0.0, protect=0.33)
|
| 228 |
-
logger.info("[RVC] Profile %s loaded on %s (f0up_key=%d).",
|
| 229 |
name, device, spec["f0up_key"])
|
| 230 |
return rvc
|
| 231 |
|
|
@@ -385,8 +385,8 @@ def _get_rvc_locked(name: str):
|
|
| 385 |
# flat, which is easily enough to read as a different person however
|
| 386 |
# well the EQ matches. -1 lands near 130 Hz, the closest integer step
|
| 387 |
# to the target. Everything else in the matrix is untouched.
|
| 388 |
-
_rvc_model.set_params(f0up_key=-1, f0method='
|
| 389 |
-
logger.info("[RVC] JARVIS Ultimate Model loaded and parameters locked.")
|
| 390 |
except ImportError:
|
| 391 |
logger.warning("[RVC] rvc_python not installed. Running Kokoro in pure fallback mode without RVC.")
|
| 392 |
_rvc_model = "FALLBACK"
|
|
|
|
| 222 |
logger.warning("[RVC] No index for profile %s — timbre retrieval off.", name)
|
| 223 |
rvc.load_model(model_path)
|
| 224 |
|
| 225 |
+
rvc.set_params(f0up_key=spec["f0up_key"], f0method="pm",
|
| 226 |
index_rate=0.65, filter_radius=1, resample_sr=0,
|
| 227 |
rms_mix_rate=0.0, protect=0.33)
|
| 228 |
+
logger.info("[RVC] Profile %s loaded on %s (f0up_key=%d, f0method=pm).",
|
| 229 |
name, device, spec["f0up_key"])
|
| 230 |
return rvc
|
| 231 |
|
|
|
|
| 385 |
# flat, which is easily enough to read as a different person however
|
| 386 |
# well the EQ matches. -1 lands near 130 Hz, the closest integer step
|
| 387 |
# to the target. Everything else in the matrix is untouched.
|
| 388 |
+
_rvc_model.set_params(f0up_key=-1, f0method='pm', index_rate=0.65, rms_mix_rate=0.0, filter_radius=1, protect=0.33)
|
| 389 |
+
logger.info("[RVC] JARVIS Ultimate Model loaded (f0method=pm) and parameters locked.")
|
| 390 |
except ImportError:
|
| 391 |
logger.warning("[RVC] rvc_python not installed. Running Kokoro in pure fallback mode without RVC.")
|
| 392 |
_rvc_model = "FALLBACK"
|