A local, voice-first personal AI assistant for Apple Silicon Macs. Wake word → on-device speech-to-text → local reasoning → on-device text-to-speech, with a lightweight always-on-top HUD and a menu bar icon.
This is the fully-local, zero-signup edition: no Anthropic API key, no
Picovoice account, no cloud reasoning call, no per-token cost, ever.
Speech-to-text, text-to-speech, wake word, and the reasoning step all run
on-device. The only paid dependency the original design called for — the
Claude API — has been swapped for a local instruct model via mlx-lm with
a hand-built tool-calling loop, and the wake word uses openWakeWord (a
pretrained "hey jarvis" model, no account) instead of Picovoice Porcupine
(which requires signing up). See Architecture note
for the reasoning trade-offs.
[Mic] → Wake word (openWakeWord, on-device, pretrained "hey jarvis" model)
→ Speech-to-text (MLX Whisper, on-device)
→ Local LLM + tool loop (mlx-lm, on-device — no cloud call)
├── Memory store (memories/, persists across sessions)
└── Local tools (memory, files, web search, shell, browser control)
→ Text-to-speech (MLX/Kokoro, on-device)
→ [Speaker] + HUD (idle / listening / thinking / speaking)
Turn-based (wake → record → think → speak, one exchange at a time). This was built and pushed from a Linux CI sandbox that has no microphone, no Apple GPU, and no macOS — every module is real, working code, but the hardware-bound phases (2, 5, 6) have only been verified with mocked hardware in the test suite. You need to actually run this on your Mac to validate the acceptance tests below.
xcode-select --installgit clone <this repo>
cd jarvis
./setup.sh
setup.sh is idempotent — safe to re-run. It installs portaudio/ffmpeg
via Homebrew, creates .venv (Python 3.12, arm64), installs all Python
deps (including mlx-lm for local reasoning), downloads openWakeWord's
pretrained models (free, no account), checks for Node (needed for the
Playwright MCP browser tool), and copies .env.example to .env if you
don't already have one — no keys need to be filled in.
Then:
source .venv/bin/activate
python -c "import mlx_whisper, mlx_audio, mlx_lm, openwakeword" # should exit 0
The first time Jarvis records audio, macOS shows a native microphone permission dialog — accept it. Don't try to pre-grant this programmatically. If the dialog never appears, add your terminal app manually under System Settings → Privacy & Security → Microphone, then restart the terminal.
The first time Jarvis actually thinks (any Phase 1+ test below), it
downloads the local model from Hugging Face — a few GB, one-time, needs a
network connection. After that it's cached under ~/.cache/huggingface
and every subsequent run is fully offline.
python -m src.system.config # prints merged config.yaml + .env, secrets redacted
python scripts/chat_cli.py
Have a multi-turn conversation. Ask it to search the web, read a file in the
project folder, or run a shell command. Ask a follow-up that depends on
something you said two turns ago — conversation history is kept in memory
for the lifetime of the process (src/brain/agent.py's JarvisSession).
python -m src.pipeline
Say "Jarvis" (or whatever JARVIS_WAKE_WORD is set to) from across the
room, ask a question, and listen for a spoken reply. Watch stdout for
[idle] → [listening] → [thinking] → [speaking] transitions. Nothing
here makes a network call after the one-time model download — this is
the milestone the original spec calls out as "the point where this stops
being a plan and starts being Jarvis," now with zero recurring cost.
openWakeWord ships six pretrained models (hey jarvis, alexa, hey mycroft, hey rhasspy, current weather, timers); JARVIS_WAKE_WORD
just needs to be a substring that matches one of their names (default
jarvis matches the bundled hey_jarvis model). If you ever train a
fully custom model, point JARVIS_WAKE_WORD_MODEL_PATH at it instead.
src/brain/tools.py registers: memory, read_file/write_file (scoped
to filesystem_allowlist in config.yaml), run_shell, and web_search
(free — scrapes DuckDuckGo's HTML endpoint, no API key). Browser control
(@playwright/mcp) is discovered dynamically over a minimal MCP stdio
client (src/brain/mcp_client.py) if Node is installed; it degrades
silently to "unavailable" otherwise.
Try: "search the web for the weather in Boston" (runs without asking) vs. "write a file called test.txt in Documents saying hello" (should speak/print the exact action and wait for you to say "confirm" — see the confirmation gate below).
Known gap vs. the original spec: Gmail/Google Drive integration is not implemented in this local-LLM edition. The original design reused Claude Agent SDK's native MCP client to wire in your account's Gmail/Drive connectors; without that SDK, wiring the same connectors would mean building a full HTTP+SSE JSON-RPC MCP client with OAuth handling from scratch, which wasn't done here. If you want it, the cleanest path is adding a proper MCP HTTP client alongside
mcp_client.py's stdio one.
python scripts/chat_cli.py
# > remember that I take my coffee black, no sugar
# Ctrl-C, restart:
python scripts/chat_cli.py
# > how do I take my coffee
Facts persist in memories/ (see src/brain/memory.py, which implements
Anthropic's memory tool file operations — view/create/str_replace/
insert/delete/rename — against that directory, with path-traversal
protection; the command set is Anthropic's design, but it's just plain
Python now, no Claude dependency). memories/preferences.md is seeded on
first run if missing.
./install_daemon.sh
Installs and loads three LaunchAgents (see the architecture note below for why it's three, not one). Reboot (or log out/in). The menu bar icon and the HUD should both appear automatically with no manual terminal command, and the wake word should work immediately.
Three independent processes make up the full experience — run each in its
own terminal tab (source .venv/bin/activate in each first):
python -m src.main # app window + its WebSocket relay server
python -m src.pipeline # voice pipeline (wake word / STT / brain / TTS)
python -m src.system.menubar # menu bar icon (optional)
This opens a normal, resizable window (not a tiny corner overlay — see the design note below) with five tabs:
src.pipeline terminal.~/Library/Logs/jarvis_transcript.jsonl so it survives restarts.memories/ without
touching the filesystem directly.~/Library/Logs/jarvis_tool_calls.jsonl. This is the same "check before
trusting a claim" ground truth as the log file, just in the UI.config.yaml (wake word, voice, models, filesystem
allowlist, confirmation requirements) from a form instead of by hand.The frontend is a real React app (src/hud/web/frontend/), built with Vite
and committed pre-built to src/hud/web/dist/ — running Jarvis never
requires Node.js or npm. If you modify the frontend source, rebuild with:
cd src/hud/web/frontend
npm install
npm run build
Design trade-off: the original spec called for a tiny, always-on-top, click-through corner widget. That's fundamentally incompatible with a Settings panel or a scrollable history list — you can't click a tab if clicks pass through the window. Building out the fuller UI meant giving up the minimal-ambient-widget behavior in favor of a normal app window.
Why three processes, not one: rumps (menu bar) and pywebview (HUD window) each require macOS's Cocoa main-thread run loop, and AppKit only tolerates one owner of it per process — and opening the microphone for the first time hit that same constraint. Both failures were fatal, not cosmetic: running rumps in a background thread inside a combined
src.main, and separately running the voice pipeline in that same process, each crashed the entire process — HUD included — with an uncaught native exception the instant the mic or the menu bar initialized. So nowsrc.main(HUD),src.pipeline(microphone), andsrc.system.menubar(menu bar) are three independent processes that never share Cocoa or mic access. They talk to each other only over the HUD's WebSocket server (src/hud/server.py): the voice pipeline pushes state updates in as a client (src/hud/client.py), the React app and the menu bar both consume them as clients — the React app additionally uses the same connection as a request/response API (src/hud/api.py) for its History/Memory/Settings/Activity tabs. The trade-off: the old "Pause Jarvis" menu bar toggle is gone, since the process showing the menu bar no longer owns the pipeline it would be pausing. To pause, stopsrc.pipelineitself (Ctrl-C, orlaunchctl unloadits LaunchAgent).
source .venv/bin/activate
pip install -e ".[dev]"
pytest
The test suite runs anywhere (it was written and verified in a Linux CI
sandbox with no MLX/macOS available) by exercising the hardware-independent
logic directly — the memory tool's file operations, the confirmation-gate/
filesystem-scoping logic, the tool-calling loop's JSON parsing (with a fake
local LLM standing in for mlx-lm), config parsing, and the audio modules'
pure logic (sentence splitting, wav writing, wake-word score matching)
against mocked mlx_whisper/mlx_audio/openwakeword/sounddevice. It
does not and cannot verify actual local
model quality/tool-calling reliability, transcription accuracy, TTS audio
quality, wake-word detection from real audio, or launchd/rumps/pywebview
behavior — those are the phase acceptance tests above, and they require
your actual Mac.
The original design for this project used Claude (via the Claude Agent SDK) as the reasoning brain, with STT/TTS local and only the reasoning step hitting the API — a very cheap setup, but not literally $0. This edition was built after an explicit request for zero ongoing cost, which means the brain had to move on-device too. The trade-offs that come with that:
src/brain/agent.py), which is
inherently less robust. In testing, a 3B model didn't just fail to call
a tool — it fabricated an entire fake tool execution: it invented a
shell script, invented a fake confirmation exchange, and then claimed
"the daemon has been installed and is now running," all without ever
emitting a real tool call or touching the filesystem. model.local
defaults to 8B for this reason, with a hardened system prompt that
explicitly forbids claiming an action succeeded without a real tool
result. This significantly reduces but does not eliminate the risk —
always check ~/Library/Logs/jarvis.log (see Safety rules below) if
Jarvis claims to have done something consequential; only lines starting
TOOL_CALL reflect something that actually happened. If you see this
behavior at 8B, bump to local_heavy (14B) or larger.src/brain/web_search.py.If you'd rather have Claude's reasoning quality and are fine with the
(small, usage-based) API cost, the swap back is mostly confined to
src/brain/agent.py, src/brain/tools.py, and config.yaml's model:
section — the audio pipeline, HUD, memory tool, and safety gates are
unchanged either way.
require_confirmation_for
in config.yaml (delete_file, run_shell_command, Write) is
intercepted by ToolGuard.check() (src/brain/tools.py), called by the
tool loop before every execution, which speaks/prints the exact pending
action and denies it unless you explicitly say/type "confirm".read_file/write_file are denied outside the
directories listed in filesystem_allowlist (default: ~/Documents,
~/Desktop, the project folder) — never widen this to the whole home
directory or root. This check is baked directly into the tool
implementations, so the model has no other path to the filesystem.~/Library/Logs/jarvis.log via ToolGuard.log() as a TOOL_CALL line —
this is the ground truth for what Jarvis actually did. If Jarvis says it
did something and there's no matching TOOL_CALL line, it didn't happen
(see the architecture note above on local models fabricating actions).src/audio/recorder.py); wake-word
listening (src/audio/wake_word.py) inspects small rolling frames and
discards them immediately — nothing is ever continuously streamed
anywhere, local or cloud..env, it's gitignored
and never committed.openwakeword fails to import, or the model download hangs: confirm
arm64 Python — python3 -c "import platform; print(platform.machine())"
should print arm64, not x86_64. If it prints x86_64, you're running
under Rosetta; install an arm64-native Python (e.g.
brew install python@3.12) and re-run setup.sh. The model download
itself needs a network connection the first time only.audio.wake_word_sensitivity in config.yaml (e.g. to 0.4) if it's not
triggering, or raising it (e.g. to 0.8) if it's too trigger-happy.mlx-whisper/mlx-audio/mlx-lm should use the GPU via MLX
automatically on Apple Silicon. If not, delete .venv and re-run
setup.sh in a clean arm64-only environment.~/Library/Logs/jarvis.log for a matching TOOL_CALL line. If there
isn't one, it didn't happen — the model narrated it in plain text
instead of actually calling the tool. This is a known failure mode of
small local models (see the architecture note above); switch
model.local to model.local_heavy's value or something bigger if you
see it, and never take a consequential claim at face value without
checking the log.~/Library/Logs/jarvis.log, and confirm the installed plist's
ProgramArguments points at .venv/bin/python, not the system Python
(install_daemon.sh does this substitution automatically — re-run it if
you moved the project folder).pynput,
macOS requires granting Accessibility + Input Monitoring permission to
the terminal/app separately from microphone access.jarvis/
├── setup.sh install_daemon.sh
├── config.yaml .env.example / .env (gitignored)
├── memories/ persisted memory-tool files
├── src/
│ ├── main.py HUD process: window + WebSocket relay server (no mic)
│ ├── pipeline.py voice process: wake → stt → brain → tts (no Cocoa)
│ ├── audio/ wake_word.py, recorder.py, stt.py, tts.py
│ ├── brain/
│ │ ├── agent.py local tool-calling loop (JarvisSession, run_turn)
│ │ ├── local_llm.py mlx-lm model loading + chat()
│ │ ├── tools.py tool registry + ToolGuard (confirm/scope/log)
│ │ ├── tool_types.py Tool dataclass
│ │ ├── memory.py memory-tool file ops
│ │ ├── web_search.py free DuckDuckGo search
│ │ ├── mcp_client.py minimal stdio MCP client
│ │ └── browser_tools.py wraps @playwright/mcp as local tools
│ ├── hud/
│ │ ├── server.py WebSocket relay + request/response dispatch
│ │ ├── client.py HudStateReporter (pipeline → HUD state pusher)
│ │ ├── api.py backend handlers: memory/transcript/tool-calls/config
│ │ └── web/
│ │ ├── frontend/ React app source (Vite)
│ │ └── dist/ built output — this is what src.main actually loads
│ └── system/ config.py, menubar.py (own process), daemon/*.plist
├── scripts/chat_cli.py text-only harness (Phase 1)
└── tests/ pytest suite (hardware-independent)
$0 recurring, $0 up front. STT, TTS, and reasoning are all on-device via MLX; openWakeWord, the memory tool, launchd, rumps, and pywebview are all free and open-source with no account needed. The only network usage is the one-time model downloads (LLM + wake word) on first run.
Full-duplex/barge-in via Pipecat, Home Assistant integration, proactive calendar-aware speech, computer-use tool access, a cloned custom voice, and Gmail/Drive integration (see the architecture note above) are deliberately out of scope for this edition.
Python
76.9%
JavaScript
12.2%
CSS
6.1%
Swift
2.4%
Shell
2.3%
A local, voice-first personal AI assistant for Apple Silicon Macs. Wake word → on-device speech-to-text → local reasoning → on-device text-to-speech, with a lightweight always-on-top HUD and a menu bar icon.
This is the fully-local, zero-signup edition: no Anthropic API key, no
Picovoice account, no cloud reasoning call, no per-token cost, ever.
Speech-to-text, text-to-speech, wake word, and the reasoning step all run
on-device. The only paid dependency the original design called for — the
Claude API — has been swapped for a local instruct model via mlx-lm with
a hand-built tool-calling loop, and the wake word uses openWakeWord (a
pretrained "hey jarvis" model, no account) instead of Picovoice Porcupine
(which requires signing up). See Architecture note
for the reasoning trade-offs.
[Mic] → Wake word (openWakeWord, on-device, pretrained "hey jarvis" model)
→ Speech-to-text (MLX Whisper, on-device)
→ Local LLM + tool loop (mlx-lm, on-device — no cloud call)
├── Memory store (memories/, persists across sessions)
└── Local tools (memory, files, web search, shell, browser control)
→ Text-to-speech (MLX/Kokoro, on-device)
→ [Speaker] + HUD (idle / listening / thinking / speaking)
Turn-based (wake → record → think → speak, one exchange at a time). This was built and pushed from a Linux CI sandbox that has no microphone, no Apple GPU, and no macOS — every module is real, working code, but the hardware-bound phases (2, 5, 6) have only been verified with mocked hardware in the test suite. You need to actually run this on your Mac to validate the acceptance tests below.
xcode-select --installgit clone <this repo>
cd jarvis
./setup.sh
setup.sh is idempotent — safe to re-run. It installs portaudio/ffmpeg
via Homebrew, creates .venv (Python 3.12, arm64), installs all Python
deps (including mlx-lm for local reasoning), downloads openWakeWord's
pretrained models (free, no account), checks for Node (needed for the
Playwright MCP browser tool), and copies .env.example to .env if you
don't already have one — no keys need to be filled in.
Then:
source .venv/bin/activate
python -c "import mlx_whisper, mlx_audio, mlx_lm, openwakeword" # should exit 0
The first time Jarvis records audio, macOS shows a native microphone permission dialog — accept it. Don't try to pre-grant this programmatically. If the dialog never appears, add your terminal app manually under System Settings → Privacy & Security → Microphone, then restart the terminal.
The first time Jarvis actually thinks (any Phase 1+ test below), it
downloads the local model from Hugging Face — a few GB, one-time, needs a
network connection. After that it's cached under ~/.cache/huggingface
and every subsequent run is fully offline.
python -m src.system.config # prints merged config.yaml + .env, secrets redacted
python scripts/chat_cli.py
Have a multi-turn conversation. Ask it to search the web, read a file in the
project folder, or run a shell command. Ask a follow-up that depends on
something you said two turns ago — conversation history is kept in memory
for the lifetime of the process (src/brain/agent.py's JarvisSession).
python -m src.pipeline
Say "Jarvis" (or whatever JARVIS_WAKE_WORD is set to) from across the
room, ask a question, and listen for a spoken reply. Watch stdout for
[idle] → [listening] → [thinking] → [speaking] transitions. Nothing
here makes a network call after the one-time model download — this is
the milestone the original spec calls out as "the point where this stops
being a plan and starts being Jarvis," now with zero recurring cost.
openWakeWord ships six pretrained models (hey jarvis, alexa, hey mycroft, hey rhasspy, current weather, timers); JARVIS_WAKE_WORD
just needs to be a substring that matches one of their names (default
jarvis matches the bundled hey_jarvis model). If you ever train a
fully custom model, point JARVIS_WAKE_WORD_MODEL_PATH at it instead.
src/brain/tools.py registers: memory, read_file/write_file (scoped
to filesystem_allowlist in config.yaml), run_shell, and web_search
(free — scrapes DuckDuckGo's HTML endpoint, no API key). Browser control
(@playwright/mcp) is discovered dynamically over a minimal MCP stdio
client (src/brain/mcp_client.py) if Node is installed; it degrades
silently to "unavailable" otherwise.
Try: "search the web for the weather in Boston" (runs without asking) vs. "write a file called test.txt in Documents saying hello" (should speak/print the exact action and wait for you to say "confirm" — see the confirmation gate below).
Known gap vs. the original spec: Gmail/Google Drive integration is not implemented in this local-LLM edition. The original design reused Claude Agent SDK's native MCP client to wire in your account's Gmail/Drive connectors; without that SDK, wiring the same connectors would mean building a full HTTP+SSE JSON-RPC MCP client with OAuth handling from scratch, which wasn't done here. If you want it, the cleanest path is adding a proper MCP HTTP client alongside
mcp_client.py's stdio one.
python scripts/chat_cli.py
# > remember that I take my coffee black, no sugar
# Ctrl-C, restart:
python scripts/chat_cli.py
# > how do I take my coffee
Facts persist in memories/ (see src/brain/memory.py, which implements
Anthropic's memory tool file operations — view/create/str_replace/
insert/delete/rename — against that directory, with path-traversal
protection; the command set is Anthropic's design, but it's just plain
Python now, no Claude dependency). memories/preferences.md is seeded on
first run if missing.
./install_daemon.sh
Installs and loads three LaunchAgents (see the architecture note below for why it's three, not one). Reboot (or log out/in). The menu bar icon and the HUD should both appear automatically with no manual terminal command, and the wake word should work immediately.
Three independent processes make up the full experience — run each in its
own terminal tab (source .venv/bin/activate in each first):
python -m src.main # app window + its WebSocket relay server
python -m src.pipeline # voice pipeline (wake word / STT / brain / TTS)
python -m src.system.menubar # menu bar icon (optional)
This opens a normal, resizable window (not a tiny corner overlay — see the design note below) with five tabs:
src.pipeline terminal.~/Library/Logs/jarvis_transcript.jsonl so it survives restarts.memories/ without
touching the filesystem directly.~/Library/Logs/jarvis_tool_calls.jsonl. This is the same "check before
trusting a claim" ground truth as the log file, just in the UI.config.yaml (wake word, voice, models, filesystem
allowlist, confirmation requirements) from a form instead of by hand.The frontend is a real React app (src/hud/web/frontend/), built with Vite
and committed pre-built to src/hud/web/dist/ — running Jarvis never
requires Node.js or npm. If you modify the frontend source, rebuild with:
cd src/hud/web/frontend
npm install
npm run build
Design trade-off: the original spec called for a tiny, always-on-top, click-through corner widget. That's fundamentally incompatible with a Settings panel or a scrollable history list — you can't click a tab if clicks pass through the window. Building out the fuller UI meant giving up the minimal-ambient-widget behavior in favor of a normal app window.
Why three processes, not one: rumps (menu bar) and pywebview (HUD window) each require macOS's Cocoa main-thread run loop, and AppKit only tolerates one owner of it per process — and opening the microphone for the first time hit that same constraint. Both failures were fatal, not cosmetic: running rumps in a background thread inside a combined
src.main, and separately running the voice pipeline in that same process, each crashed the entire process — HUD included — with an uncaught native exception the instant the mic or the menu bar initialized. So nowsrc.main(HUD),src.pipeline(microphone), andsrc.system.menubar(menu bar) are three independent processes that never share Cocoa or mic access. They talk to each other only over the HUD's WebSocket server (src/hud/server.py): the voice pipeline pushes state updates in as a client (src/hud/client.py), the React app and the menu bar both consume them as clients — the React app additionally uses the same connection as a request/response API (src/hud/api.py) for its History/Memory/Settings/Activity tabs. The trade-off: the old "Pause Jarvis" menu bar toggle is gone, since the process showing the menu bar no longer owns the pipeline it would be pausing. To pause, stopsrc.pipelineitself (Ctrl-C, orlaunchctl unloadits LaunchAgent).
source .venv/bin/activate
pip install -e ".[dev]"
pytest
The test suite runs anywhere (it was written and verified in a Linux CI
sandbox with no MLX/macOS available) by exercising the hardware-independent
logic directly — the memory tool's file operations, the confirmation-gate/
filesystem-scoping logic, the tool-calling loop's JSON parsing (with a fake
local LLM standing in for mlx-lm), config parsing, and the audio modules'
pure logic (sentence splitting, wav writing, wake-word score matching)
against mocked mlx_whisper/mlx_audio/openwakeword/sounddevice. It
does not and cannot verify actual local
model quality/tool-calling reliability, transcription accuracy, TTS audio
quality, wake-word detection from real audio, or launchd/rumps/pywebview
behavior — those are the phase acceptance tests above, and they require
your actual Mac.
The original design for this project used Claude (via the Claude Agent SDK) as the reasoning brain, with STT/TTS local and only the reasoning step hitting the API — a very cheap setup, but not literally $0. This edition was built after an explicit request for zero ongoing cost, which means the brain had to move on-device too. The trade-offs that come with that:
src/brain/agent.py), which is
inherently less robust. In testing, a 3B model didn't just fail to call
a tool — it fabricated an entire fake tool execution: it invented a
shell script, invented a fake confirmation exchange, and then claimed
"the daemon has been installed and is now running," all without ever
emitting a real tool call or touching the filesystem. model.local
defaults to 8B for this reason, with a hardened system prompt that
explicitly forbids claiming an action succeeded without a real tool
result. This significantly reduces but does not eliminate the risk —
always check ~/Library/Logs/jarvis.log (see Safety rules below) if
Jarvis claims to have done something consequential; only lines starting
TOOL_CALL reflect something that actually happened. If you see this
behavior at 8B, bump to local_heavy (14B) or larger.src/brain/web_search.py.If you'd rather have Claude's reasoning quality and are fine with the
(small, usage-based) API cost, the swap back is mostly confined to
src/brain/agent.py, src/brain/tools.py, and config.yaml's model:
section — the audio pipeline, HUD, memory tool, and safety gates are
unchanged either way.
require_confirmation_for
in config.yaml (delete_file, run_shell_command, Write) is
intercepted by ToolGuard.check() (src/brain/tools.py), called by the
tool loop before every execution, which speaks/prints the exact pending
action and denies it unless you explicitly say/type "confirm".read_file/write_file are denied outside the
directories listed in filesystem_allowlist (default: ~/Documents,
~/Desktop, the project folder) — never widen this to the whole home
directory or root. This check is baked directly into the tool
implementations, so the model has no other path to the filesystem.~/Library/Logs/jarvis.log via ToolGuard.log() as a TOOL_CALL line —
this is the ground truth for what Jarvis actually did. If Jarvis says it
did something and there's no matching TOOL_CALL line, it didn't happen
(see the architecture note above on local models fabricating actions).src/audio/recorder.py); wake-word
listening (src/audio/wake_word.py) inspects small rolling frames and
discards them immediately — nothing is ever continuously streamed
anywhere, local or cloud..env, it's gitignored
and never committed.openwakeword fails to import, or the model download hangs: confirm
arm64 Python — python3 -c "import platform; print(platform.machine())"
should print arm64, not x86_64. If it prints x86_64, you're running
under Rosetta; install an arm64-native Python (e.g.
brew install python@3.12) and re-run setup.sh. The model download
itself needs a network connection the first time only.audio.wake_word_sensitivity in config.yaml (e.g. to 0.4) if it's not
triggering, or raising it (e.g. to 0.8) if it's too trigger-happy.mlx-whisper/mlx-audio/mlx-lm should use the GPU via MLX
automatically on Apple Silicon. If not, delete .venv and re-run
setup.sh in a clean arm64-only environment.~/Library/Logs/jarvis.log for a matching TOOL_CALL line. If there
isn't one, it didn't happen — the model narrated it in plain text
instead of actually calling the tool. This is a known failure mode of
small local models (see the architecture note above); switch
model.local to model.local_heavy's value or something bigger if you
see it, and never take a consequential claim at face value without
checking the log.~/Library/Logs/jarvis.log, and confirm the installed plist's
ProgramArguments points at .venv/bin/python, not the system Python
(install_daemon.sh does this substitution automatically — re-run it if
you moved the project folder).pynput,
macOS requires granting Accessibility + Input Monitoring permission to
the terminal/app separately from microphone access.jarvis/
├── setup.sh install_daemon.sh
├── config.yaml .env.example / .env (gitignored)
├── memories/ persisted memory-tool files
├── src/
│ ├── main.py HUD process: window + WebSocket relay server (no mic)
│ ├── pipeline.py voice process: wake → stt → brain → tts (no Cocoa)
│ ├── audio/ wake_word.py, recorder.py, stt.py, tts.py
│ ├── brain/
│ │ ├── agent.py local tool-calling loop (JarvisSession, run_turn)
│ │ ├── local_llm.py mlx-lm model loading + chat()
│ │ ├── tools.py tool registry + ToolGuard (confirm/scope/log)
│ │ ├── tool_types.py Tool dataclass
│ │ ├── memory.py memory-tool file ops
│ │ ├── web_search.py free DuckDuckGo search
│ │ ├── mcp_client.py minimal stdio MCP client
│ │ └── browser_tools.py wraps @playwright/mcp as local tools
│ ├── hud/
│ │ ├── server.py WebSocket relay + request/response dispatch
│ │ ├── client.py HudStateReporter (pipeline → HUD state pusher)
│ │ ├── api.py backend handlers: memory/transcript/tool-calls/config
│ │ └── web/
│ │ ├── frontend/ React app source (Vite)
│ │ └── dist/ built output — this is what src.main actually loads
│ └── system/ config.py, menubar.py (own process), daemon/*.plist
├── scripts/chat_cli.py text-only harness (Phase 1)
└── tests/ pytest suite (hardware-independent)
$0 recurring, $0 up front. STT, TTS, and reasoning are all on-device via MLX; openWakeWord, the memory tool, launchd, rumps, and pywebview are all free and open-source with no account needed. The only network usage is the one-time model downloads (LLM + wake word) on first run.
Full-duplex/barge-in via Pipecat, Home Assistant integration, proactive calendar-aware speech, computer-use tool access, a cloned custom voice, and Gmail/Drive integration (see the architecture note above) are deliberately out of scope for this edition.
Python
76.9%
JavaScript
12.2%
CSS
6.1%
Swift
2.4%
Shell
2.3%