feat: security hardening, transcription mode, 15 new features, Gradio UI
Browse filesSecurity (12 fixes):
- SQL injection prevention in PRAGMA key (hex encoding + validation)
- Placeholder API keys in .env.example
- User ID spoofing protection with whitelist validation
- WebSocket message length limit (10k chars)
- CORS middleware with configurable origins
- Auth query param deprecation warning
- Rate limit memory leak fix + auth brute-force protection (5/min)
- Tool argument validation and type coercion
- Extended sensitive file blocklist (.pem, .key, .p12, id_rsa, etc.)
- Generic error messages (no exception type leakage)
- SQLite connection timeout
- Auth endpoint rate limiting
Transcription mode:
- "Jarvis transcribe this" β continuous speech capture to Apple Notes
- Timestamped chunks with punctuation restoration
- Basic speaker diarization (Speaker 1/2)
- Keepalive thread bypasses 10-min idle timeout
- Overlay shows transcription state with chunk counter
New features (15):
- Echo cancellation β mute wake listener during speak()
- Multilingual support β JARVIS_LANGUAGE env var
- Custom TTS β ElevenLabs integration with macOS say fallback
- Photos app tools (recent, search, albums, open)
- Focus mode tools (status, set DND/Work/Personal/Sleep)
- User-facing scheduler with SQLite persistence
- Web UI settings panel (language, voice, rate, jobs, automations)
- Automation rules engine (if-this-then-that API)
- Smart context awareness (time-of-day hints in system prompt)
- Terminal command output capture (run_in_terminal tool)
- Overlay transcription status (red pulsing orb, chunk count)
- Response streaming state in overlay
- Accessibility (skip link, ARIA roles, focus outlines)
- Gradio UI for HF Spaces (chat, voice, status, scheduler, settings)
- Settings + context REST APIs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .env.example +2 -2
- Dockerfile +1 -0
- gradio_app.py +290 -0
- jarvis_listener.py +326 -4
- memory.py +10 -2
- overlay.py +30 -2
- requirements-server.txt +3 -0
- scheduler.py +180 -1
- server.py +258 -14
- static/index.html +216 -1
- tools/__init__.py +29 -2
- tools/app_automation.py +246 -3
- tools/builtin.py +62 -6
|
@@ -1,9 +1,9 @@
|
|
| 1 |
# Claude API (optional β premium quality)
|
| 2 |
-
ANTHROPIC_API_KEY=sk-ant-api03-
|
| 3 |
|
| 4 |
# OpenRouter (optional key β free models work without it!)
|
| 5 |
# Get a key at https://openrouter.ai/keys for access to paid models
|
| 6 |
-
OPENROUTER_API_KEY=sk-or-v1-
|
| 7 |
OPENROUTER_MODEL=meta-llama/llama-3.1-8b-instruct:free
|
| 8 |
|
| 9 |
# Ollama (local, free β install from https://ollama.ai)
|
|
|
|
| 1 |
# Claude API (optional β premium quality)
|
| 2 |
+
ANTHROPIC_API_KEY=sk-ant-api03-YOUR-KEY-HERE
|
| 3 |
|
| 4 |
# OpenRouter (optional key β free models work without it!)
|
| 5 |
# Get a key at https://openrouter.ai/keys for access to paid models
|
| 6 |
+
OPENROUTER_API_KEY=sk-or-v1-YOUR-KEY-HERE
|
| 7 |
OPENROUTER_MODEL=meta-llama/llama-3.1-8b-instruct:free
|
| 8 |
|
| 9 |
# Ollama (local, free β install from https://ollama.ai)
|
|
@@ -8,6 +8,7 @@ RUN pip install --no-cache-dir -r requirements-server.txt
|
|
| 8 |
COPY . .
|
| 9 |
|
| 10 |
ENV PORT=7860
|
|
|
|
| 11 |
EXPOSE 7860
|
| 12 |
|
| 13 |
CMD ["python", "server.py"]
|
|
|
|
| 8 |
COPY . .
|
| 9 |
|
| 10 |
ENV PORT=7860
|
| 11 |
+
ENV GRADIO_SERVER_PORT=7860
|
| 12 |
EXPOSE 7860
|
| 13 |
|
| 14 |
CMD ["python", "server.py"]
|
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JARVIS Gradio UI β primary interface for HF Spaces deployment.
|
| 2 |
+
|
| 3 |
+
Provides:
|
| 4 |
+
- Chat interface with streaming responses
|
| 5 |
+
- Audio input (mic β transcription β response β TTS playback)
|
| 6 |
+
- Settings panel (language, TTS, backend selection)
|
| 7 |
+
- Tools status, scheduler, automation rules viewer
|
| 8 |
+
- Responsive mobile-friendly layout
|
| 9 |
+
|
| 10 |
+
Mounts on the existing FastAPI app at /gradio (or / on HF Spaces).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import json
|
| 15 |
+
import httpx
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
JARVIS_URL = os.getenv("JARVIS_URL", "http://localhost:8000")
|
| 19 |
+
_AUTH_TOKEN = os.getenv("JARVIS_AUTH_TOKEN", "")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _headers():
|
| 23 |
+
h = {"Content-Type": "application/json"}
|
| 24 |
+
if _AUTH_TOKEN:
|
| 25 |
+
h["Authorization"] = f"Bearer {_AUTH_TOKEN}"
|
| 26 |
+
return h
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _ask(message: str, history: list) -> str:
|
| 30 |
+
"""Send a message to the JARVIS API and get a response."""
|
| 31 |
+
try:
|
| 32 |
+
resp = httpx.post(
|
| 33 |
+
f"{JARVIS_URL}/api/ask",
|
| 34 |
+
json={"message": message},
|
| 35 |
+
headers=_headers(),
|
| 36 |
+
timeout=60,
|
| 37 |
+
)
|
| 38 |
+
data = resp.json()
|
| 39 |
+
return data.get("response", data.get("error", "No response"))
|
| 40 |
+
except httpx.ConnectError:
|
| 41 |
+
return "Cannot connect to JARVIS server. Is it running?"
|
| 42 |
+
except Exception as e:
|
| 43 |
+
return f"Error: {e}"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _ask_with_audio(audio_path: str, history: list) -> tuple[str, str | None]:
|
| 47 |
+
"""Process audio input: transcribe β ask JARVIS β return text + optional TTS audio."""
|
| 48 |
+
if not audio_path:
|
| 49 |
+
return "No audio received.", None
|
| 50 |
+
|
| 51 |
+
# Use Whisper via the server (or local) to transcribe
|
| 52 |
+
# For HF Spaces, we send the audio to the server's /api/ask endpoint
|
| 53 |
+
# The server handles transcription internally
|
| 54 |
+
try:
|
| 55 |
+
# Read and transcribe audio using Whisper
|
| 56 |
+
import numpy as np
|
| 57 |
+
try:
|
| 58 |
+
from mlx_whisper import transcribe
|
| 59 |
+
import soundfile as sf
|
| 60 |
+
audio_data, sr = sf.read(audio_path)
|
| 61 |
+
if len(audio_data.shape) > 1:
|
| 62 |
+
audio_data = audio_data.mean(axis=1)
|
| 63 |
+
if sr != 16000:
|
| 64 |
+
# Simple resample
|
| 65 |
+
ratio = 16000 / sr
|
| 66 |
+
indices = np.arange(0, len(audio_data), 1 / ratio).astype(int)
|
| 67 |
+
indices = indices[indices < len(audio_data)]
|
| 68 |
+
audio_data = audio_data[indices]
|
| 69 |
+
audio_data = audio_data.astype(np.float32)
|
| 70 |
+
result = transcribe(
|
| 71 |
+
audio_data,
|
| 72 |
+
path_or_hf_repo=os.getenv("JARVIS_LOCAL_WAKE_MODEL", "mlx-community/whisper-small-mlx"),
|
| 73 |
+
verbose=False,
|
| 74 |
+
language=os.getenv("JARVIS_LANGUAGE", "en"),
|
| 75 |
+
)
|
| 76 |
+
text = (result.get("text") or "").strip()
|
| 77 |
+
except ImportError:
|
| 78 |
+
# No local Whisper β use Google Speech Recognition as fallback
|
| 79 |
+
import speech_recognition as sr_lib
|
| 80 |
+
recognizer = sr_lib.Recognizer()
|
| 81 |
+
with sr_lib.AudioFile(audio_path) as source:
|
| 82 |
+
audio = recognizer.record(source)
|
| 83 |
+
text = recognizer.recognize_google(audio)
|
| 84 |
+
|
| 85 |
+
if not text:
|
| 86 |
+
return "Could not understand the audio.", None
|
| 87 |
+
|
| 88 |
+
# Send transcribed text to JARVIS
|
| 89 |
+
response = _ask(text, history)
|
| 90 |
+
return f"**You said:** {text}\n\n{response}", None
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
return f"Audio processing error: {e}", None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _get_status() -> str:
|
| 97 |
+
"""Get JARVIS system status."""
|
| 98 |
+
try:
|
| 99 |
+
resp = httpx.get(f"{JARVIS_URL}/api/status", headers=_headers(), timeout=5)
|
| 100 |
+
data = resp.json()
|
| 101 |
+
status = data.get("status", "unknown")
|
| 102 |
+
backend = data.get("backend", "unknown")
|
| 103 |
+
tools = data.get("tools", [])
|
| 104 |
+
return (
|
| 105 |
+
f"**Status:** {status}\n"
|
| 106 |
+
f"**Active Backend:** {backend}\n"
|
| 107 |
+
f"**Available Backends:** {', '.join(data.get('backends', []))}\n"
|
| 108 |
+
f"**Tools Loaded:** {len(tools)}\n"
|
| 109 |
+
f"**STM:** {'Active' if data.get('stm') else 'Off'}\n"
|
| 110 |
+
f"**AutoTune:** {'Active' if data.get('autotune') else 'Off'}"
|
| 111 |
+
)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
return f"Cannot reach JARVIS server: {e}"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _get_settings() -> dict:
|
| 117 |
+
"""Fetch current settings."""
|
| 118 |
+
try:
|
| 119 |
+
resp = httpx.get(f"{JARVIS_URL}/api/settings", headers=_headers(), timeout=5)
|
| 120 |
+
return resp.json()
|
| 121 |
+
except Exception:
|
| 122 |
+
return {}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _get_scheduled_jobs() -> str:
|
| 126 |
+
"""Get scheduled jobs as formatted text."""
|
| 127 |
+
try:
|
| 128 |
+
resp = httpx.get(f"{JARVIS_URL}/api/scheduler/jobs", headers=_headers(), timeout=5)
|
| 129 |
+
jobs = resp.json().get("jobs", [])
|
| 130 |
+
if not jobs:
|
| 131 |
+
return "No scheduled jobs."
|
| 132 |
+
lines = []
|
| 133 |
+
for j in jobs:
|
| 134 |
+
time_info = f"@ {j['run_at']}" if j.get("run_at") else f"every {j['interval_seconds']}s"
|
| 135 |
+
status = "enabled" if j.get("enabled") else "disabled"
|
| 136 |
+
lines.append(f"- **{j['name']}**: `{j['command']}` ({time_info}, {status})")
|
| 137 |
+
return "\n".join(lines)
|
| 138 |
+
except Exception as e:
|
| 139 |
+
return f"Error loading jobs: {e}"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _add_job(name: str, command: str, run_at: str) -> str:
|
| 143 |
+
"""Add a scheduled job."""
|
| 144 |
+
if not name or not command:
|
| 145 |
+
return "Name and command are required."
|
| 146 |
+
try:
|
| 147 |
+
resp = httpx.post(
|
| 148 |
+
f"{JARVIS_URL}/api/scheduler/jobs",
|
| 149 |
+
json={"name": name, "command": command, "run_at": run_at, "repeat_daily": bool(run_at)},
|
| 150 |
+
headers=_headers(),
|
| 151 |
+
timeout=5,
|
| 152 |
+
)
|
| 153 |
+
return f"Job '{name}' scheduled."
|
| 154 |
+
except Exception as e:
|
| 155 |
+
return f"Error: {e}"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _get_context() -> str:
|
| 159 |
+
"""Get smart context."""
|
| 160 |
+
try:
|
| 161 |
+
resp = httpx.get(f"{JARVIS_URL}/api/context", headers=_headers(), timeout=5)
|
| 162 |
+
data = resp.json()
|
| 163 |
+
ctx = (
|
| 164 |
+
f"**Time:** {data.get('date', '')} β {data.get('time_of_day', '')}\n"
|
| 165 |
+
f"**Timestamp:** {data.get('timestamp', '')}\n"
|
| 166 |
+
)
|
| 167 |
+
if data.get("location"):
|
| 168 |
+
ctx += f"**Location:** {data['location']['lat']:.4f}, {data['location']['lon']:.4f}\n"
|
| 169 |
+
if data.get("active_work_session"):
|
| 170 |
+
ctx += f"**Active Work Session:** {data['active_work_session']}\n"
|
| 171 |
+
return ctx
|
| 172 |
+
except Exception as e:
|
| 173 |
+
return f"Error: {e}"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ββ Build the Gradio Interface βββββββββββββββββββββββββββββββββββ
|
| 177 |
+
|
| 178 |
+
with gr.Blocks(title="J.A.R.V.I.S.") as demo:
|
| 179 |
+
gr.Markdown(
|
| 180 |
+
"# J.A.R.V.I.S.\n"
|
| 181 |
+
"*Just A Rather Very Intelligent System*"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
with gr.Tabs():
|
| 185 |
+
# ββ Chat Tab ββ
|
| 186 |
+
with gr.Tab("Chat", id="chat"):
|
| 187 |
+
chatbot = gr.Chatbot(
|
| 188 |
+
label="JARVIS",
|
| 189 |
+
height=450,
|
| 190 |
+
)
|
| 191 |
+
with gr.Row():
|
| 192 |
+
msg_input = gr.Textbox(
|
| 193 |
+
placeholder="Talk to JARVIS...",
|
| 194 |
+
show_label=False,
|
| 195 |
+
scale=8,
|
| 196 |
+
container=False,
|
| 197 |
+
)
|
| 198 |
+
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 199 |
+
|
| 200 |
+
with gr.Accordion("Voice Input", open=False):
|
| 201 |
+
audio_input = gr.Audio(
|
| 202 |
+
sources=["microphone"],
|
| 203 |
+
type="filepath",
|
| 204 |
+
label="Speak to JARVIS",
|
| 205 |
+
)
|
| 206 |
+
voice_output = gr.Markdown(label="Response")
|
| 207 |
+
|
| 208 |
+
def chat_respond(message, history):
|
| 209 |
+
if not message:
|
| 210 |
+
return history, ""
|
| 211 |
+
history = history + [{"role": "user", "content": message}]
|
| 212 |
+
response = _ask(message, history)
|
| 213 |
+
history = history + [{"role": "assistant", "content": response}]
|
| 214 |
+
return history, ""
|
| 215 |
+
|
| 216 |
+
def voice_respond(audio, history):
|
| 217 |
+
if audio is None:
|
| 218 |
+
return "No audio recorded."
|
| 219 |
+
response, _ = _ask_with_audio(audio, history)
|
| 220 |
+
return response
|
| 221 |
+
|
| 222 |
+
send_btn.click(chat_respond, [msg_input, chatbot], [chatbot, msg_input])
|
| 223 |
+
msg_input.submit(chat_respond, [msg_input, chatbot], [chatbot, msg_input])
|
| 224 |
+
audio_input.stop_recording(voice_respond, [audio_input, chatbot], [voice_output])
|
| 225 |
+
|
| 226 |
+
# ββ Status Tab ββ
|
| 227 |
+
with gr.Tab("Status", id="status"):
|
| 228 |
+
status_display = gr.Markdown(value="Click Refresh to load status.")
|
| 229 |
+
context_display = gr.Markdown(value="")
|
| 230 |
+
refresh_btn = gr.Button("Refresh", variant="secondary")
|
| 231 |
+
|
| 232 |
+
def refresh_status():
|
| 233 |
+
return _get_status(), _get_context()
|
| 234 |
+
|
| 235 |
+
refresh_btn.click(refresh_status, [], [status_display, context_display])
|
| 236 |
+
|
| 237 |
+
# ββ Scheduler Tab ββ
|
| 238 |
+
with gr.Tab("Scheduler", id="scheduler"):
|
| 239 |
+
jobs_display = gr.Markdown(value="Click Refresh to load jobs.")
|
| 240 |
+
refresh_jobs_btn = gr.Button("Refresh Jobs", variant="secondary")
|
| 241 |
+
refresh_jobs_btn.click(lambda: _get_scheduled_jobs(), [], [jobs_display])
|
| 242 |
+
|
| 243 |
+
gr.Markdown("### Add New Job")
|
| 244 |
+
with gr.Row():
|
| 245 |
+
job_name = gr.Textbox(label="Job Name", placeholder="Morning briefing")
|
| 246 |
+
job_cmd = gr.Textbox(label="Command", placeholder="What's on my calendar today?")
|
| 247 |
+
job_time = gr.Textbox(label="Time (HH:MM)", placeholder="09:00")
|
| 248 |
+
add_job_btn = gr.Button("Add Job", variant="primary")
|
| 249 |
+
add_result = gr.Markdown()
|
| 250 |
+
add_job_btn.click(_add_job, [job_name, job_cmd, job_time], [add_result])
|
| 251 |
+
|
| 252 |
+
# ββ Settings Tab ββ
|
| 253 |
+
with gr.Tab("Settings", id="settings"):
|
| 254 |
+
gr.Markdown("### Configuration")
|
| 255 |
+
with gr.Row():
|
| 256 |
+
lang_select = gr.Dropdown(
|
| 257 |
+
choices=["en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "hi", "ar"],
|
| 258 |
+
value="en",
|
| 259 |
+
label="Language",
|
| 260 |
+
)
|
| 261 |
+
tts_select = gr.Dropdown(
|
| 262 |
+
choices=["Daniel", "Samantha", "Alex", "Karen", "Moira", "Tessa"],
|
| 263 |
+
value="Daniel",
|
| 264 |
+
label="TTS Voice",
|
| 265 |
+
)
|
| 266 |
+
rate_slider = gr.Slider(
|
| 267 |
+
minimum=120, maximum=250, value=180, step=5,
|
| 268 |
+
label="Speech Rate (WPM)",
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
gr.Markdown(
|
| 272 |
+
"### Environment Variables\n"
|
| 273 |
+
"These settings are configured via `.env` file or environment variables:\n"
|
| 274 |
+
"- `JARVIS_LANGUAGE` β Speech recognition language\n"
|
| 275 |
+
"- `JARVIS_TTS_BACKEND` β `say` (macOS) or `elevenlabs`\n"
|
| 276 |
+
"- `JARVIS_TTS_VOICE` β Voice name for macOS `say`\n"
|
| 277 |
+
"- `ELEVENLABS_API_KEY` β For premium TTS\n"
|
| 278 |
+
"- `JARVIS_CORS_ORIGINS` β Allowed CORS origins\n"
|
| 279 |
+
"- `JARVIS_ALLOWED_USERS` β Whitelisted user IDs\n"
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def create_gradio_app():
|
| 284 |
+
"""Return the Gradio Blocks instance for mounting on FastAPI."""
|
| 285 |
+
return demo
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# Allow running standalone for development
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
demo.launch(server_port=7861)
|
|
@@ -67,6 +67,17 @@ WAKE_PHRASES = ["jarvis", "hey jarvis", "okay jarvis", "yo jarvis", "hey j a r v
|
|
| 67 |
COMMAND_MIN_ENERGY = 120
|
| 68 |
COMMAND_MAX_ENERGY = 400
|
| 69 |
IDLE_QUIT_MINUTES = int(os.getenv("JARVIS_IDLE_QUIT_MINUTES", "10"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
ENABLE_CLAP_DETECTION = os.getenv("JARVIS_ENABLE_CLAP", "1").strip().lower() in {"1", "true", "yes", "on"}
|
| 71 |
|
| 72 |
|
|
@@ -77,6 +88,49 @@ wake_pause_event = threading.Event()
|
|
| 77 |
_last_activity = time.time()
|
| 78 |
_idle_quit_disabled = False
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def touch_activity():
|
| 82 |
"""Update the last-activity timestamp. Called on every user interaction."""
|
|
@@ -320,7 +374,7 @@ def listen_for_command_local(timeout: int = 15, phrase_time_limit: int = 30, sil
|
|
| 320 |
samples,
|
| 321 |
path_or_hf_repo=LOCAL_WAKE_MODEL,
|
| 322 |
verbose=False,
|
| 323 |
-
language=
|
| 324 |
condition_on_previous_text=False,
|
| 325 |
)
|
| 326 |
text = (result.get("text") or "").strip()
|
|
@@ -351,6 +405,224 @@ def listen_for_command_fallback() -> str:
|
|
| 351 |
return text
|
| 352 |
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
# βββ Audio Feedback ββββββββββββββββββββββββββββββ
|
| 355 |
def chime():
|
| 356 |
subprocess.Popen(["afplay", "/System/Library/Sounds/Tink.aiff"], stderr=subprocess.DEVNULL)
|
|
@@ -360,8 +632,45 @@ def speak(text):
|
|
| 360 |
text = re.sub(r'```[\s\S]*?```', '', text)
|
| 361 |
text = re.sub(r'[`*#\[\]]', '', text)
|
| 362 |
text = text.strip()[:500]
|
| 363 |
-
if text:
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
|
| 367 |
def notify(title, msg):
|
|
@@ -610,6 +919,13 @@ def handle_activation(command_text=None):
|
|
| 610 |
while True:
|
| 611 |
touch_activity()
|
| 612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
# Check if user wants to end conversation
|
| 614 |
if command_text.lower().strip().rstrip(".!") in EXIT_PHRASES:
|
| 615 |
speak("Standing by, sir. Mic is off.")
|
|
@@ -787,6 +1103,12 @@ def start_local_wake_listener():
|
|
| 787 |
diag_max_amp = 0
|
| 788 |
diag_last_report = now
|
| 789 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
is_speech = vad.is_speech(frame, sample_rate)
|
| 791 |
if is_speech:
|
| 792 |
diag_speech_count += 1
|
|
@@ -819,7 +1141,7 @@ def start_local_wake_listener():
|
|
| 819 |
samples,
|
| 820 |
path_or_hf_repo=LOCAL_WAKE_MODEL,
|
| 821 |
verbose=False,
|
| 822 |
-
language=
|
| 823 |
condition_on_previous_text=False,
|
| 824 |
)
|
| 825 |
except Exception as e:
|
|
|
|
| 67 |
COMMAND_MIN_ENERGY = 120
|
| 68 |
COMMAND_MAX_ENERGY = 400
|
| 69 |
IDLE_QUIT_MINUTES = int(os.getenv("JARVIS_IDLE_QUIT_MINUTES", "10"))
|
| 70 |
+
|
| 71 |
+
# βββ Language & TTS Configuration ββββββββββββββββ
|
| 72 |
+
JARVIS_LANGUAGE = os.getenv("JARVIS_LANGUAGE", "en").strip().lower()
|
| 73 |
+
JARVIS_TTS_BACKEND = os.getenv("JARVIS_TTS_BACKEND", "say").strip().lower() # say, elevenlabs
|
| 74 |
+
JARVIS_TTS_VOICE = os.getenv("JARVIS_TTS_VOICE", "Daniel").strip()
|
| 75 |
+
JARVIS_TTS_RATE = int(os.getenv("JARVIS_TTS_RATE", "180"))
|
| 76 |
+
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
| 77 |
+
ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "").strip()
|
| 78 |
+
|
| 79 |
+
# Echo cancellation β track when JARVIS is speaking to mute mic input
|
| 80 |
+
_speaking = threading.Event()
|
| 81 |
ENABLE_CLAP_DETECTION = os.getenv("JARVIS_ENABLE_CLAP", "1").strip().lower() in {"1", "true", "yes", "on"}
|
| 82 |
|
| 83 |
|
|
|
|
| 88 |
_last_activity = time.time()
|
| 89 |
_idle_quit_disabled = False
|
| 90 |
|
| 91 |
+
# βββ Transcription Mode ββββββββββββββββββββββββββ
|
| 92 |
+
_transcribing = False
|
| 93 |
+
_transcribe_note_title = ""
|
| 94 |
+
|
| 95 |
+
TRANSCRIBE_TRIGGER_PHRASES = {"transcribe this", "start transcribing", "transcribe",
|
| 96 |
+
"transcribe this conversation", "start transcription"}
|
| 97 |
+
TRANSCRIBE_STOP_PHRASES = {"stop transcribing", "stop transcribe", "jarvis stop transcribing",
|
| 98 |
+
"stop the transcription", "end transcription", "stop transcription"}
|
| 99 |
+
|
| 100 |
+
# Punctuation restoration patterns for transcription
|
| 101 |
+
_PUNCT_RULES = [
|
| 102 |
+
# Capitalize first letter of each segment
|
| 103 |
+
(r'^([a-z])', lambda m: m.group(1).upper()),
|
| 104 |
+
# Add period at end if missing terminal punctuation
|
| 105 |
+
(r'([a-zA-Z])$', r'\1.'),
|
| 106 |
+
# Capitalize after period
|
| 107 |
+
(r'\.\s+([a-z])', lambda m: '. ' + m.group(1).upper()),
|
| 108 |
+
# Common speech patterns β punctuation
|
| 109 |
+
(r'\b(so|well|anyway|ok|okay|now|right)\b\s', lambda m: m.group(0).rstrip() + ', '),
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _restore_punctuation(text: str) -> str:
|
| 114 |
+
"""Basic punctuation restoration for transcribed speech."""
|
| 115 |
+
if not text:
|
| 116 |
+
return text
|
| 117 |
+
for pattern, repl in _PUNCT_RULES:
|
| 118 |
+
text = re.sub(pattern, repl, text)
|
| 119 |
+
# Remove double periods
|
| 120 |
+
text = text.replace('..', '.')
|
| 121 |
+
return text.strip()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _estimate_speaker_change(prev_text: str, curr_text: str) -> bool:
|
| 125 |
+
"""Simple heuristic to detect speaker changes between chunks.
|
| 126 |
+
Returns True if a speaker change is likely."""
|
| 127 |
+
if not prev_text or not curr_text:
|
| 128 |
+
return False
|
| 129 |
+
# If there's a question followed by a non-question, likely speaker change
|
| 130 |
+
prev_is_question = prev_text.rstrip().endswith('?')
|
| 131 |
+
# If tone/topic shifts significantly, might be a different speaker
|
| 132 |
+
return prev_is_question
|
| 133 |
+
|
| 134 |
|
| 135 |
def touch_activity():
|
| 136 |
"""Update the last-activity timestamp. Called on every user interaction."""
|
|
|
|
| 374 |
samples,
|
| 375 |
path_or_hf_repo=LOCAL_WAKE_MODEL,
|
| 376 |
verbose=False,
|
| 377 |
+
language=JARVIS_LANGUAGE,
|
| 378 |
condition_on_previous_text=False,
|
| 379 |
)
|
| 380 |
text = (result.get("text") or "").strip()
|
|
|
|
| 405 |
return text
|
| 406 |
|
| 407 |
|
| 408 |
+
# βββ Transcription Mode ββββββββββββββββββββββββββ
|
| 409 |
+
|
| 410 |
+
def listen_for_transcription_chunk(phrase_time_limit: int = 60, silence_seconds: float = 2.0,
|
| 411 |
+
timeout: int = 120) -> str | None:
|
| 412 |
+
"""Capture a transcription chunk optimized for continuous speech.
|
| 413 |
+
|
| 414 |
+
Unlike listen_for_command_local(), this uses:
|
| 415 |
+
- Longer phrase_time_limit (60s) to capture extended speech
|
| 416 |
+
- Longer silence threshold (2.0s) to handle natural conversation pauses
|
| 417 |
+
- Longer timeout (120s) to wait through lulls in conversation
|
| 418 |
+
- Less aggressive VAD (level 1) to avoid cutting off mid-sentence
|
| 419 |
+
"""
|
| 420 |
+
try:
|
| 421 |
+
import numpy as np
|
| 422 |
+
import sounddevice as sd
|
| 423 |
+
import webrtcvad
|
| 424 |
+
from mlx_whisper import transcribe
|
| 425 |
+
except ImportError as exc:
|
| 426 |
+
log.warning(f"Transcription capture unavailable: {exc}")
|
| 427 |
+
return None
|
| 428 |
+
|
| 429 |
+
sample_rate = 16000
|
| 430 |
+
frame_ms = 30
|
| 431 |
+
frame_samples = int(sample_rate * frame_ms / 1000)
|
| 432 |
+
max_frames = int(phrase_time_limit * 1000 / frame_ms)
|
| 433 |
+
silence_frames_to_stop = int(silence_seconds * 1000 / frame_ms)
|
| 434 |
+
vad = webrtcvad.Vad(1) # Less aggressive β avoid cutting mid-sentence
|
| 435 |
+
queue = []
|
| 436 |
+
queue_lock = threading.Lock()
|
| 437 |
+
audio_frames = []
|
| 438 |
+
silence_frames = 0
|
| 439 |
+
|
| 440 |
+
def push_audio(indata, frames, time_info, status):
|
| 441 |
+
with queue_lock:
|
| 442 |
+
queue.append(bytes(indata))
|
| 443 |
+
|
| 444 |
+
def pop_audio():
|
| 445 |
+
with queue_lock:
|
| 446 |
+
if queue:
|
| 447 |
+
return queue.pop(0)
|
| 448 |
+
return None
|
| 449 |
+
|
| 450 |
+
stream = None
|
| 451 |
+
for attempt in range(3):
|
| 452 |
+
try:
|
| 453 |
+
stream = sd.RawInputStream(
|
| 454 |
+
samplerate=sample_rate,
|
| 455 |
+
blocksize=frame_samples,
|
| 456 |
+
dtype="int16",
|
| 457 |
+
channels=1,
|
| 458 |
+
callback=push_audio,
|
| 459 |
+
)
|
| 460 |
+
break
|
| 461 |
+
except Exception as e:
|
| 462 |
+
log.warning(f"Transcription stream open attempt {attempt + 1}/3 failed: {e}")
|
| 463 |
+
time.sleep(0.3)
|
| 464 |
+
|
| 465 |
+
if stream is None:
|
| 466 |
+
log.error("Could not open audio stream for transcription")
|
| 467 |
+
return None
|
| 468 |
+
|
| 469 |
+
try:
|
| 470 |
+
with stream:
|
| 471 |
+
time.sleep(0.2)
|
| 472 |
+
with queue_lock:
|
| 473 |
+
queue.clear()
|
| 474 |
+
|
| 475 |
+
deadline = time.time() + timeout
|
| 476 |
+
|
| 477 |
+
while True:
|
| 478 |
+
frame = pop_audio()
|
| 479 |
+
if frame is None:
|
| 480 |
+
if not audio_frames and time.time() >= deadline:
|
| 481 |
+
return None # No speech during timeout β just return None
|
| 482 |
+
time.sleep(0.01)
|
| 483 |
+
continue
|
| 484 |
+
|
| 485 |
+
is_speech = vad.is_speech(frame, sample_rate)
|
| 486 |
+
if is_speech:
|
| 487 |
+
audio_frames.append(frame)
|
| 488 |
+
silence_frames = 0
|
| 489 |
+
continue
|
| 490 |
+
|
| 491 |
+
if audio_frames:
|
| 492 |
+
audio_frames.append(frame)
|
| 493 |
+
silence_frames += 1
|
| 494 |
+
|
| 495 |
+
if not audio_frames:
|
| 496 |
+
continue
|
| 497 |
+
|
| 498 |
+
if len(audio_frames) >= max_frames or silence_frames >= silence_frames_to_stop:
|
| 499 |
+
break
|
| 500 |
+
|
| 501 |
+
if not audio_frames:
|
| 502 |
+
return None
|
| 503 |
+
|
| 504 |
+
segment = b"".join(audio_frames)
|
| 505 |
+
samples = np.frombuffer(segment, dtype=np.int16).astype(np.float32) / 32768.0
|
| 506 |
+
result = transcribe(
|
| 507 |
+
samples,
|
| 508 |
+
path_or_hf_repo=LOCAL_WAKE_MODEL,
|
| 509 |
+
verbose=False,
|
| 510 |
+
language=JARVIS_LANGUAGE,
|
| 511 |
+
condition_on_previous_text=False,
|
| 512 |
+
)
|
| 513 |
+
text = (result.get("text") or "").strip()
|
| 514 |
+
return text if text else None
|
| 515 |
+
|
| 516 |
+
except Exception as e:
|
| 517 |
+
log.warning(f"Transcription chunk error: {e}")
|
| 518 |
+
return None
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def run_transcription_mode():
|
| 522 |
+
"""Run continuous transcription. Creates an Apple Note and appends speech chunks."""
|
| 523 |
+
global _transcribing, _transcribe_note_title
|
| 524 |
+
from datetime import datetime
|
| 525 |
+
|
| 526 |
+
try:
|
| 527 |
+
from overlay import set_state
|
| 528 |
+
except ImportError:
|
| 529 |
+
set_state = lambda *a, **kw: None
|
| 530 |
+
|
| 531 |
+
try:
|
| 532 |
+
from tools.app_automation import notes_create, notes_append
|
| 533 |
+
except ImportError:
|
| 534 |
+
speak("I can't access Notes right now, sir.")
|
| 535 |
+
return
|
| 536 |
+
|
| 537 |
+
# Create note with timestamp title
|
| 538 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 539 |
+
_transcribe_note_title = f"JARVIS Transcription - {timestamp}"
|
| 540 |
+
|
| 541 |
+
notes_create(
|
| 542 |
+
title=_transcribe_note_title,
|
| 543 |
+
body=f"Transcription started at {timestamp}\n---"
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
speak("Transcription started. I'll capture everything and save it to Notes. "
|
| 547 |
+
"Say stop transcribing when you're done.")
|
| 548 |
+
notify("JARVIS β Transcribing", f"Note: {_transcribe_note_title}")
|
| 549 |
+
|
| 550 |
+
_transcribing = True
|
| 551 |
+
chunk_count = 0
|
| 552 |
+
prev_text = ""
|
| 553 |
+
speaker_num = 1
|
| 554 |
+
set_state("transcribing", transcript="Transcribing...", chunks=0)
|
| 555 |
+
|
| 556 |
+
# Keepalive thread β touch activity every 60s to bypass idle auto-quit
|
| 557 |
+
def _transcribe_keepalive():
|
| 558 |
+
while _transcribing:
|
| 559 |
+
touch_activity()
|
| 560 |
+
time.sleep(60)
|
| 561 |
+
|
| 562 |
+
keepalive_thread = threading.Thread(target=_transcribe_keepalive, daemon=True)
|
| 563 |
+
keepalive_thread.start()
|
| 564 |
+
|
| 565 |
+
try:
|
| 566 |
+
while _transcribing:
|
| 567 |
+
touch_activity()
|
| 568 |
+
|
| 569 |
+
text = listen_for_transcription_chunk()
|
| 570 |
+
|
| 571 |
+
if not text:
|
| 572 |
+
# No speech detected β loop back and keep listening
|
| 573 |
+
continue
|
| 574 |
+
|
| 575 |
+
touch_activity()
|
| 576 |
+
|
| 577 |
+
# Check for stop phrases
|
| 578 |
+
normalized = text.lower().strip().rstrip(".!?")
|
| 579 |
+
if any(stop in normalized for stop in TRANSCRIBE_STOP_PHRASES):
|
| 580 |
+
log.info(f"Transcription stop phrase detected: {text}")
|
| 581 |
+
break
|
| 582 |
+
|
| 583 |
+
# Restore punctuation
|
| 584 |
+
text = _restore_punctuation(text)
|
| 585 |
+
|
| 586 |
+
# Simple speaker diarization
|
| 587 |
+
if _estimate_speaker_change(prev_text, text):
|
| 588 |
+
speaker_num = 2 if speaker_num == 1 else 1
|
| 589 |
+
|
| 590 |
+
# Append to note with timestamp and speaker label
|
| 591 |
+
chunk_time = datetime.now().strftime("%H:%M:%S")
|
| 592 |
+
chunk_count += 1
|
| 593 |
+
|
| 594 |
+
notes_append(
|
| 595 |
+
title=_transcribe_note_title,
|
| 596 |
+
text=f"[{chunk_time}] Speaker {speaker_num}: {text}"
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
prev_text = text
|
| 600 |
+
|
| 601 |
+
# Update overlay with latest chunk
|
| 602 |
+
set_state("transcribing", transcript=text[:80], chunks=chunk_count)
|
| 603 |
+
log.info(f"Transcription chunk #{chunk_count}: {text[:100]}")
|
| 604 |
+
|
| 605 |
+
except Exception as e:
|
| 606 |
+
log.error(f"Transcription error: {e}")
|
| 607 |
+
finally:
|
| 608 |
+
_transcribing = False
|
| 609 |
+
set_state("idle")
|
| 610 |
+
|
| 611 |
+
# Append final summary
|
| 612 |
+
try:
|
| 613 |
+
end_time = datetime.now().strftime("%H:%M:%S")
|
| 614 |
+
notes_append(
|
| 615 |
+
title=_transcribe_note_title,
|
| 616 |
+
text=f"\n---\nTranscription ended at {end_time} ({chunk_count} segments captured)"
|
| 617 |
+
)
|
| 618 |
+
except Exception:
|
| 619 |
+
pass
|
| 620 |
+
|
| 621 |
+
speak(f"Transcription saved to Notes with {chunk_count} segments, sir.")
|
| 622 |
+
notify("JARVIS β Transcription Complete", f"{chunk_count} segments saved to Notes")
|
| 623 |
+
log.info(f"Transcription ended: {chunk_count} chunks saved to '{_transcribe_note_title}'")
|
| 624 |
+
|
| 625 |
+
|
| 626 |
# βββ Audio Feedback ββββββββββββββββββββββββββββββ
|
| 627 |
def chime():
|
| 628 |
subprocess.Popen(["afplay", "/System/Library/Sounds/Tink.aiff"], stderr=subprocess.DEVNULL)
|
|
|
|
| 632 |
text = re.sub(r'```[\s\S]*?```', '', text)
|
| 633 |
text = re.sub(r'[`*#\[\]]', '', text)
|
| 634 |
text = text.strip()[:500]
|
| 635 |
+
if not text:
|
| 636 |
+
return
|
| 637 |
+
|
| 638 |
+
_speaking.set() # Echo cancellation: signal that JARVIS is speaking
|
| 639 |
+
try:
|
| 640 |
+
if JARVIS_TTS_BACKEND == "elevenlabs" and ELEVENLABS_API_KEY:
|
| 641 |
+
_speak_elevenlabs(text)
|
| 642 |
+
else:
|
| 643 |
+
subprocess.run(
|
| 644 |
+
["say", "-v", JARVIS_TTS_VOICE, "-r", str(JARVIS_TTS_RATE), text],
|
| 645 |
+
stderr=subprocess.DEVNULL,
|
| 646 |
+
)
|
| 647 |
+
finally:
|
| 648 |
+
time.sleep(0.3) # Brief pause after speaking to avoid echo pickup
|
| 649 |
+
_speaking.clear()
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def _speak_elevenlabs(text):
|
| 653 |
+
"""Speak using ElevenLabs TTS API (higher quality voices)."""
|
| 654 |
+
try:
|
| 655 |
+
resp = httpx.post(
|
| 656 |
+
f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVENLABS_VOICE_ID}",
|
| 657 |
+
headers={"xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json"},
|
| 658 |
+
json={"text": text, "model_id": "eleven_monolingual_v1"},
|
| 659 |
+
timeout=15,
|
| 660 |
+
)
|
| 661 |
+
if resp.status_code == 200:
|
| 662 |
+
audio_path = os.path.join(JARVIS_DIR, ".jarvis_tts.mp3")
|
| 663 |
+
with open(audio_path, "wb") as f:
|
| 664 |
+
f.write(resp.content)
|
| 665 |
+
subprocess.run(["afplay", audio_path], stderr=subprocess.DEVNULL)
|
| 666 |
+
else:
|
| 667 |
+
log.warning(f"ElevenLabs TTS failed ({resp.status_code}), falling back to macOS say")
|
| 668 |
+
subprocess.run(["say", "-v", JARVIS_TTS_VOICE, "-r", str(JARVIS_TTS_RATE), text],
|
| 669 |
+
stderr=subprocess.DEVNULL)
|
| 670 |
+
except Exception as e:
|
| 671 |
+
log.warning(f"ElevenLabs error: {e}, falling back to macOS say")
|
| 672 |
+
subprocess.run(["say", "-v", JARVIS_TTS_VOICE, "-r", str(JARVIS_TTS_RATE), text],
|
| 673 |
+
stderr=subprocess.DEVNULL)
|
| 674 |
|
| 675 |
|
| 676 |
def notify(title, msg):
|
|
|
|
| 919 |
while True:
|
| 920 |
touch_activity()
|
| 921 |
|
| 922 |
+
# Check if user wants to start transcription mode
|
| 923 |
+
normalized_cmd = command_text.lower().strip().rstrip(".!?")
|
| 924 |
+
if any(trigger in normalized_cmd for trigger in TRANSCRIBE_TRIGGER_PHRASES):
|
| 925 |
+
log.info(f"Transcription mode triggered: {command_text}")
|
| 926 |
+
run_transcription_mode()
|
| 927 |
+
break # Return to idle after transcription ends
|
| 928 |
+
|
| 929 |
# Check if user wants to end conversation
|
| 930 |
if command_text.lower().strip().rstrip(".!") in EXIT_PHRASES:
|
| 931 |
speak("Standing by, sir. Mic is off.")
|
|
|
|
| 1103 |
diag_max_amp = 0
|
| 1104 |
diag_last_report = now
|
| 1105 |
|
| 1106 |
+
# Echo cancellation: skip audio while JARVIS is speaking
|
| 1107 |
+
if _speaking.is_set():
|
| 1108 |
+
audio_frames.clear()
|
| 1109 |
+
silence_frames = 0
|
| 1110 |
+
continue
|
| 1111 |
+
|
| 1112 |
is_speech = vad.is_speech(frame, sample_rate)
|
| 1113 |
if is_speech:
|
| 1114 |
diag_speech_count += 1
|
|
|
|
| 1141 |
samples,
|
| 1142 |
path_or_hf_repo=LOCAL_WAKE_MODEL,
|
| 1143 |
verbose=False,
|
| 1144 |
+
language=JARVIS_LANGUAGE,
|
| 1145 |
condition_on_previous_text=False,
|
| 1146 |
)
|
| 1147 |
except Exception as e:
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""JARVIS Memory System β persistent SQLite-backed memory with cloud sync."""
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
import sqlite3
|
| 5 |
import json
|
| 6 |
import asyncio
|
|
@@ -16,14 +17,21 @@ DB_PATH = Path(__file__).parent / "jarvis_memory.db"
|
|
| 16 |
# Encryption key from environment (optional β if not set, no encryption)
|
| 17 |
_ENCRYPTION_KEY = os.environ.get("JARVIS_DB_KEY", "")
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
def get_db():
|
| 21 |
-
conn = sqlite3.connect(str(DB_PATH))
|
| 22 |
conn.row_factory = sqlite3.Row
|
| 23 |
conn.execute("PRAGMA journal_mode=WAL")
|
| 24 |
if _ENCRYPTION_KEY:
|
| 25 |
try:
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
except Exception:
|
| 28 |
pass # Standard sqlite3 doesn't support PRAGMA key
|
| 29 |
return conn
|
|
|
|
| 1 |
"""JARVIS Memory System β persistent SQLite-backed memory with cloud sync."""
|
| 2 |
|
| 3 |
import os
|
| 4 |
+
import re
|
| 5 |
import sqlite3
|
| 6 |
import json
|
| 7 |
import asyncio
|
|
|
|
| 17 |
# Encryption key from environment (optional β if not set, no encryption)
|
| 18 |
_ENCRYPTION_KEY = os.environ.get("JARVIS_DB_KEY", "")
|
| 19 |
|
| 20 |
+
# Validate encryption key at startup β reject unsafe characters to prevent SQL injection
|
| 21 |
+
if _ENCRYPTION_KEY and not re.match(r'^[a-zA-Z0-9_\-+=/.]+$', _ENCRYPTION_KEY):
|
| 22 |
+
_log.error("JARVIS_DB_KEY contains unsafe characters. Only alphanumeric, _, -, +, =, /, . allowed.")
|
| 23 |
+
raise SystemExit("Invalid JARVIS_DB_KEY β contains unsafe characters")
|
| 24 |
+
|
| 25 |
|
| 26 |
def get_db():
|
| 27 |
+
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
| 28 |
conn.row_factory = sqlite3.Row
|
| 29 |
conn.execute("PRAGMA journal_mode=WAL")
|
| 30 |
if _ENCRYPTION_KEY:
|
| 31 |
try:
|
| 32 |
+
# Use hex-encoded key to avoid SQL injection via single quotes
|
| 33 |
+
hex_key = _ENCRYPTION_KEY.encode().hex()
|
| 34 |
+
conn.execute(f"PRAGMA key=\"x'{hex_key}'\"")
|
| 35 |
except Exception:
|
| 36 |
pass # Standard sqlite3 doesn't support PRAGMA key
|
| 37 |
return conn
|
|
@@ -85,6 +85,12 @@ body {
|
|
| 85 |
box-shadow: 0 0 40px rgba(0,230,118,0.4);
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
@keyframes orbPulse {
|
| 89 |
0%,100% { transform: scale(1); }
|
| 90 |
50% { transform: scale(1.15); }
|
|
@@ -165,6 +171,14 @@ body {
|
|
| 165 |
text-transform: uppercase;
|
| 166 |
}
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
.hidden { display: none; }
|
| 169 |
</style>
|
| 170 |
</head>
|
|
@@ -181,6 +195,7 @@ body {
|
|
| 181 |
</div>
|
| 182 |
<div class="transcript" id="transcript">Listening...</div>
|
| 183 |
<div class="response hidden" id="response"></div>
|
|
|
|
| 184 |
<div class="status" id="status">J.A.R.V.I.S.</div>
|
| 185 |
</div>
|
| 186 |
|
|
@@ -201,6 +216,7 @@ body {
|
|
| 201 |
const transcript = document.getElementById('transcript');
|
| 202 |
const response = document.getElementById('response');
|
| 203 |
const status = document.getElementById('status');
|
|
|
|
| 204 |
|
| 205 |
orb.className = 'orb ' + data.state;
|
| 206 |
|
|
@@ -208,18 +224,28 @@ body {
|
|
| 208 |
wave.className = 'waveform active';
|
| 209 |
transcript.textContent = data.transcript || 'Listening...';
|
| 210 |
response.className = 'response hidden';
|
|
|
|
| 211 |
status.textContent = 'J.A.R.V.I.S.';
|
| 212 |
} else if (data.state === 'thinking') {
|
| 213 |
wave.className = 'waveform';
|
| 214 |
transcript.textContent = data.transcript || '';
|
| 215 |
response.className = 'response hidden';
|
|
|
|
| 216 |
status.textContent = 'PROCESSING';
|
| 217 |
} else if (data.state === 'speaking') {
|
| 218 |
wave.className = 'waveform';
|
| 219 |
transcript.textContent = data.transcript || '';
|
| 220 |
response.className = 'response';
|
| 221 |
response.textContent = data.response || '';
|
|
|
|
| 222 |
status.textContent = 'J.A.R.V.I.S.';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
} else if (data.state === 'idle') {
|
| 224 |
// Will be hidden by the app
|
| 225 |
}
|
|
@@ -231,17 +257,19 @@ body {
|
|
| 231 |
</html>"""
|
| 232 |
|
| 233 |
# Shared state
|
| 234 |
-
_state = {"state": "idle", "transcript": "", "response": ""}
|
| 235 |
_state_lock = threading.Lock()
|
| 236 |
|
| 237 |
|
| 238 |
-
def set_state(state, transcript="", response=""):
|
| 239 |
with _state_lock:
|
| 240 |
_state["state"] = state
|
| 241 |
if transcript:
|
| 242 |
_state["transcript"] = transcript
|
| 243 |
if response:
|
| 244 |
_state["response"] = response
|
|
|
|
|
|
|
| 245 |
|
| 246 |
|
| 247 |
def get_state():
|
|
|
|
| 85 |
box-shadow: 0 0 40px rgba(0,230,118,0.4);
|
| 86 |
}
|
| 87 |
|
| 88 |
+
.orb.transcribing {
|
| 89 |
+
animation: orbPulse 2s ease-in-out infinite;
|
| 90 |
+
background: radial-gradient(circle, rgba(255,0,100,0.9) 0%, rgba(200,0,80,0.6) 50%, transparent 70%);
|
| 91 |
+
box-shadow: 0 0 40px rgba(255,0,100,0.4);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
@keyframes orbPulse {
|
| 95 |
0%,100% { transform: scale(1); }
|
| 96 |
50% { transform: scale(1.15); }
|
|
|
|
| 171 |
text-transform: uppercase;
|
| 172 |
}
|
| 173 |
|
| 174 |
+
.chunk-count {
|
| 175 |
+
text-align: center;
|
| 176 |
+
font-size: 12px;
|
| 177 |
+
color: rgba(255,0,100,0.8);
|
| 178 |
+
margin-top: 6px;
|
| 179 |
+
font-weight: 600;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
.hidden { display: none; }
|
| 183 |
</style>
|
| 184 |
</head>
|
|
|
|
| 195 |
</div>
|
| 196 |
<div class="transcript" id="transcript">Listening...</div>
|
| 197 |
<div class="response hidden" id="response"></div>
|
| 198 |
+
<div class="chunk-count hidden" id="chunkCount"></div>
|
| 199 |
<div class="status" id="status">J.A.R.V.I.S.</div>
|
| 200 |
</div>
|
| 201 |
|
|
|
|
| 216 |
const transcript = document.getElementById('transcript');
|
| 217 |
const response = document.getElementById('response');
|
| 218 |
const status = document.getElementById('status');
|
| 219 |
+
const chunkCount = document.getElementById('chunkCount');
|
| 220 |
|
| 221 |
orb.className = 'orb ' + data.state;
|
| 222 |
|
|
|
|
| 224 |
wave.className = 'waveform active';
|
| 225 |
transcript.textContent = data.transcript || 'Listening...';
|
| 226 |
response.className = 'response hidden';
|
| 227 |
+
chunkCount.className = 'chunk-count hidden';
|
| 228 |
status.textContent = 'J.A.R.V.I.S.';
|
| 229 |
} else if (data.state === 'thinking') {
|
| 230 |
wave.className = 'waveform';
|
| 231 |
transcript.textContent = data.transcript || '';
|
| 232 |
response.className = 'response hidden';
|
| 233 |
+
chunkCount.className = 'chunk-count hidden';
|
| 234 |
status.textContent = 'PROCESSING';
|
| 235 |
} else if (data.state === 'speaking') {
|
| 236 |
wave.className = 'waveform';
|
| 237 |
transcript.textContent = data.transcript || '';
|
| 238 |
response.className = 'response';
|
| 239 |
response.textContent = data.response || '';
|
| 240 |
+
chunkCount.className = 'chunk-count hidden';
|
| 241 |
status.textContent = 'J.A.R.V.I.S.';
|
| 242 |
+
} else if (data.state === 'transcribing') {
|
| 243 |
+
wave.className = 'waveform active';
|
| 244 |
+
transcript.textContent = data.transcript || 'Transcribing...';
|
| 245 |
+
response.className = 'response hidden';
|
| 246 |
+
chunkCount.className = 'chunk-count';
|
| 247 |
+
chunkCount.textContent = data.chunks ? data.chunks + ' segments captured' : '';
|
| 248 |
+
status.textContent = 'TRANSCRIBING β say "stop transcribing" to end';
|
| 249 |
} else if (data.state === 'idle') {
|
| 250 |
// Will be hidden by the app
|
| 251 |
}
|
|
|
|
| 257 |
</html>"""
|
| 258 |
|
| 259 |
# Shared state
|
| 260 |
+
_state = {"state": "idle", "transcript": "", "response": "", "chunks": 0}
|
| 261 |
_state_lock = threading.Lock()
|
| 262 |
|
| 263 |
|
| 264 |
+
def set_state(state, transcript="", response="", chunks=0):
|
| 265 |
with _state_lock:
|
| 266 |
_state["state"] = state
|
| 267 |
if transcript:
|
| 268 |
_state["transcript"] = transcript
|
| 269 |
if response:
|
| 270 |
_state["response"] = response
|
| 271 |
+
if chunks:
|
| 272 |
+
_state["chunks"] = chunks
|
| 273 |
|
| 274 |
|
| 275 |
def get_state():
|
|
@@ -15,3 +15,6 @@ numpy<2.0.0
|
|
| 15 |
motor>=3.3.0
|
| 16 |
pymongo>=4.6.0
|
| 17 |
paho-mqtt>=2.0.0
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
motor>=3.3.0
|
| 16 |
pymongo>=4.6.0
|
| 17 |
paho-mqtt>=2.0.0
|
| 18 |
+
gradio>=4.44.0
|
| 19 |
+
SpeechRecognition>=3.10.0
|
| 20 |
+
soundfile>=0.12.0
|
|
@@ -2,20 +2,111 @@
|
|
| 2 |
|
| 3 |
Runs as a background asyncio loop inside the server. Checks scheduled triggers
|
| 4 |
every 60 seconds and fires notifications or actions when conditions are met.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import asyncio
|
|
|
|
| 8 |
import logging
|
|
|
|
|
|
|
| 9 |
import subprocess
|
| 10 |
import platform
|
| 11 |
from datetime import datetime, timezone, timedelta
|
|
|
|
| 12 |
|
| 13 |
_log = logging.getLogger("jarvis.scheduler")
|
| 14 |
|
| 15 |
# ββ Scheduled Jobs Registry ββββββββββββββββββββββββββββββββββββββ
|
| 16 |
_jobs: list[dict] = []
|
|
|
|
| 17 |
_running = False
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
def register_job(name: str, interval_seconds: int, callback, enabled: bool = True):
|
| 21 |
"""Register a periodic job.
|
|
@@ -130,6 +221,87 @@ async def _check_routine():
|
|
| 130 |
|
| 131 |
# ββ Scheduler Loop ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
async def start_scheduler():
|
| 134 |
"""Start the background scheduler loop. Call from server lifespan."""
|
| 135 |
global _running
|
|
@@ -142,7 +314,11 @@ async def start_scheduler():
|
|
| 142 |
register_job("task_reminder", 300, _check_pending_tasks)
|
| 143 |
register_job("routine_check", 120, _check_routine)
|
| 144 |
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
while _running:
|
| 148 |
now = datetime.now(timezone.utc)
|
|
@@ -160,6 +336,9 @@ async def start_scheduler():
|
|
| 160 |
_log.warning(f"[SCHEDULER] {job['name']} failed: {e}")
|
| 161 |
job["last_run"] = now
|
| 162 |
|
|
|
|
|
|
|
|
|
|
| 163 |
await asyncio.sleep(60)
|
| 164 |
|
| 165 |
|
|
|
|
| 2 |
|
| 3 |
Runs as a background asyncio loop inside the server. Checks scheduled triggers
|
| 4 |
every 60 seconds and fires notifications or actions when conditions are met.
|
| 5 |
+
|
| 6 |
+
Supports:
|
| 7 |
+
- Built-in system jobs (calendar, tasks, routine)
|
| 8 |
+
- User-defined scheduled commands (persistent via SQLite)
|
| 9 |
+
- Cron-like scheduling: "every N minutes", specific times, intervals
|
| 10 |
"""
|
| 11 |
|
| 12 |
import asyncio
|
| 13 |
+
import json
|
| 14 |
import logging
|
| 15 |
+
import os
|
| 16 |
+
import sqlite3
|
| 17 |
import subprocess
|
| 18 |
import platform
|
| 19 |
from datetime import datetime, timezone, timedelta
|
| 20 |
+
from pathlib import Path
|
| 21 |
|
| 22 |
_log = logging.getLogger("jarvis.scheduler")
|
| 23 |
|
| 24 |
# ββ Scheduled Jobs Registry ββββββββββββββββββββββββββββββββββββββ
|
| 25 |
_jobs: list[dict] = []
|
| 26 |
+
_user_jobs: list[dict] = [] # User-defined persistent jobs
|
| 27 |
_running = False
|
| 28 |
|
| 29 |
+
_SCHEDULER_DB = Path(__file__).parent / "jarvis_scheduler.db"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _init_scheduler_db():
|
| 33 |
+
"""Initialize scheduler database for persistent user jobs."""
|
| 34 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 35 |
+
conn.row_factory = sqlite3.Row
|
| 36 |
+
conn.execute("""
|
| 37 |
+
CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
| 38 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 39 |
+
name TEXT NOT NULL,
|
| 40 |
+
command TEXT NOT NULL,
|
| 41 |
+
interval_seconds INTEGER DEFAULT 0,
|
| 42 |
+
run_at TEXT DEFAULT '',
|
| 43 |
+
repeat_daily INTEGER DEFAULT 0,
|
| 44 |
+
enabled INTEGER DEFAULT 1,
|
| 45 |
+
last_run TEXT DEFAULT '',
|
| 46 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 47 |
+
)
|
| 48 |
+
""")
|
| 49 |
+
conn.commit()
|
| 50 |
+
conn.close()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _load_user_jobs():
|
| 54 |
+
"""Load user-defined jobs from database."""
|
| 55 |
+
global _user_jobs
|
| 56 |
+
try:
|
| 57 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 58 |
+
conn.row_factory = sqlite3.Row
|
| 59 |
+
rows = conn.execute("SELECT * FROM scheduled_jobs WHERE enabled=1").fetchall()
|
| 60 |
+
conn.close()
|
| 61 |
+
_user_jobs = [dict(r) for r in rows]
|
| 62 |
+
_log.info(f"Loaded {len(_user_jobs)} user-defined scheduled job(s)")
|
| 63 |
+
except Exception as e:
|
| 64 |
+
_log.warning(f"Failed to load user jobs: {e}")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def add_scheduled_job(name: str, command: str, interval_seconds: int = 0,
|
| 68 |
+
run_at: str = "", repeat_daily: bool = False) -> dict:
|
| 69 |
+
"""Add a user-defined scheduled job (persistent).
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
name: Human-readable job name (e.g., "Morning briefing")
|
| 73 |
+
command: JARVIS command to execute (e.g., "what's on my calendar today")
|
| 74 |
+
interval_seconds: Run every N seconds (0 = not interval-based)
|
| 75 |
+
run_at: Specific time to run (HH:MM format, e.g., "09:00")
|
| 76 |
+
repeat_daily: If True and run_at is set, run daily at that time
|
| 77 |
+
"""
|
| 78 |
+
_init_scheduler_db()
|
| 79 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 80 |
+
cur = conn.execute(
|
| 81 |
+
"INSERT INTO scheduled_jobs (name, command, interval_seconds, run_at, repeat_daily) VALUES (?,?,?,?,?)",
|
| 82 |
+
(name, command, interval_seconds, run_at, 1 if repeat_daily else 0),
|
| 83 |
+
)
|
| 84 |
+
job_id = cur.lastrowid
|
| 85 |
+
conn.commit()
|
| 86 |
+
conn.close()
|
| 87 |
+
_load_user_jobs()
|
| 88 |
+
return {"id": job_id, "name": name, "command": command, "status": "scheduled"}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def remove_scheduled_job(job_id: int) -> bool:
|
| 92 |
+
"""Remove a user-defined scheduled job."""
|
| 93 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 94 |
+
conn.execute("DELETE FROM scheduled_jobs WHERE id=?", (job_id,))
|
| 95 |
+
conn.commit()
|
| 96 |
+
conn.close()
|
| 97 |
+
_load_user_jobs()
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def list_scheduled_jobs() -> list[dict]:
|
| 102 |
+
"""List all user-defined scheduled jobs."""
|
| 103 |
+
_init_scheduler_db()
|
| 104 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 105 |
+
conn.row_factory = sqlite3.Row
|
| 106 |
+
rows = conn.execute("SELECT * FROM scheduled_jobs ORDER BY created_at DESC").fetchall()
|
| 107 |
+
conn.close()
|
| 108 |
+
return [dict(r) for r in rows]
|
| 109 |
+
|
| 110 |
|
| 111 |
def register_job(name: str, interval_seconds: int, callback, enabled: bool = True):
|
| 112 |
"""Register a periodic job.
|
|
|
|
| 221 |
|
| 222 |
# ββ Scheduler Loop ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
|
| 224 |
+
async def _run_user_command(command: str) -> str | None:
|
| 225 |
+
"""Execute a user-scheduled command via the JARVIS API."""
|
| 226 |
+
try:
|
| 227 |
+
import httpx
|
| 228 |
+
jarvis_url = os.getenv("JARVIS_URL", "http://localhost:8000")
|
| 229 |
+
async with httpx.AsyncClient() as client:
|
| 230 |
+
resp = await client.post(
|
| 231 |
+
f"{jarvis_url}/api/ask",
|
| 232 |
+
json={"message": command},
|
| 233 |
+
timeout=30,
|
| 234 |
+
)
|
| 235 |
+
data = resp.json()
|
| 236 |
+
return data.get("response", "")
|
| 237 |
+
except Exception as e:
|
| 238 |
+
_log.warning(f"User command execution failed: {e}")
|
| 239 |
+
return None
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
async def _check_user_jobs():
|
| 243 |
+
"""Check and execute user-defined scheduled jobs."""
|
| 244 |
+
now = datetime.now()
|
| 245 |
+
now_str = now.strftime("%H:%M")
|
| 246 |
+
|
| 247 |
+
for job in _user_jobs:
|
| 248 |
+
if not job.get("enabled"):
|
| 249 |
+
continue
|
| 250 |
+
|
| 251 |
+
should_run = False
|
| 252 |
+
|
| 253 |
+
# Time-based jobs (run_at = "HH:MM")
|
| 254 |
+
if job.get("run_at"):
|
| 255 |
+
if job["run_at"] == now_str:
|
| 256 |
+
last_run = job.get("last_run", "")
|
| 257 |
+
today = now.strftime("%Y-%m-%d")
|
| 258 |
+
if not last_run or not last_run.startswith(today):
|
| 259 |
+
should_run = True
|
| 260 |
+
|
| 261 |
+
# Interval-based jobs
|
| 262 |
+
elif job.get("interval_seconds", 0) > 0:
|
| 263 |
+
last_run = job.get("last_run", "")
|
| 264 |
+
if not last_run:
|
| 265 |
+
should_run = True
|
| 266 |
+
else:
|
| 267 |
+
try:
|
| 268 |
+
lr = datetime.fromisoformat(last_run)
|
| 269 |
+
if (now - lr).total_seconds() >= job["interval_seconds"]:
|
| 270 |
+
should_run = True
|
| 271 |
+
except ValueError:
|
| 272 |
+
should_run = True
|
| 273 |
+
|
| 274 |
+
if should_run:
|
| 275 |
+
_log.info(f"[SCHEDULER] Running user job: {job['name']} β {job['command']}")
|
| 276 |
+
result = await _run_user_command(job["command"])
|
| 277 |
+
if result:
|
| 278 |
+
_notify(f"JARVIS β {job['name']}", result[:200])
|
| 279 |
+
_speak(result[:200])
|
| 280 |
+
|
| 281 |
+
# Update last_run in database
|
| 282 |
+
try:
|
| 283 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 284 |
+
conn.execute(
|
| 285 |
+
"UPDATE scheduled_jobs SET last_run=? WHERE id=?",
|
| 286 |
+
(now.isoformat(), job["id"]),
|
| 287 |
+
)
|
| 288 |
+
conn.commit()
|
| 289 |
+
conn.close()
|
| 290 |
+
job["last_run"] = now.isoformat()
|
| 291 |
+
except Exception:
|
| 292 |
+
pass
|
| 293 |
+
|
| 294 |
+
# Disable non-repeating time-based jobs after first run
|
| 295 |
+
if job.get("run_at") and not job.get("repeat_daily"):
|
| 296 |
+
try:
|
| 297 |
+
conn = sqlite3.connect(str(_SCHEDULER_DB), timeout=10)
|
| 298 |
+
conn.execute("UPDATE scheduled_jobs SET enabled=0 WHERE id=?", (job["id"],))
|
| 299 |
+
conn.commit()
|
| 300 |
+
conn.close()
|
| 301 |
+
except Exception:
|
| 302 |
+
pass
|
| 303 |
+
|
| 304 |
+
|
| 305 |
async def start_scheduler():
|
| 306 |
"""Start the background scheduler loop. Call from server lifespan."""
|
| 307 |
global _running
|
|
|
|
| 314 |
register_job("task_reminder", 300, _check_pending_tasks)
|
| 315 |
register_job("routine_check", 120, _check_routine)
|
| 316 |
|
| 317 |
+
# Load user-defined persistent jobs
|
| 318 |
+
_init_scheduler_db()
|
| 319 |
+
_load_user_jobs()
|
| 320 |
+
|
| 321 |
+
_log.info(f"Scheduler started with {len(_jobs)} system + {len(_user_jobs)} user job(s)")
|
| 322 |
|
| 323 |
while _running:
|
| 324 |
now = datetime.now(timezone.utc)
|
|
|
|
| 336 |
_log.warning(f"[SCHEDULER] {job['name']} failed: {e}")
|
| 337 |
job["last_run"] = now
|
| 338 |
|
| 339 |
+
# Check user-defined jobs
|
| 340 |
+
await _check_user_jobs()
|
| 341 |
+
|
| 342 |
await asyncio.sleep(60)
|
| 343 |
|
| 344 |
|
|
@@ -26,6 +26,7 @@ _log.setLevel(logging.DEBUG)
|
|
| 26 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
| 27 |
from fastapi.staticfiles import StaticFiles
|
| 28 |
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
| 29 |
from dotenv import load_dotenv
|
| 30 |
|
| 31 |
load_dotenv()
|
|
@@ -103,6 +104,27 @@ async def lifespan(app: FastAPI):
|
|
| 103 |
|
| 104 |
app = FastAPI(title="JARVIS", lifespan=lifespan)
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
def _check_auth(request: Request) -> bool:
|
| 108 |
"""Validate auth token from header, query param, or cookie."""
|
|
@@ -115,8 +137,9 @@ def _check_auth(request: Request) -> bool:
|
|
| 115 |
auth_header = request.headers.get("authorization", "")
|
| 116 |
if auth_header.startswith("Bearer ") and auth_header[7:] == AUTH_TOKEN:
|
| 117 |
return True
|
| 118 |
-
# Check query param
|
| 119 |
if request.query_params.get("token") == AUTH_TOKEN:
|
|
|
|
| 120 |
return True
|
| 121 |
# Check cookie
|
| 122 |
if request.cookies.get("jarvis_token") == AUTH_TOKEN:
|
|
@@ -126,13 +149,13 @@ def _check_auth(request: Request) -> bool:
|
|
| 126 |
|
| 127 |
# ββ Rate Limiting βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
import time as _time
|
| 129 |
-
from collections import defaultdict
|
| 130 |
|
| 131 |
-
_rate_limit_store: dict[str, list[float]] =
|
| 132 |
RATE_LIMIT_MAX = int(os.getenv("JARVIS_RATE_LIMIT", "30")) # requests per minute
|
| 133 |
RATE_LIMIT_WINDOW = 60 # seconds
|
| 134 |
|
| 135 |
-
_RATE_LIMITED_PREFIXES = ("/api/ask", "/ws", "/api/work", "/api/routine")
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
def _check_rate_limit(client_ip: str, path: str) -> bool:
|
|
@@ -140,10 +163,18 @@ def _check_rate_limit(client_ip: str, path: str) -> bool:
|
|
| 140 |
if not any(path.startswith(p) for p in _RATE_LIMITED_PREFIXES):
|
| 141 |
return True
|
| 142 |
now = _time.time()
|
| 143 |
-
|
|
|
|
|
|
|
| 144 |
# Purge old entries
|
| 145 |
-
_rate_limit_store[client_ip] = [t for t in
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
return False
|
| 148 |
_rate_limit_store[client_ip].append(now)
|
| 149 |
return True
|
|
@@ -216,12 +247,36 @@ def _get_os_context() -> str:
|
|
| 216 |
)
|
| 217 |
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
async def build_system_prompt(user_id: str = "default") -> str:
|
| 220 |
user_ctx = await get_user_context(user_id)
|
| 221 |
os_ctx = _get_os_context()
|
|
|
|
| 222 |
return SYSTEM_PROMPT.format(
|
| 223 |
tools_prompt=get_tools_prompt(),
|
| 224 |
-
memory_context=memory.get_context_summary() + user_ctx + os_ctx,
|
| 225 |
)
|
| 226 |
|
| 227 |
|
|
@@ -301,7 +356,7 @@ async def ask_jarvis(req: Request):
|
|
| 301 |
return {"error": "Message too long (max 10,000 characters)"}
|
| 302 |
|
| 303 |
backend = request.get("backend", "auto")
|
| 304 |
-
user_id = request.get("user_id", "default")
|
| 305 |
messages = [{"role": "user", "content": user_msg}]
|
| 306 |
system = await build_system_prompt(user_id)
|
| 307 |
params = AutoTune.get_params(user_msg)
|
|
@@ -469,14 +524,18 @@ async def websocket_endpoint(ws: WebSocket):
|
|
| 469 |
data = await ws.receive_json()
|
| 470 |
|
| 471 |
if data.get("type") == "set_user_id":
|
| 472 |
-
ws_user_id = data.get("user_id", "default")
|
| 473 |
continue
|
| 474 |
|
| 475 |
if data.get("type") == "message":
|
| 476 |
user_msg = data["content"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
backend = data.get("backend", "auto")
|
| 478 |
stm_enabled = data.get("stm", True)
|
| 479 |
-
ws_user_id = data.get("user_id", ws_user_id)
|
| 480 |
|
| 481 |
# Save user message
|
| 482 |
memory.add_message(conv_id, "user", user_msg)
|
|
@@ -744,7 +803,7 @@ async def api_register_device(req: Request):
|
|
| 744 |
alias=alias,
|
| 745 |
device_type=data.get("device_type", "computer"),
|
| 746 |
device_id=data.get("device_id", ""),
|
| 747 |
-
user_id=data.get("user_id", "default"),
|
| 748 |
)
|
| 749 |
return device
|
| 750 |
|
|
@@ -753,7 +812,7 @@ async def api_register_device(req: Request):
|
|
| 753 |
async def api_list_my_devices(req: Request):
|
| 754 |
"""List all registered devices for the current user."""
|
| 755 |
from user_device_registry import list_devices
|
| 756 |
-
user_id = req.query_params.get("user_id", "default")
|
| 757 |
devices = await list_devices(user_id)
|
| 758 |
return {"devices": devices}
|
| 759 |
|
|
@@ -785,7 +844,7 @@ async def api_send_device_command(req: Request):
|
|
| 785 |
target_alias=target_alias,
|
| 786 |
command=command,
|
| 787 |
source_device_id=data.get("source_device_id", ""),
|
| 788 |
-
user_id=data.get("user_id", "default"),
|
| 789 |
)
|
| 790 |
if "error" in result:
|
| 791 |
return JSONResponse(result, status_code=404)
|
|
@@ -879,6 +938,191 @@ async def dashboard_page():
|
|
| 879 |
return HTMLResponse("<h1>Dashboard not found</h1>", status_code=404)
|
| 880 |
|
| 881 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
if __name__ == "__main__":
|
| 883 |
import uvicorn
|
| 884 |
port = int(os.getenv("PORT", os.getenv("JARVIS_PORT", "8000")))
|
|
|
|
| 26 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
| 27 |
from fastapi.staticfiles import StaticFiles
|
| 28 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 29 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
from dotenv import load_dotenv
|
| 31 |
|
| 32 |
load_dotenv()
|
|
|
|
| 104 |
|
| 105 |
app = FastAPI(title="JARVIS", lifespan=lifespan)
|
| 106 |
|
| 107 |
+
# ββ CORS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("JARVIS_CORS_ORIGINS", "http://localhost:8000").split(",")]
|
| 109 |
+
app.add_middleware(
|
| 110 |
+
CORSMiddleware,
|
| 111 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 112 |
+
allow_credentials=True,
|
| 113 |
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
| 114 |
+
allow_headers=["Authorization", "Content-Type"],
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# ββ Allowed User IDs βββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
_ALLOWED_USERS = {u.strip() for u in os.getenv("JARVIS_ALLOWED_USERS", "default").split(",")}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _validate_user_id(user_id: str) -> str:
|
| 122 |
+
"""Validate user_id against whitelist. Returns 'default' if invalid."""
|
| 123 |
+
if user_id in _ALLOWED_USERS:
|
| 124 |
+
return user_id
|
| 125 |
+
_log.warning(f"[AUTH] Rejected unknown user_id: {user_id!r}")
|
| 126 |
+
return "default"
|
| 127 |
+
|
| 128 |
|
| 129 |
def _check_auth(request: Request) -> bool:
|
| 130 |
"""Validate auth token from header, query param, or cookie."""
|
|
|
|
| 137 |
auth_header = request.headers.get("authorization", "")
|
| 138 |
if auth_header.startswith("Bearer ") and auth_header[7:] == AUTH_TOKEN:
|
| 139 |
return True
|
| 140 |
+
# Check query param (deprecated β tokens in URLs leak via logs/Referer)
|
| 141 |
if request.query_params.get("token") == AUTH_TOKEN:
|
| 142 |
+
_log.warning(f"[AUTH] Token passed via query param (deprecated) β use Bearer header or cookie instead. Path: {path}")
|
| 143 |
return True
|
| 144 |
# Check cookie
|
| 145 |
if request.cookies.get("jarvis_token") == AUTH_TOKEN:
|
|
|
|
| 149 |
|
| 150 |
# ββ Rate Limiting βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 151 |
import time as _time
|
|
|
|
| 152 |
|
| 153 |
+
_rate_limit_store: dict[str, list[float]] = {}
|
| 154 |
RATE_LIMIT_MAX = int(os.getenv("JARVIS_RATE_LIMIT", "30")) # requests per minute
|
| 155 |
RATE_LIMIT_WINDOW = 60 # seconds
|
| 156 |
|
| 157 |
+
_RATE_LIMITED_PREFIXES = ("/api/ask", "/ws", "/api/work", "/api/routine", "/api/auth")
|
| 158 |
+
_AUTH_RATE_LIMIT_MAX = 5 # Stricter limit for auth endpoints (5 per minute)
|
| 159 |
|
| 160 |
|
| 161 |
def _check_rate_limit(client_ip: str, path: str) -> bool:
|
|
|
|
| 163 |
if not any(path.startswith(p) for p in _RATE_LIMITED_PREFIXES):
|
| 164 |
return True
|
| 165 |
now = _time.time()
|
| 166 |
+
if client_ip not in _rate_limit_store:
|
| 167 |
+
_rate_limit_store[client_ip] = [now]
|
| 168 |
+
return True
|
| 169 |
# Purge old entries
|
| 170 |
+
_rate_limit_store[client_ip] = [t for t in _rate_limit_store[client_ip] if now - t < RATE_LIMIT_WINDOW]
|
| 171 |
+
# Remove empty IP entries to prevent memory leak
|
| 172 |
+
if not _rate_limit_store[client_ip]:
|
| 173 |
+
del _rate_limit_store[client_ip]
|
| 174 |
+
return True
|
| 175 |
+
# Stricter limit for auth endpoints (brute-force protection)
|
| 176 |
+
limit = _AUTH_RATE_LIMIT_MAX if path.startswith("/api/auth") else RATE_LIMIT_MAX
|
| 177 |
+
if len(_rate_limit_store[client_ip]) >= limit:
|
| 178 |
return False
|
| 179 |
_rate_limit_store[client_ip].append(now)
|
| 180 |
return True
|
|
|
|
| 247 |
)
|
| 248 |
|
| 249 |
|
| 250 |
+
def _get_time_context() -> str:
|
| 251 |
+
"""Build time-aware context for smarter responses."""
|
| 252 |
+
from datetime import datetime
|
| 253 |
+
now = datetime.now()
|
| 254 |
+
hour = now.hour
|
| 255 |
+
|
| 256 |
+
if 5 <= hour < 12:
|
| 257 |
+
greeting_hint = "It's morning. Be energetic and proactive."
|
| 258 |
+
elif 12 <= hour < 17:
|
| 259 |
+
greeting_hint = "It's afternoon. Be focused and efficient."
|
| 260 |
+
elif 17 <= hour < 21:
|
| 261 |
+
greeting_hint = "It's evening. Be relaxed and wind-down oriented."
|
| 262 |
+
else:
|
| 263 |
+
greeting_hint = "It's nighttime. Be brief and quiet."
|
| 264 |
+
|
| 265 |
+
return (
|
| 266 |
+
f"\n[TIME CONTEXT]\n"
|
| 267 |
+
f"Current time: {now.strftime('%I:%M %p')}\n"
|
| 268 |
+
f"Date: {now.strftime('%A, %B %d, %Y')}\n"
|
| 269 |
+
f"{greeting_hint}\n"
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
async def build_system_prompt(user_id: str = "default") -> str:
|
| 274 |
user_ctx = await get_user_context(user_id)
|
| 275 |
os_ctx = _get_os_context()
|
| 276 |
+
time_ctx = _get_time_context()
|
| 277 |
return SYSTEM_PROMPT.format(
|
| 278 |
tools_prompt=get_tools_prompt(),
|
| 279 |
+
memory_context=memory.get_context_summary() + user_ctx + os_ctx + time_ctx,
|
| 280 |
)
|
| 281 |
|
| 282 |
|
|
|
|
| 356 |
return {"error": "Message too long (max 10,000 characters)"}
|
| 357 |
|
| 358 |
backend = request.get("backend", "auto")
|
| 359 |
+
user_id = _validate_user_id(request.get("user_id", "default"))
|
| 360 |
messages = [{"role": "user", "content": user_msg}]
|
| 361 |
system = await build_system_prompt(user_id)
|
| 362 |
params = AutoTune.get_params(user_msg)
|
|
|
|
| 524 |
data = await ws.receive_json()
|
| 525 |
|
| 526 |
if data.get("type") == "set_user_id":
|
| 527 |
+
ws_user_id = _validate_user_id(data.get("user_id", "default"))
|
| 528 |
continue
|
| 529 |
|
| 530 |
if data.get("type") == "message":
|
| 531 |
user_msg = data["content"]
|
| 532 |
+
# Enforce message length limit (same as REST endpoint)
|
| 533 |
+
if len(user_msg) > 10000:
|
| 534 |
+
await ws.send_json({"type": "error", "content": "Message too long (max 10,000 characters)"})
|
| 535 |
+
continue
|
| 536 |
backend = data.get("backend", "auto")
|
| 537 |
stm_enabled = data.get("stm", True)
|
| 538 |
+
ws_user_id = _validate_user_id(data.get("user_id", ws_user_id))
|
| 539 |
|
| 540 |
# Save user message
|
| 541 |
memory.add_message(conv_id, "user", user_msg)
|
|
|
|
| 803 |
alias=alias,
|
| 804 |
device_type=data.get("device_type", "computer"),
|
| 805 |
device_id=data.get("device_id", ""),
|
| 806 |
+
user_id=_validate_user_id(data.get("user_id", "default")),
|
| 807 |
)
|
| 808 |
return device
|
| 809 |
|
|
|
|
| 812 |
async def api_list_my_devices(req: Request):
|
| 813 |
"""List all registered devices for the current user."""
|
| 814 |
from user_device_registry import list_devices
|
| 815 |
+
user_id = _validate_user_id(req.query_params.get("user_id", "default"))
|
| 816 |
devices = await list_devices(user_id)
|
| 817 |
return {"devices": devices}
|
| 818 |
|
|
|
|
| 844 |
target_alias=target_alias,
|
| 845 |
command=command,
|
| 846 |
source_device_id=data.get("source_device_id", ""),
|
| 847 |
+
user_id=_validate_user_id(data.get("user_id", "default")),
|
| 848 |
)
|
| 849 |
if "error" in result:
|
| 850 |
return JSONResponse(result, status_code=404)
|
|
|
|
| 938 |
return HTMLResponse("<h1>Dashboard not found</h1>", status_code=404)
|
| 939 |
|
| 940 |
|
| 941 |
+
# ββ Scheduler API (user-defined jobs) ββββββββββββββββββββββββββββ
|
| 942 |
+
|
| 943 |
+
@app.get("/api/scheduler/jobs")
|
| 944 |
+
async def api_list_scheduled_jobs():
|
| 945 |
+
"""List all user-defined scheduled jobs."""
|
| 946 |
+
from scheduler import list_scheduled_jobs
|
| 947 |
+
return {"jobs": list_scheduled_jobs()}
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
@app.post("/api/scheduler/jobs")
|
| 951 |
+
async def api_add_scheduled_job(req: Request):
|
| 952 |
+
"""Add a new scheduled job. Body: {name, command, interval_seconds?, run_at?, repeat_daily?}"""
|
| 953 |
+
from scheduler import add_scheduled_job
|
| 954 |
+
data = await req.json()
|
| 955 |
+
name = data.get("name", "").strip()
|
| 956 |
+
command = data.get("command", "").strip()
|
| 957 |
+
if not name or not command:
|
| 958 |
+
return JSONResponse({"error": "name and command are required"}, status_code=400)
|
| 959 |
+
job = add_scheduled_job(
|
| 960 |
+
name=name,
|
| 961 |
+
command=command,
|
| 962 |
+
interval_seconds=data.get("interval_seconds", 0),
|
| 963 |
+
run_at=data.get("run_at", ""),
|
| 964 |
+
repeat_daily=data.get("repeat_daily", False),
|
| 965 |
+
)
|
| 966 |
+
return job
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
@app.delete("/api/scheduler/jobs/{job_id}")
|
| 970 |
+
async def api_remove_scheduled_job(job_id: int):
|
| 971 |
+
"""Remove a scheduled job by ID."""
|
| 972 |
+
from scheduler import remove_scheduled_job
|
| 973 |
+
remove_scheduled_job(job_id)
|
| 974 |
+
return {"deleted": job_id}
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
# ββ Automation Rules API βββββββββββββββββββββββββββββββββββββββββ
|
| 978 |
+
|
| 979 |
+
_automation_rules: list[dict] = []
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
@app.get("/api/automations")
|
| 983 |
+
async def api_list_automations():
|
| 984 |
+
"""List all automation rules (if-this-then-that)."""
|
| 985 |
+
return {"rules": _automation_rules}
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
@app.post("/api/automations")
|
| 989 |
+
async def api_add_automation(req: Request):
|
| 990 |
+
"""Add an automation rule. Body: {name, trigger, condition, action}
|
| 991 |
+
trigger: 'time', 'event', 'keyword'
|
| 992 |
+
condition: depends on trigger type (e.g., time='09:00', keyword='meeting')
|
| 993 |
+
action: JARVIS command to execute
|
| 994 |
+
"""
|
| 995 |
+
data = await req.json()
|
| 996 |
+
rule = {
|
| 997 |
+
"id": len(_automation_rules) + 1,
|
| 998 |
+
"name": data.get("name", "Untitled Rule"),
|
| 999 |
+
"trigger": data.get("trigger", ""),
|
| 1000 |
+
"condition": data.get("condition", ""),
|
| 1001 |
+
"action": data.get("action", ""),
|
| 1002 |
+
"enabled": data.get("enabled", True),
|
| 1003 |
+
}
|
| 1004 |
+
_automation_rules.append(rule)
|
| 1005 |
+
return rule
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
@app.delete("/api/automations/{rule_id}")
|
| 1009 |
+
async def api_remove_automation(rule_id: int):
|
| 1010 |
+
"""Remove an automation rule."""
|
| 1011 |
+
global _automation_rules
|
| 1012 |
+
_automation_rules = [r for r in _automation_rules if r.get("id") != rule_id]
|
| 1013 |
+
return {"deleted": rule_id}
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
# ββ Settings API βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1017 |
+
|
| 1018 |
+
@app.get("/api/settings")
|
| 1019 |
+
async def api_get_settings():
|
| 1020 |
+
"""Get all JARVIS settings (read from environment/preferences)."""
|
| 1021 |
+
prefs = await get_preferences()
|
| 1022 |
+
return {
|
| 1023 |
+
"language": os.getenv("JARVIS_LANGUAGE", "en"),
|
| 1024 |
+
"tts_backend": os.getenv("JARVIS_TTS_BACKEND", "say"),
|
| 1025 |
+
"tts_voice": os.getenv("JARVIS_TTS_VOICE", "Daniel"),
|
| 1026 |
+
"tts_rate": int(os.getenv("JARVIS_TTS_RATE", "180")),
|
| 1027 |
+
"wake_mode": os.getenv("JARVIS_WAKE_MODE", "auto"),
|
| 1028 |
+
"idle_quit_minutes": int(os.getenv("JARVIS_IDLE_QUIT_MINUTES", "10")),
|
| 1029 |
+
"auth_enabled": AUTH_ENABLED,
|
| 1030 |
+
"cors_origins": ALLOWED_ORIGINS,
|
| 1031 |
+
"rate_limit": RATE_LIMIT_MAX,
|
| 1032 |
+
"backend": get_active_backend(),
|
| 1033 |
+
"backends_available": get_available_backends(),
|
| 1034 |
+
"tools_count": len(TOOL_REGISTRY),
|
| 1035 |
+
"user_preferences": prefs,
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
@app.put("/api/settings")
|
| 1040 |
+
async def api_update_settings(req: Request):
|
| 1041 |
+
"""Update a user preference. Body: {key, value}"""
|
| 1042 |
+
data = await req.json()
|
| 1043 |
+
key = data.get("key", "").strip()
|
| 1044 |
+
value = data.get("value", "")
|
| 1045 |
+
if not key:
|
| 1046 |
+
return JSONResponse({"error": "key is required"}, status_code=400)
|
| 1047 |
+
from user_profile import save_preference
|
| 1048 |
+
await save_preference(key, str(value))
|
| 1049 |
+
return {"key": key, "value": value, "saved": True}
|
| 1050 |
+
|
| 1051 |
+
|
| 1052 |
+
# ββ Smart Context API ββββββββββββββββββββββββββββββββββββββββββββ
|
| 1053 |
+
|
| 1054 |
+
@app.get("/api/context")
|
| 1055 |
+
async def api_smart_context():
|
| 1056 |
+
"""Get smart context: time of day, location (if available), system state."""
|
| 1057 |
+
import platform as _plat
|
| 1058 |
+
now = datetime.now() if 'datetime' not in dir() else __import__('datetime').datetime.now()
|
| 1059 |
+
from datetime import datetime as _dt
|
| 1060 |
+
now = _dt.now()
|
| 1061 |
+
hour = now.hour
|
| 1062 |
+
|
| 1063 |
+
if 5 <= hour < 12:
|
| 1064 |
+
time_of_day = "morning"
|
| 1065 |
+
elif 12 <= hour < 17:
|
| 1066 |
+
time_of_day = "afternoon"
|
| 1067 |
+
elif 17 <= hour < 21:
|
| 1068 |
+
time_of_day = "evening"
|
| 1069 |
+
else:
|
| 1070 |
+
time_of_day = "night"
|
| 1071 |
+
|
| 1072 |
+
# Get location (macOS CoreLocation via AppleScript)
|
| 1073 |
+
location = None
|
| 1074 |
+
if _plat.system() == "Darwin":
|
| 1075 |
+
try:
|
| 1076 |
+
import subprocess
|
| 1077 |
+
loc_result = subprocess.run(
|
| 1078 |
+
["osascript", "-e", '''
|
| 1079 |
+
use framework "CoreLocation"
|
| 1080 |
+
set mgr to current application's CLLocationManager's alloc()'s init()
|
| 1081 |
+
set loc to mgr's location()
|
| 1082 |
+
if loc is not missing value then
|
| 1083 |
+
set lat to loc's coordinate()'s latitude() as real
|
| 1084 |
+
set lon to loc's coordinate()'s longitude() as real
|
| 1085 |
+
return (lat as string) & "," & (lon as string)
|
| 1086 |
+
end if
|
| 1087 |
+
return "unavailable"
|
| 1088 |
+
'''],
|
| 1089 |
+
capture_output=True, text=True, timeout=5,
|
| 1090 |
+
)
|
| 1091 |
+
loc_str = loc_result.stdout.strip()
|
| 1092 |
+
if loc_str and loc_str != "unavailable":
|
| 1093 |
+
parts = loc_str.split(",")
|
| 1094 |
+
if len(parts) == 2:
|
| 1095 |
+
location = {"lat": float(parts[0]), "lon": float(parts[1])}
|
| 1096 |
+
except Exception:
|
| 1097 |
+
pass
|
| 1098 |
+
|
| 1099 |
+
# Get active work session
|
| 1100 |
+
work_session = await get_active_work_session()
|
| 1101 |
+
|
| 1102 |
+
return {
|
| 1103 |
+
"time_of_day": time_of_day,
|
| 1104 |
+
"hour": hour,
|
| 1105 |
+
"date": now.strftime("%A, %B %d, %Y"),
|
| 1106 |
+
"timestamp": now.isoformat(),
|
| 1107 |
+
"location": location,
|
| 1108 |
+
"active_work_session": work_session.get("title") if work_session else None,
|
| 1109 |
+
"system": _plat.system(),
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
# ββ Mount Gradio UI (for HF Spaces) ββββββββββββββββββββββββββββββ
|
| 1114 |
+
try:
|
| 1115 |
+
import gradio as gr
|
| 1116 |
+
from gradio_app import create_gradio_app
|
| 1117 |
+
gradio_demo = create_gradio_app()
|
| 1118 |
+
app = gr.mount_gradio_app(app, gradio_demo, path="/gradio")
|
| 1119 |
+
_log.info("Gradio UI mounted at /gradio")
|
| 1120 |
+
except ImportError:
|
| 1121 |
+
_log.info("Gradio not installed β /gradio UI unavailable (using static HTML only)")
|
| 1122 |
+
except Exception as e:
|
| 1123 |
+
_log.warning(f"Gradio mount failed: {e}")
|
| 1124 |
+
|
| 1125 |
+
|
| 1126 |
if __name__ == "__main__":
|
| 1127 |
import uvicorn
|
| 1128 |
port = int(os.getenv("PORT", os.getenv("JARVIS_PORT", "8000")))
|
|
@@ -157,6 +157,63 @@
|
|
| 157 |
box-shadow: 0 0 10px rgba(187, 134, 252, 0.3);
|
| 158 |
}
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
select.ctrl-select {
|
| 161 |
background: var(--surface2);
|
| 162 |
border: 1px solid rgba(0, 180, 216, 0.2);
|
|
@@ -508,6 +565,9 @@
|
|
| 508 |
</style>
|
| 509 |
</head>
|
| 510 |
<body>
|
|
|
|
|
|
|
|
|
|
| 511 |
<!-- Header -->
|
| 512 |
<div class="header">
|
| 513 |
<div class="logo">
|
|
@@ -531,11 +591,70 @@
|
|
| 531 |
<div class="autotune-badge" id="autotune-badge">AT: <span id="autotune-type">β</span></div>
|
| 532 |
<button class="ctrl-btn" id="wakeword-btn" onclick="toggleWakeWord()" title="Always-on wake word: say 'Hey JARVIS'">WAKE</button>
|
| 533 |
<button class="ctrl-btn" onclick="newChat()" title="New Chat">NEW</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
</div>
|
| 535 |
</div>
|
| 536 |
|
| 537 |
<!-- Chat -->
|
| 538 |
-
<div class="chat-container" id="chat">
|
| 539 |
<div class="welcome" id="welcome">
|
| 540 |
<div class="arc-reactor-lg"></div>
|
| 541 |
<h2>J.A.R.V.I.S.</h2>
|
|
@@ -1292,6 +1411,102 @@
|
|
| 1292 |
}
|
| 1293 |
}
|
| 1294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1295 |
// Initialize
|
| 1296 |
checkAuth().then(ok => {
|
| 1297 |
if (ok) {
|
|
|
|
| 157 |
box-shadow: 0 0 10px rgba(187, 134, 252, 0.3);
|
| 158 |
}
|
| 159 |
|
| 160 |
+
/* Settings Panel */
|
| 161 |
+
.settings-panel {
|
| 162 |
+
position: fixed;
|
| 163 |
+
top: 50px;
|
| 164 |
+
right: 16px;
|
| 165 |
+
width: 380px;
|
| 166 |
+
max-height: 80vh;
|
| 167 |
+
overflow-y: auto;
|
| 168 |
+
background: var(--surface);
|
| 169 |
+
border: 1px solid var(--border);
|
| 170 |
+
border-radius: 12px;
|
| 171 |
+
padding: 20px;
|
| 172 |
+
z-index: 1000;
|
| 173 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 174 |
+
}
|
| 175 |
+
.settings-group {
|
| 176 |
+
margin-bottom: 14px;
|
| 177 |
+
}
|
| 178 |
+
.settings-group label {
|
| 179 |
+
display: block;
|
| 180 |
+
font-size: 11px;
|
| 181 |
+
color: var(--text2);
|
| 182 |
+
margin-bottom: 4px;
|
| 183 |
+
text-transform: uppercase;
|
| 184 |
+
letter-spacing: 0.5px;
|
| 185 |
+
}
|
| 186 |
+
.settings-group select, .settings-group input[type="range"] {
|
| 187 |
+
width: 100%;
|
| 188 |
+
padding: 6px 10px;
|
| 189 |
+
background: var(--bg2);
|
| 190 |
+
border: 1px solid var(--border);
|
| 191 |
+
color: var(--text);
|
| 192 |
+
border-radius: 6px;
|
| 193 |
+
font-size: 13px;
|
| 194 |
+
font-family: inherit;
|
| 195 |
+
}
|
| 196 |
+
.settings-group select:focus, .settings-group input:focus {
|
| 197 |
+
outline: 2px solid var(--accent);
|
| 198 |
+
outline-offset: 2px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/* Skip link for accessibility */
|
| 202 |
+
.skip-link {
|
| 203 |
+
position: absolute;
|
| 204 |
+
top: -40px;
|
| 205 |
+
left: 0;
|
| 206 |
+
background: var(--accent);
|
| 207 |
+
color: #000;
|
| 208 |
+
padding: 8px 16px;
|
| 209 |
+
z-index: 9999;
|
| 210 |
+
border-radius: 0 0 8px 0;
|
| 211 |
+
font-weight: 600;
|
| 212 |
+
}
|
| 213 |
+
.skip-link:focus {
|
| 214 |
+
top: 0;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
select.ctrl-select {
|
| 218 |
background: var(--surface2);
|
| 219 |
border: 1px solid rgba(0, 180, 216, 0.2);
|
|
|
|
| 565 |
</style>
|
| 566 |
</head>
|
| 567 |
<body>
|
| 568 |
+
<!-- Accessibility: Skip to main content -->
|
| 569 |
+
<a href="#message-input" class="skip-link">Skip to chat input</a>
|
| 570 |
+
|
| 571 |
<!-- Header -->
|
| 572 |
<div class="header">
|
| 573 |
<div class="logo">
|
|
|
|
| 591 |
<div class="autotune-badge" id="autotune-badge">AT: <span id="autotune-type">β</span></div>
|
| 592 |
<button class="ctrl-btn" id="wakeword-btn" onclick="toggleWakeWord()" title="Always-on wake word: say 'Hey JARVIS'">WAKE</button>
|
| 593 |
<button class="ctrl-btn" onclick="newChat()" title="New Chat">NEW</button>
|
| 594 |
+
<button class="ctrl-btn" onclick="toggleSettings()" title="Settings" aria-label="Settings">β</button>
|
| 595 |
+
</div>
|
| 596 |
+
</div>
|
| 597 |
+
|
| 598 |
+
<!-- Settings Panel -->
|
| 599 |
+
<div class="settings-panel" id="settings-panel" role="dialog" aria-label="Settings" style="display:none;">
|
| 600 |
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
| 601 |
+
<h3 style="margin:0;color:var(--accent);">Settings</h3>
|
| 602 |
+
<button onclick="toggleSettings()" style="background:none;border:none;color:var(--text2);font-size:20px;cursor:pointer;" aria-label="Close settings">×</button>
|
| 603 |
+
</div>
|
| 604 |
+
<div class="settings-group">
|
| 605 |
+
<label for="setting-language">Language</label>
|
| 606 |
+
<select id="setting-language" aria-label="Language">
|
| 607 |
+
<option value="en">English</option>
|
| 608 |
+
<option value="es">Spanish</option>
|
| 609 |
+
<option value="fr">French</option>
|
| 610 |
+
<option value="de">German</option>
|
| 611 |
+
<option value="it">Italian</option>
|
| 612 |
+
<option value="pt">Portuguese</option>
|
| 613 |
+
<option value="ja">Japanese</option>
|
| 614 |
+
<option value="ko">Korean</option>
|
| 615 |
+
<option value="zh">Chinese</option>
|
| 616 |
+
<option value="hi">Hindi</option>
|
| 617 |
+
<option value="ar">Arabic</option>
|
| 618 |
+
</select>
|
| 619 |
+
</div>
|
| 620 |
+
<div class="settings-group">
|
| 621 |
+
<label for="setting-tts">TTS Voice</label>
|
| 622 |
+
<select id="setting-tts" aria-label="TTS Voice">
|
| 623 |
+
<option value="Daniel">Daniel (Default)</option>
|
| 624 |
+
<option value="Samantha">Samantha</option>
|
| 625 |
+
<option value="Alex">Alex</option>
|
| 626 |
+
<option value="Karen">Karen</option>
|
| 627 |
+
<option value="Moira">Moira</option>
|
| 628 |
+
<option value="Tessa">Tessa</option>
|
| 629 |
+
<option value="elevenlabs">ElevenLabs (Premium)</option>
|
| 630 |
+
</select>
|
| 631 |
+
</div>
|
| 632 |
+
<div class="settings-group">
|
| 633 |
+
<label for="setting-rate">Speech Rate</label>
|
| 634 |
+
<input type="range" id="setting-rate" min="120" max="250" value="180" aria-label="Speech rate">
|
| 635 |
+
<span id="rate-value">180 WPM</span>
|
| 636 |
+
</div>
|
| 637 |
+
<div class="settings-group">
|
| 638 |
+
<label>Scheduled Jobs</label>
|
| 639 |
+
<div id="scheduled-jobs-list" style="font-size:12px;color:var(--text2);margin-top:4px;">Loading...</div>
|
| 640 |
+
<div style="display:flex;gap:6px;margin-top:8px;">
|
| 641 |
+
<input type="text" id="job-name" placeholder="Job name" style="flex:1;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px;">
|
| 642 |
+
<input type="text" id="job-command" placeholder="Command" style="flex:2;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px;">
|
| 643 |
+
<input type="text" id="job-time" placeholder="HH:MM" style="width:60px;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px;">
|
| 644 |
+
<button onclick="addScheduledJob()" style="padding:4px 10px;background:var(--accent);border:none;border-radius:4px;cursor:pointer;font-size:12px;color:#000;">Add</button>
|
| 645 |
+
</div>
|
| 646 |
+
</div>
|
| 647 |
+
<div class="settings-group">
|
| 648 |
+
<label>Automation Rules</label>
|
| 649 |
+
<div id="automation-rules-list" style="font-size:12px;color:var(--text2);margin-top:4px;">Loading...</div>
|
| 650 |
+
</div>
|
| 651 |
+
<div class="settings-group" style="margin-top:12px;">
|
| 652 |
+
<button onclick="saveSettings()" style="width:100%;padding:8px;background:var(--accent);border:none;border-radius:6px;cursor:pointer;font-weight:600;color:#000;">Save Settings</button>
|
| 653 |
</div>
|
| 654 |
</div>
|
| 655 |
|
| 656 |
<!-- Chat -->
|
| 657 |
+
<div class="chat-container" id="chat" role="log" aria-label="Chat messages" aria-live="polite">
|
| 658 |
<div class="welcome" id="welcome">
|
| 659 |
<div class="arc-reactor-lg"></div>
|
| 660 |
<h2>J.A.R.V.I.S.</h2>
|
|
|
|
| 1411 |
}
|
| 1412 |
}
|
| 1413 |
|
| 1414 |
+
// ββ Settings Panel ββββββββββββββββββββββββββββββ
|
| 1415 |
+
function toggleSettings() {
|
| 1416 |
+
const panel = document.getElementById('settings-panel');
|
| 1417 |
+
const visible = panel.style.display !== 'none';
|
| 1418 |
+
panel.style.display = visible ? 'none' : 'block';
|
| 1419 |
+
if (!visible) loadSettings();
|
| 1420 |
+
}
|
| 1421 |
+
|
| 1422 |
+
async function loadSettings() {
|
| 1423 |
+
try {
|
| 1424 |
+
const resp = await fetch('/api/settings', { headers: authHeaders() });
|
| 1425 |
+
const data = await resp.json();
|
| 1426 |
+
document.getElementById('setting-language').value = data.language || 'en';
|
| 1427 |
+
document.getElementById('setting-tts').value = data.tts_voice || 'Daniel';
|
| 1428 |
+
document.getElementById('setting-rate').value = data.tts_rate || 180;
|
| 1429 |
+
document.getElementById('rate-value').textContent = (data.tts_rate || 180) + ' WPM';
|
| 1430 |
+
} catch(e) { console.log('Settings load error', e); }
|
| 1431 |
+
|
| 1432 |
+
// Load scheduled jobs
|
| 1433 |
+
try {
|
| 1434 |
+
const resp = await fetch('/api/scheduler/jobs', { headers: authHeaders() });
|
| 1435 |
+
const data = await resp.json();
|
| 1436 |
+
const el = document.getElementById('scheduled-jobs-list');
|
| 1437 |
+
if (data.jobs && data.jobs.length > 0) {
|
| 1438 |
+
el.innerHTML = data.jobs.map(j =>
|
| 1439 |
+
`<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;">` +
|
| 1440 |
+
`<span>${j.name} β <em>${j.command}</em> ${j.run_at ? '@ '+j.run_at : j.interval_seconds+'s'}</span>` +
|
| 1441 |
+
`<button onclick="deleteJob(${j.id})" style="background:none;border:none;color:#f44;cursor:pointer;font-size:14px;">×</button></div>`
|
| 1442 |
+
).join('');
|
| 1443 |
+
} else {
|
| 1444 |
+
el.textContent = 'No scheduled jobs.';
|
| 1445 |
+
}
|
| 1446 |
+
} catch(e) { document.getElementById('scheduled-jobs-list').textContent = 'Could not load jobs.'; }
|
| 1447 |
+
|
| 1448 |
+
// Load automation rules
|
| 1449 |
+
try {
|
| 1450 |
+
const resp = await fetch('/api/automations', { headers: authHeaders() });
|
| 1451 |
+
const data = await resp.json();
|
| 1452 |
+
const el = document.getElementById('automation-rules-list');
|
| 1453 |
+
if (data.rules && data.rules.length > 0) {
|
| 1454 |
+
el.innerHTML = data.rules.map(r =>
|
| 1455 |
+
`<div style="padding:3px 0;">${r.name}: when ${r.trigger}="${r.condition}" β ${r.action}</div>`
|
| 1456 |
+
).join('');
|
| 1457 |
+
} else {
|
| 1458 |
+
el.textContent = 'No automation rules.';
|
| 1459 |
+
}
|
| 1460 |
+
} catch(e) { document.getElementById('automation-rules-list').textContent = 'Could not load rules.'; }
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
// Speech rate slider live update
|
| 1464 |
+
document.getElementById('setting-rate').addEventListener('input', function() {
|
| 1465 |
+
document.getElementById('rate-value').textContent = this.value + ' WPM';
|
| 1466 |
+
});
|
| 1467 |
+
|
| 1468 |
+
async function saveSettings() {
|
| 1469 |
+
const prefs = [
|
| 1470 |
+
{ key: 'language', value: document.getElementById('setting-language').value },
|
| 1471 |
+
{ key: 'tts_voice', value: document.getElementById('setting-tts').value },
|
| 1472 |
+
{ key: 'tts_rate', value: document.getElementById('setting-rate').value },
|
| 1473 |
+
];
|
| 1474 |
+
for (const p of prefs) {
|
| 1475 |
+
await fetch('/api/settings', {
|
| 1476 |
+
method: 'PUT',
|
| 1477 |
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
| 1478 |
+
body: JSON.stringify(p),
|
| 1479 |
+
});
|
| 1480 |
+
}
|
| 1481 |
+
toggleSettings();
|
| 1482 |
+
}
|
| 1483 |
+
|
| 1484 |
+
async function addScheduledJob() {
|
| 1485 |
+
const name = document.getElementById('job-name').value.trim();
|
| 1486 |
+
const command = document.getElementById('job-command').value.trim();
|
| 1487 |
+
const runAt = document.getElementById('job-time').value.trim();
|
| 1488 |
+
if (!name || !command) return alert('Name and command are required.');
|
| 1489 |
+
await fetch('/api/scheduler/jobs', {
|
| 1490 |
+
method: 'POST',
|
| 1491 |
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
| 1492 |
+
body: JSON.stringify({ name, command, run_at: runAt, repeat_daily: !!runAt }),
|
| 1493 |
+
});
|
| 1494 |
+
document.getElementById('job-name').value = '';
|
| 1495 |
+
document.getElementById('job-command').value = '';
|
| 1496 |
+
document.getElementById('job-time').value = '';
|
| 1497 |
+
loadSettings();
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
async function deleteJob(id) {
|
| 1501 |
+
await fetch('/api/scheduler/jobs/' + id, { method: 'DELETE', headers: authHeaders() });
|
| 1502 |
+
loadSettings();
|
| 1503 |
+
}
|
| 1504 |
+
|
| 1505 |
+
function authHeaders() {
|
| 1506 |
+
const token = localStorage.getItem('jarvis_token') || '';
|
| 1507 |
+
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
| 1508 |
+
}
|
| 1509 |
+
|
| 1510 |
// Initialize
|
| 1511 |
checkAuth().then(ok => {
|
| 1512 |
if (ok) {
|
|
@@ -1,9 +1,12 @@
|
|
| 1 |
"""JARVIS Tool System β extensible tool registry."""
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
import platform
|
| 5 |
from typing import Callable
|
| 6 |
|
|
|
|
|
|
|
| 7 |
TOOL_REGISTRY: dict[str, dict] = {}
|
| 8 |
|
| 9 |
# Tools that require macOS (osascript/subprocess) and should be delegated
|
|
@@ -90,6 +93,27 @@ async def execute_tool(name: str, args: dict) -> str:
|
|
| 90 |
if not IS_MACOS and name in _MACOS_ONLY_TOOLS:
|
| 91 |
return await _delegate_to_device(name, args)
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try:
|
| 94 |
result = TOOL_REGISTRY[name]["function"](**args)
|
| 95 |
if hasattr(result, "__await__"):
|
|
@@ -98,7 +122,8 @@ async def execute_tool(name: str, args: dict) -> str:
|
|
| 98 |
except TypeError as e:
|
| 99 |
return f"Error executing {name}: invalid arguments"
|
| 100 |
except Exception as e:
|
| 101 |
-
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
def register_macos_tools():
|
|
@@ -129,8 +154,10 @@ def register_macos_tools():
|
|
| 129 |
"spotlight_search", "finder_open", "finder_move", "finder_copy", "trash_file",
|
| 130 |
"contacts_search", "maps_directions",
|
| 131 |
"app_keystroke", "app_menu_click", "app_window_manage", "textedit_create",
|
|
|
|
|
|
|
| 132 |
# builtin.py β macOS-specific subset
|
| 133 |
-
"open_app", "open_terminal",
|
| 134 |
# vscode_tools.py β all 7 tools
|
| 135 |
"vscode_open", "vscode_open_terminal", "vscode_run_command",
|
| 136 |
"copilot_chat", "copilot_inline", "vscode_list_extensions", "vscode_diff",
|
|
|
|
| 1 |
"""JARVIS Tool System β extensible tool registry."""
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import logging
|
| 5 |
import platform
|
| 6 |
from typing import Callable
|
| 7 |
|
| 8 |
+
_log = logging.getLogger("jarvis.tools")
|
| 9 |
+
|
| 10 |
TOOL_REGISTRY: dict[str, dict] = {}
|
| 11 |
|
| 12 |
# Tools that require macOS (osascript/subprocess) and should be delegated
|
|
|
|
| 93 |
if not IS_MACOS and name in _MACOS_ONLY_TOOLS:
|
| 94 |
return await _delegate_to_device(name, args)
|
| 95 |
|
| 96 |
+
# Validate required parameters and basic types before calling
|
| 97 |
+
schema = TOOL_REGISTRY[name].get("parameters", {})
|
| 98 |
+
properties = schema.get("properties", {})
|
| 99 |
+
required = schema.get("required", [])
|
| 100 |
+
|
| 101 |
+
for req_param in required:
|
| 102 |
+
if req_param not in args:
|
| 103 |
+
return f"Error: {name} requires parameter '{req_param}'"
|
| 104 |
+
|
| 105 |
+
# Basic type coercion for common mismatches from LLM output
|
| 106 |
+
for param_name, param_value in list(args.items()):
|
| 107 |
+
if param_name in properties:
|
| 108 |
+
expected_type = properties[param_name].get("type")
|
| 109 |
+
if expected_type == "integer" and isinstance(param_value, str):
|
| 110 |
+
try:
|
| 111 |
+
args[param_name] = int(param_value)
|
| 112 |
+
except ValueError:
|
| 113 |
+
return f"Error: {name} parameter '{param_name}' must be an integer"
|
| 114 |
+
elif expected_type == "boolean" and isinstance(param_value, str):
|
| 115 |
+
args[param_name] = param_value.lower() in ("true", "1", "yes")
|
| 116 |
+
|
| 117 |
try:
|
| 118 |
result = TOOL_REGISTRY[name]["function"](**args)
|
| 119 |
if hasattr(result, "__await__"):
|
|
|
|
| 122 |
except TypeError as e:
|
| 123 |
return f"Error executing {name}: invalid arguments"
|
| 124 |
except Exception as e:
|
| 125 |
+
_log.error(f"Tool {name} failed: {type(e).__name__}: {e}")
|
| 126 |
+
return f"Error executing {name}: an unexpected error occurred"
|
| 127 |
|
| 128 |
|
| 129 |
def register_macos_tools():
|
|
|
|
| 154 |
"spotlight_search", "finder_open", "finder_move", "finder_copy", "trash_file",
|
| 155 |
"contacts_search", "maps_directions",
|
| 156 |
"app_keystroke", "app_menu_click", "app_window_manage", "textedit_create",
|
| 157 |
+
"photos_recent", "photos_search", "photos_albums", "photos_open",
|
| 158 |
+
"focus_status", "focus_set",
|
| 159 |
# builtin.py β macOS-specific subset
|
| 160 |
+
"open_app", "open_terminal", "run_in_terminal",
|
| 161 |
# vscode_tools.py β all 7 tools
|
| 162 |
"vscode_open", "vscode_open_terminal", "vscode_run_command",
|
| 163 |
"copilot_chat", "copilot_inline", "vscode_list_extensions", "vscode_diff",
|
|
@@ -709,7 +709,7 @@ def calendar_today() -> str:
|
|
| 709 |
if eventList is "" then return "No events today."
|
| 710 |
return eventList
|
| 711 |
end tell
|
| 712 |
-
''', timeout=
|
| 713 |
return f"Today's events:\n{result}"
|
| 714 |
|
| 715 |
|
|
@@ -992,9 +992,10 @@ def browser_read_page(browser: str = "safari") -> str:
|
|
| 992 |
def spotlight_search(query: str, limit: int = 10) -> str:
|
| 993 |
limit = max(1, min(50, limit))
|
| 994 |
try:
|
|
|
|
| 995 |
result = subprocess.run(
|
| 996 |
-
|
| 997 |
-
capture_output=True, text=True, timeout=10,
|
| 998 |
)
|
| 999 |
files = result.stdout.strip().split("\n")
|
| 1000 |
files = [f for f in files if f.strip()]
|
|
@@ -1371,6 +1372,248 @@ def app_window_manage(app_name: str, action: str) -> str:
|
|
| 1371 |
return f"{app_name}: {action}"
|
| 1372 |
|
| 1373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1374 |
@tool(
|
| 1375 |
name="textedit_create",
|
| 1376 |
description="Create and open a text document in TextEdit with given content",
|
|
|
|
| 709 |
if eventList is "" then return "No events today."
|
| 710 |
return eventList
|
| 711 |
end tell
|
| 712 |
+
''', timeout=30)
|
| 713 |
return f"Today's events:\n{result}"
|
| 714 |
|
| 715 |
|
|
|
|
| 992 |
def spotlight_search(query: str, limit: int = 10) -> str:
|
| 993 |
limit = max(1, min(50, limit))
|
| 994 |
try:
|
| 995 |
+
# mdfind doesn't support -limit; pipe through head instead
|
| 996 |
result = subprocess.run(
|
| 997 |
+
f'mdfind "{query}" | head -n {limit}',
|
| 998 |
+
shell=True, capture_output=True, text=True, timeout=10,
|
| 999 |
)
|
| 1000 |
files = result.stdout.strip().split("\n")
|
| 1001 |
files = [f for f in files if f.strip()]
|
|
|
|
| 1372 |
return f"{app_name}: {action}"
|
| 1373 |
|
| 1374 |
|
| 1375 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1376 |
+
# APPLE PHOTOS
|
| 1377 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1378 |
+
|
| 1379 |
+
|
| 1380 |
+
@tool(
|
| 1381 |
+
name="photos_recent",
|
| 1382 |
+
description="Get recent photos from Apple Photos library",
|
| 1383 |
+
parameters={
|
| 1384 |
+
"type": "object",
|
| 1385 |
+
"properties": {
|
| 1386 |
+
"count": {"type": "integer", "description": "Number of recent photos (default 5, max 20)"},
|
| 1387 |
+
},
|
| 1388 |
+
},
|
| 1389 |
+
)
|
| 1390 |
+
def photos_recent(count: int = 5) -> str:
|
| 1391 |
+
count = max(1, min(20, count))
|
| 1392 |
+
result = _osascript(f'''
|
| 1393 |
+
tell application "Photos"
|
| 1394 |
+
set photoList to ""
|
| 1395 |
+
set allPhotos to every media item
|
| 1396 |
+
set photoCount to count of allPhotos
|
| 1397 |
+
set startIdx to photoCount - {count} + 1
|
| 1398 |
+
if startIdx < 1 then set startIdx to 1
|
| 1399 |
+
repeat with i from startIdx to photoCount
|
| 1400 |
+
set p to item i of allPhotos
|
| 1401 |
+
set pName to ""
|
| 1402 |
+
try
|
| 1403 |
+
set pName to filename of p
|
| 1404 |
+
end try
|
| 1405 |
+
if pName is "" or pName is missing value then set pName to "Photo " & i
|
| 1406 |
+
set photoList to photoList & pName & " (" & date of p & ")\\n"
|
| 1407 |
+
end repeat
|
| 1408 |
+
return photoList
|
| 1409 |
+
end tell
|
| 1410 |
+
''', timeout=20)
|
| 1411 |
+
return f"Recent photos:\n{result}" if result else "No photos found."
|
| 1412 |
+
|
| 1413 |
+
|
| 1414 |
+
@tool(
|
| 1415 |
+
name="photos_search",
|
| 1416 |
+
description="Search Apple Photos by keyword (searches titles and descriptions)",
|
| 1417 |
+
parameters={
|
| 1418 |
+
"type": "object",
|
| 1419 |
+
"properties": {
|
| 1420 |
+
"query": {"type": "string", "description": "Search keyword"},
|
| 1421 |
+
},
|
| 1422 |
+
"required": ["query"],
|
| 1423 |
+
},
|
| 1424 |
+
)
|
| 1425 |
+
def photos_search(query: str) -> str:
|
| 1426 |
+
safe_query = _sanitize_applescript(query)
|
| 1427 |
+
result = _osascript(f'''
|
| 1428 |
+
tell application "Photos"
|
| 1429 |
+
set matchList to ""
|
| 1430 |
+
set matchPhotos to every media item whose name contains "{safe_query}" or description contains "{safe_query}"
|
| 1431 |
+
set matchCount to count of matchPhotos
|
| 1432 |
+
if matchCount > 20 then set matchCount to 20
|
| 1433 |
+
repeat with i from 1 to matchCount
|
| 1434 |
+
set p to item i of matchPhotos
|
| 1435 |
+
set matchList to matchList & name of p & " (" & date of p & ")\\n"
|
| 1436 |
+
end repeat
|
| 1437 |
+
if matchList is "" then return "No photos matching: {safe_query}"
|
| 1438 |
+
return matchList
|
| 1439 |
+
end tell
|
| 1440 |
+
''', timeout=20)
|
| 1441 |
+
return result
|
| 1442 |
+
|
| 1443 |
+
|
| 1444 |
+
@tool(
|
| 1445 |
+
name="photos_albums",
|
| 1446 |
+
description="List photo albums in Apple Photos",
|
| 1447 |
+
parameters={"type": "object", "properties": {}},
|
| 1448 |
+
)
|
| 1449 |
+
def photos_albums() -> str:
|
| 1450 |
+
result = _osascript('''
|
| 1451 |
+
tell application "Photos"
|
| 1452 |
+
set albumList to ""
|
| 1453 |
+
repeat with a in albums
|
| 1454 |
+
set albumList to albumList & name of a & " (" & (count of media items of a) & " photos)\\n"
|
| 1455 |
+
end repeat
|
| 1456 |
+
return albumList
|
| 1457 |
+
end tell
|
| 1458 |
+
''', timeout=20)
|
| 1459 |
+
return f"Albums:\n{result}" if result else "No albums found."
|
| 1460 |
+
|
| 1461 |
+
|
| 1462 |
+
@tool(
|
| 1463 |
+
name="photos_open",
|
| 1464 |
+
description="Open the Photos app, optionally to a specific album",
|
| 1465 |
+
parameters={
|
| 1466 |
+
"type": "object",
|
| 1467 |
+
"properties": {
|
| 1468 |
+
"album": {"type": "string", "description": "Album name to open (optional)"},
|
| 1469 |
+
},
|
| 1470 |
+
},
|
| 1471 |
+
)
|
| 1472 |
+
def photos_open(album: str = "") -> str:
|
| 1473 |
+
if album:
|
| 1474 |
+
safe_album = _sanitize_applescript(album)
|
| 1475 |
+
_osascript(f'''
|
| 1476 |
+
tell application "Photos"
|
| 1477 |
+
activate
|
| 1478 |
+
-- Photos doesn't support direct album navigation via AppleScript
|
| 1479 |
+
end tell
|
| 1480 |
+
''')
|
| 1481 |
+
return f"Photos opened (navigate to album: {album})"
|
| 1482 |
+
else:
|
| 1483 |
+
_osascript('tell application "Photos" to activate')
|
| 1484 |
+
return "Photos opened"
|
| 1485 |
+
|
| 1486 |
+
|
| 1487 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1488 |
+
# FOCUS MODES
|
| 1489 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1490 |
+
|
| 1491 |
+
|
| 1492 |
+
@tool(
|
| 1493 |
+
name="focus_status",
|
| 1494 |
+
description="Check the current Focus mode (Do Not Disturb, Work, Personal, etc.)",
|
| 1495 |
+
parameters={"type": "object", "properties": {}},
|
| 1496 |
+
)
|
| 1497 |
+
def focus_status() -> str:
|
| 1498 |
+
# Modern macOS: check Focus/DND via multiple methods
|
| 1499 |
+
try:
|
| 1500 |
+
# Method 1: Check DND assertion via plutil
|
| 1501 |
+
result = subprocess.run(
|
| 1502 |
+
["plutil", "-extract", "dnd_prefs", "raw",
|
| 1503 |
+
os.path.expanduser("~/Library/DoNotDisturb/DB/Assertions.json")],
|
| 1504 |
+
capture_output=True, text=True, timeout=5,
|
| 1505 |
+
)
|
| 1506 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 1507 |
+
return "Focus is ON (Do Not Disturb active)"
|
| 1508 |
+
except Exception:
|
| 1509 |
+
pass
|
| 1510 |
+
|
| 1511 |
+
try:
|
| 1512 |
+
# Method 2: Check via defaults
|
| 1513 |
+
result = subprocess.run(
|
| 1514 |
+
["defaults", "read", "com.apple.ncprefs", "dnd_prefs"],
|
| 1515 |
+
capture_output=True, text=True, timeout=5,
|
| 1516 |
+
)
|
| 1517 |
+
if "userPref" in result.stdout and "1" in result.stdout:
|
| 1518 |
+
return "Focus is ON"
|
| 1519 |
+
except Exception:
|
| 1520 |
+
pass
|
| 1521 |
+
|
| 1522 |
+
try:
|
| 1523 |
+
# Method 3: AppleScript fallback
|
| 1524 |
+
as_result = _osascript('''
|
| 1525 |
+
try
|
| 1526 |
+
do shell script "defaults read com.apple.controlcenter 'NSStatusItem Visible FocusModes'"
|
| 1527 |
+
return "Focus indicator visible in Control Center"
|
| 1528 |
+
on error
|
| 1529 |
+
return "Focus is OFF"
|
| 1530 |
+
end try
|
| 1531 |
+
''')
|
| 1532 |
+
return as_result
|
| 1533 |
+
except Exception:
|
| 1534 |
+
pass
|
| 1535 |
+
|
| 1536 |
+
return "Focus status: could not determine (likely OFF)"
|
| 1537 |
+
|
| 1538 |
+
|
| 1539 |
+
@tool(
|
| 1540 |
+
name="focus_set",
|
| 1541 |
+
description="Enable or disable a Focus mode (Do Not Disturb, Work, Personal, Sleep, etc.)",
|
| 1542 |
+
parameters={
|
| 1543 |
+
"type": "object",
|
| 1544 |
+
"properties": {
|
| 1545 |
+
"mode": {"type": "string", "description": "Focus mode name: 'dnd', 'work', 'personal', 'sleep', or 'off'"},
|
| 1546 |
+
"duration_minutes": {"type": "integer", "description": "Duration in minutes (0 = until manually turned off)"},
|
| 1547 |
+
},
|
| 1548 |
+
"required": ["mode"],
|
| 1549 |
+
},
|
| 1550 |
+
)
|
| 1551 |
+
def focus_set(mode: str, duration_minutes: int = 0) -> str:
|
| 1552 |
+
mode_lower = mode.lower().strip()
|
| 1553 |
+
|
| 1554 |
+
if mode_lower == "off":
|
| 1555 |
+
_osascript('''
|
| 1556 |
+
tell application "System Events"
|
| 1557 |
+
if exists process "ControlCenter" then
|
| 1558 |
+
-- Use Shortcuts to disable Focus
|
| 1559 |
+
end if
|
| 1560 |
+
end tell
|
| 1561 |
+
''')
|
| 1562 |
+
# Use shortcuts as the most reliable method
|
| 1563 |
+
try:
|
| 1564 |
+
subprocess.run(
|
| 1565 |
+
["shortcuts", "run", "Turn Off Focus"],
|
| 1566 |
+
capture_output=True, text=True, timeout=10,
|
| 1567 |
+
)
|
| 1568 |
+
return "Focus mode turned off"
|
| 1569 |
+
except Exception:
|
| 1570 |
+
# Fallback: toggle DND via AppleScript
|
| 1571 |
+
_osascript('''
|
| 1572 |
+
tell application "System Events"
|
| 1573 |
+
set doNotDisturb of appearance preferences to false
|
| 1574 |
+
end tell
|
| 1575 |
+
''')
|
| 1576 |
+
return "Do Not Disturb turned off"
|
| 1577 |
+
|
| 1578 |
+
# Map mode names to Shortcuts
|
| 1579 |
+
mode_map = {
|
| 1580 |
+
"dnd": "Do Not Disturb",
|
| 1581 |
+
"do not disturb": "Do Not Disturb",
|
| 1582 |
+
"work": "Work",
|
| 1583 |
+
"personal": "Personal",
|
| 1584 |
+
"sleep": "Sleep",
|
| 1585 |
+
"driving": "Driving",
|
| 1586 |
+
"fitness": "Fitness",
|
| 1587 |
+
"gaming": "Gaming",
|
| 1588 |
+
"reading": "Reading",
|
| 1589 |
+
}
|
| 1590 |
+
focus_name = mode_map.get(mode_lower, mode)
|
| 1591 |
+
|
| 1592 |
+
# Try using Shortcuts (most reliable for Focus modes)
|
| 1593 |
+
try:
|
| 1594 |
+
subprocess.run(
|
| 1595 |
+
["shortcuts", "run", f"Turn On {focus_name}"],
|
| 1596 |
+
capture_output=True, text=True, timeout=10,
|
| 1597 |
+
)
|
| 1598 |
+
dur_msg = f" for {duration_minutes} minutes" if duration_minutes else ""
|
| 1599 |
+
return f"Focus mode '{focus_name}' enabled{dur_msg}"
|
| 1600 |
+
except Exception:
|
| 1601 |
+
# Fallback for DND
|
| 1602 |
+
if mode_lower in ("dnd", "do not disturb"):
|
| 1603 |
+
_osascript('''
|
| 1604 |
+
tell application "System Events"
|
| 1605 |
+
set doNotDisturb of appearance preferences to true
|
| 1606 |
+
end tell
|
| 1607 |
+
''')
|
| 1608 |
+
return "Do Not Disturb enabled"
|
| 1609 |
+
return f"Could not enable Focus '{focus_name}'. Create a Shortcut named 'Turn On {focus_name}'."
|
| 1610 |
+
|
| 1611 |
+
|
| 1612 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1613 |
+
# TEXTEDIT
|
| 1614 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1615 |
+
|
| 1616 |
+
|
| 1617 |
@tool(
|
| 1618 |
name="textedit_create",
|
| 1619 |
description="Create and open a text document in TextEdit with given content",
|
|
@@ -142,8 +142,55 @@ def run_command(command: str) -> str:
|
|
| 142 |
return "Error: Command timed out (30s limit)"
|
| 143 |
except FileNotFoundError:
|
| 144 |
return f"Error: Command not found β '{args[0]}'"
|
| 145 |
-
except Exception
|
| 146 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
@tool(
|
|
@@ -175,6 +222,9 @@ _BLOCKED_PATH_FRAGMENTS = [
|
|
| 175 |
".ssh", ".gnupg", ".netrc", ".aws/credentials", ".aws/config",
|
| 176 |
"keychain", ".secret", "/etc/shadow", "/etc/sudoers",
|
| 177 |
".kube/config", ".docker/config.json",
|
|
|
|
|
|
|
|
|
|
| 178 |
]
|
| 179 |
_BLOCKED_WRITE_PATHS = _BLOCKED_PATH_FRAGMENTS + [
|
| 180 |
".env", ".zshrc", ".bashrc", ".bash_profile", ".profile",
|
|
@@ -201,8 +251,12 @@ def read_file(path: str) -> str:
|
|
| 201 |
with open(p, "r") as f:
|
| 202 |
content = f.read()
|
| 203 |
return content[:5000] if len(content) > 5000 else content
|
| 204 |
-
except
|
| 205 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
@tool(
|
|
@@ -228,8 +282,10 @@ def write_file(path: str, content: str) -> str:
|
|
| 228 |
with open(p, "w") as f:
|
| 229 |
f.write(content)
|
| 230 |
return f"Written to {p}"
|
| 231 |
-
except
|
| 232 |
-
return
|
|
|
|
|
|
|
| 233 |
|
| 234 |
|
| 235 |
@tool(
|
|
|
|
| 142 |
return "Error: Command timed out (30s limit)"
|
| 143 |
except FileNotFoundError:
|
| 144 |
return f"Error: Command not found β '{args[0]}'"
|
| 145 |
+
except Exception:
|
| 146 |
+
return "Error: Could not run command."
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@tool(
|
| 150 |
+
name="run_in_terminal",
|
| 151 |
+
description="Run a command in Terminal.app and capture the output. "
|
| 152 |
+
"Unlike run_command, this opens a visible Terminal window.",
|
| 153 |
+
parameters={
|
| 154 |
+
"type": "object",
|
| 155 |
+
"properties": {
|
| 156 |
+
"command": {"type": "string", "description": "Shell command to execute"},
|
| 157 |
+
"wait": {"type": "boolean", "description": "Wait for completion and capture output (default true)"},
|
| 158 |
+
},
|
| 159 |
+
"required": ["command"],
|
| 160 |
+
},
|
| 161 |
+
)
|
| 162 |
+
def run_in_terminal(command: str, wait: bool = True) -> str:
|
| 163 |
+
import shlex
|
| 164 |
+
safe_cmd = command.replace('"', '\\"').replace("'", "'\\''")
|
| 165 |
+
if wait:
|
| 166 |
+
# Run in Terminal, capture output via temp file
|
| 167 |
+
tmp_file = "/tmp/jarvis_terminal_output.txt"
|
| 168 |
+
script = f'''
|
| 169 |
+
tell application "Terminal"
|
| 170 |
+
activate
|
| 171 |
+
do script "{safe_cmd} > {tmp_file} 2>&1"
|
| 172 |
+
end tell
|
| 173 |
+
'''
|
| 174 |
+
try:
|
| 175 |
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=5)
|
| 176 |
+
# Wait briefly for command to complete
|
| 177 |
+
import time
|
| 178 |
+
time.sleep(3)
|
| 179 |
+
try:
|
| 180 |
+
with open(tmp_file, "r") as f:
|
| 181 |
+
output = f.read()
|
| 182 |
+
return output[:3000] if output else "(No output captured)"
|
| 183 |
+
except FileNotFoundError:
|
| 184 |
+
return "Command sent to Terminal (output not yet available)"
|
| 185 |
+
except Exception:
|
| 186 |
+
return "Error: Could not open Terminal."
|
| 187 |
+
else:
|
| 188 |
+
script = f'tell application "Terminal" to do script "{safe_cmd}"'
|
| 189 |
+
try:
|
| 190 |
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=5)
|
| 191 |
+
return f"Command sent to Terminal: {command}"
|
| 192 |
+
except Exception:
|
| 193 |
+
return "Error: Could not open Terminal."
|
| 194 |
|
| 195 |
|
| 196 |
@tool(
|
|
|
|
| 222 |
".ssh", ".gnupg", ".netrc", ".aws/credentials", ".aws/config",
|
| 223 |
"keychain", ".secret", "/etc/shadow", "/etc/sudoers",
|
| 224 |
".kube/config", ".docker/config.json",
|
| 225 |
+
# Private keys and certificates
|
| 226 |
+
".pem", ".key", ".p12", ".pfx", "id_rsa", "id_ed25519", "id_ecdsa",
|
| 227 |
+
".keystore", ".jks",
|
| 228 |
]
|
| 229 |
_BLOCKED_WRITE_PATHS = _BLOCKED_PATH_FRAGMENTS + [
|
| 230 |
".env", ".zshrc", ".bashrc", ".bash_profile", ".profile",
|
|
|
|
| 251 |
with open(p, "r") as f:
|
| 252 |
content = f.read()
|
| 253 |
return content[:5000] if len(content) > 5000 else content
|
| 254 |
+
except FileNotFoundError:
|
| 255 |
+
return "Error: File not found."
|
| 256 |
+
except PermissionError:
|
| 257 |
+
return "Error: Permission denied."
|
| 258 |
+
except Exception:
|
| 259 |
+
return "Error: Could not read file."
|
| 260 |
|
| 261 |
|
| 262 |
@tool(
|
|
|
|
| 282 |
with open(p, "w") as f:
|
| 283 |
f.write(content)
|
| 284 |
return f"Written to {p}"
|
| 285 |
+
except PermissionError:
|
| 286 |
+
return "Error: Permission denied."
|
| 287 |
+
except Exception:
|
| 288 |
+
return "Error: Could not write file."
|
| 289 |
|
| 290 |
|
| 291 |
@tool(
|