Khanna, Videh Rakesh Rakesh Claude Sonnet 4.6 commited on
Commit
0191dfa
Β·
1 Parent(s): 2aa3ea6

feat: security hardening, transcription mode, 15 new features, Gradio UI

Browse files

Security (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 CHANGED
@@ -1,9 +1,9 @@
1
  # Claude API (optional β€” premium quality)
2
- ANTHROPIC_API_KEY=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
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=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
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)
Dockerfile CHANGED
@@ -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"]
gradio_app.py ADDED
@@ -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)
jarvis_listener.py CHANGED
@@ -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="en",
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
- subprocess.run(["say", "-v", "Daniel", "-r", "180", text], stderr=subprocess.DEVNULL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="en",
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:
memory.py CHANGED
@@ -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
- conn.execute(f"PRAGMA key='{_ENCRYPTION_KEY}'")
 
 
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
overlay.py CHANGED
@@ -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():
requirements-server.txt CHANGED
@@ -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
scheduler.py CHANGED
@@ -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
- _log.info(f"Scheduler started with {len(_jobs)} job(s)")
 
 
 
 
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
 
server.py CHANGED
@@ -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]] = defaultdict(list)
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
- window = _rate_limit_store[client_ip]
 
 
144
  # Purge old entries
145
- _rate_limit_store[client_ip] = [t for t in window if now - t < RATE_LIMIT_WINDOW]
146
- if len(_rate_limit_store[client_ip]) >= RATE_LIMIT_MAX:
 
 
 
 
 
 
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")))
static/index.html CHANGED
@@ -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">&times;</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;">&times;</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) {
tools/__init__.py CHANGED
@@ -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
- return f"Error executing {name}: {type(e).__name__}"
 
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",
tools/app_automation.py CHANGED
@@ -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=15)
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
- ["mdfind", "-limit", str(limit), query],
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",
tools/builtin.py CHANGED
@@ -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 as e:
146
- return f"Error running command: {type(e).__name__}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Exception as e:
205
- return f"Error reading file: {type(e).__name__}"
 
 
 
 
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 Exception as e:
232
- return f"Error writing file: {type(e).__name__}"
 
 
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(