diff --git a/.env.example b/.env.example
index 51090741..94d6005b 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,11 @@ FISH_API_KEY=your-fish-audio-api-key-here
# Optional: Your name (JARVIS will address you by name)
# USER_NAME=Tony
+# Optional: Path to an Obsidian vault for JARVIS to search (READ-ONLY).
+# Unset, the vault features stay off. A vault is just a folder of Markdown
+# files, so no plugin or REST bridge is needed.
+# OBSIDIAN_VAULT=~/Documents/MyVault
+
# Optional: Specific Apple Calendar accounts to read (comma-separated emails)
# If not set, JARVIS reads ALL calendars from Apple Calendar
# CALENDAR_ACCOUNTS=you@gmail.com,work@company.com
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts
index 7e945ef7..e2472b31 100644
--- a/frontend/src/settings.ts
+++ b/frontend/src/settings.ts
@@ -14,6 +14,8 @@ interface StatusResponse {
calendar_accessible: boolean;
mail_accessible: boolean;
notes_accessible: boolean;
+ vault_accessible: boolean;
+ vault_notes: number;
memory_count: number;
task_count: number;
server_port: number;
@@ -121,6 +123,7 @@ function buildPanelHTML(): string {
Apple Calendar
Apple Mail
Apple Notes
+ Obsidian Vault
Server
@@ -209,6 +212,14 @@ async function loadStatus() {
setDotStatus("status-calendar", status.calendar_accessible ? "green" : "red");
setDotStatus("status-mail", status.mail_accessible ? "green" : "red");
setDotStatus("status-notes", status.notes_accessible ? "green" : "red");
+ // An unconfigured vault is off rather than red — it is opt-in, not broken.
+ setDotStatus("status-vault", status.vault_accessible ? "green" : "off");
+ const vaultDetail = document.getElementById("status-vault-detail");
+ if (vaultDetail) {
+ vaultDetail.textContent = status.vault_accessible
+ ? `${status.vault_notes} notes`
+ : "set OBSIDIAN_VAULT";
+ }
setDotStatus("status-server", "green");
const serverDetail = document.getElementById("status-server-detail");
diff --git a/obsidian_access.py b/obsidian_access.py
new file mode 100644
index 00000000..6f060405
--- /dev/null
+++ b/obsidian_access.py
@@ -0,0 +1,207 @@
+"""
+JARVIS Obsidian Access — READ-ONLY access to an Obsidian vault.
+
+A vault is a folder of Markdown files, so this reads the filesystem directly:
+no AppleScript, no TCC prompts, no plugin or REST bridge to keep running.
+
+IMPORTANT: This module is intentionally READ-ONLY.
+No create, edit, move, or delete functions exist by design — the vault is the
+user's own knowledge base, and a voice assistant mishearing a command should
+never be able to alter it.
+
+Set OBSIDIAN_VAULT to the vault's path to enable it. Unset, every function
+returns empty and JARVIS simply has no vault.
+"""
+
+import asyncio
+import logging
+import os
+import re
+from pathlib import Path
+
+log = logging.getLogger("jarvis.obsidian")
+
+# Folders that hold no prose worth searching: Obsidian's own config, plus the
+# usual attachment and template directories.
+_SKIP_DIRS = {".obsidian", ".git", ".trash", "_attachments", "node_modules"}
+
+# A vault is small (hundreds of notes, well under a megabyte of text), so
+# search walks it rather than maintaining an index that could go stale.
+_MAX_NOTE_BYTES = 400_000
+
+
+def _vault_root() -> Path | None:
+ """The configured vault, or None when the feature is switched off."""
+ raw = os.getenv("OBSIDIAN_VAULT", "").strip()
+ if not raw:
+ return None
+ root = Path(raw).expanduser()
+ if not root.is_dir():
+ log.warning(f"OBSIDIAN_VAULT is not a directory: {root}")
+ return None
+ return root.resolve()
+
+
+def _iter_notes(root: Path):
+ """Yield every Markdown file in the vault, skipping machinery folders."""
+ for dirpath, dirnames, filenames in os.walk(root):
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
+ for name in filenames:
+ if name.endswith(".md"):
+ yield Path(dirpath) / name
+
+
+def _read(path: Path) -> str:
+ try:
+ if path.stat().st_size > _MAX_NOTE_BYTES:
+ return ""
+ return path.read_text(encoding="utf-8", errors="replace")
+ except OSError as e:
+ log.warning(f"Could not read {path.name}: {e}")
+ return ""
+
+
+def _terms(query: str) -> list[str]:
+ return [t for t in re.split(r"\W+", query.lower()) if len(t) > 1]
+
+
+def _score(title: str, body: str, terms: list[str]) -> int:
+ """Rank a note against the query terms.
+
+ A term in the title counts for far more than one in the body: notes are
+ named deliberately, so "memoria" in a filename is a stronger signal than
+ the same word buried in a paragraph.
+ """
+ title_l, body_l = title.lower(), body.lower()
+ score = 0
+ for t in terms:
+ score += 10 * title_l.count(t)
+ score += min(body_l.count(t), 5)
+ return score
+
+
+def _snippet(body: str, terms: list[str], width: int = 160) -> str:
+ """A readable line around the first hit — this gets spoken aloud."""
+ for line in body.split("\n"):
+ stripped = line.strip().lstrip("#").strip()
+ if not stripped:
+ continue
+ low = stripped.lower()
+ if any(t in low for t in terms):
+ return stripped[:width]
+ for line in body.split("\n"):
+ stripped = line.strip().lstrip("#").strip()
+ if stripped:
+ return stripped[:width]
+ return ""
+
+
+def _search_sync(query: str, limit: int) -> list[dict]:
+ root = _vault_root()
+ if not root:
+ return []
+ terms = _terms(query)
+ if not terms:
+ return []
+
+ hits = []
+ for path in _iter_notes(root):
+ body = _read(path)
+ if not body:
+ continue
+ title = path.stem
+ score = _score(title, body, terms)
+ if score:
+ hits.append({
+ "title": title,
+ "path": str(path.relative_to(root)),
+ "score": score,
+ "snippet": _snippet(body, terms),
+ })
+ hits.sort(key=lambda h: (-h["score"], h["title"].lower()))
+ return hits[:limit]
+
+
+async def search_vault(query: str, limit: int = 5) -> list[dict]:
+ """Find vault notes matching a query, best first."""
+ return await asyncio.to_thread(_search_sync, query, limit)
+
+
+def _read_note_sync(query: str, max_chars: int) -> dict | None:
+ root = _vault_root()
+ if not root:
+ return None
+ hits = _search_sync(query, limit=1)
+ if not hits:
+ return None
+
+ # Re-derive the path from the vault root and confirm it stayed inside.
+ # The query reaches here from a speech transcript by way of the model, so
+ # it is untrusted input, and a note name is not a licence to read the disk.
+ target = (root / hits[0]["path"]).resolve()
+ if not target.is_relative_to(root):
+ log.warning(f"Refusing to read outside the vault: {target}")
+ return None
+
+ body = _read(target)
+ return {
+ "title": hits[0]["title"],
+ "path": hits[0]["path"],
+ "body": body[:max_chars],
+ "truncated": len(body) > max_chars,
+ }
+
+
+async def read_vault_note(query: str, max_chars: int = 3000) -> dict | None:
+ """Read the vault note that best matches a title or topic."""
+ return await asyncio.to_thread(_read_note_sync, query, max_chars)
+
+
+def _recent_sync(count: int) -> list[dict]:
+ root = _vault_root()
+ if not root:
+ return []
+ notes = []
+ for path in _iter_notes(root):
+ try:
+ notes.append((path.stat().st_mtime, path))
+ except OSError:
+ continue
+ notes.sort(reverse=True)
+ return [
+ {"title": p.stem, "path": str(p.relative_to(root))}
+ for _, p in notes[:count]
+ ]
+
+
+async def recent_vault_notes(count: int = 5) -> list[dict]:
+ """List the most recently edited notes."""
+ return await asyncio.to_thread(_recent_sync, count)
+
+
+def _stats_sync() -> dict:
+ root = _vault_root()
+ if not root:
+ return {"configured": False, "notes": 0, "path": ""}
+ return {
+ "configured": True,
+ "notes": sum(1 for _ in _iter_notes(root)),
+ "path": str(root),
+ }
+
+
+async def vault_stats() -> dict:
+ """Vault reachability, for the settings panel."""
+ return await asyncio.to_thread(_stats_sync)
+
+
+def format_search_for_voice(hits: list[dict], query: str) -> str:
+ """Phrase search results as JARVIS would say them."""
+ if not hits:
+ return f"Nothing in your vault about {query}, sir."
+ top = hits[0]
+ line = f"Your note '{top['title']}' says: {top['snippet']}"
+ if len(hits) > 1:
+ others = ", ".join(h["title"] for h in hits[1:3])
+ line += f" There's also {others}."
+ return line
diff --git a/server.py b/server.py
index f08e7370..9774fb2b 100644
--- a/server.py
+++ b/server.py
@@ -50,6 +50,7 @@
format_tasks_for_voice, extract_memories, get_important_memories,
)
from notes_access import get_recent_notes, read_note, search_notes_apple, create_apple_note
+from obsidian_access import search_vault, read_vault_note, vault_stats, format_search_for_voice
from dispatch_registry import DispatchRegistry
from planner import TaskPlanner, detect_planning_mode, BYPASS_PHRASES
@@ -183,12 +184,15 @@
ACTION SYSTEM:
When you decide the user needs something DONE (not just discussed), include an action tag in your response:
-- [ACTION:SCREEN] — capture and describe what's visible on the user's screen. Use when user says "look at my screen", "what's running", "what do you see", etc. Do NOT use PROMPT_PROJECT for screen requests.
+- [ACTION:SCREEN] — capture and describe the user's display. ONLY for questions about the screen itself: what is visible, what is open, what app is running. It photographs the monitor, so it can tell you nothing about a calendar, an inbox, or anything else you cannot see on screen. Do NOT use PROMPT_PROJECT for screen requests.
+- [ACTION:CALENDAR] — re-read Apple Calendar. Use whenever the user asks about their schedule, meetings, appointments, or what the day holds, and the SCHEDULE section below is empty, stale, or does not answer them.
+- [ACTION:MAIL] — re-read Apple Mail. Use whenever the user asks about email, unread messages, or their inbox, and the EMAIL section below does not answer them.
+- [ACTION:VAULT] search terms — search the user's Obsidian vault, their written notes and research. Use when they ask what they wrote, noted, or saved about a topic, or to look something up in their notes or their vault. Pass the topic to search for, not a whole sentence. READ-ONLY: you can find and quote notes, never change them.
- [ACTION:BUILD] description — when user wants a project built. Claude Code does the work.
- [ACTION:BROWSE] url or search query — when user wants to see a webpage or search result in Chrome
- [ACTION:RESEARCH] detailed research brief — when user wants real research with real data. Claude Code will browse the web, find real listings/data, and create a report document. Give it a detailed brief of what to find.
- [ACTION:OPEN_TERMINAL] — when user just wants a fresh Claude Code terminal with no specific project
-CRITICAL: When the user asks about their SCREEN, what's RUNNING, or what they're LOOKING AT — ALWAYS use [ACTION:SCREEN] or let the fast action system handle it. NEVER use [ACTION:PROMPT_PROJECT] for screen requests. PROMPT_PROJECT is ONLY for working on code projects.
+CRITICAL: When the user asks about their SCREEN, what's RUNNING, or what they're LOOKING AT — ALWAYS use [ACTION:SCREEN] or let the fast action system handle it. NEVER use [ACTION:PROMPT_PROJECT] for screen requests. PROMPT_PROJECT is ONLY for working on code projects. Equally, never reach for SCREEN to answer a question about the calendar or the inbox — CALENDAR and MAIL are the actions that read those.
- [ACTION:PROMPT_PROJECT] project_name ||| prompt — THIS IS YOUR MOST POWERFUL ACTION. Use it whenever the user wants to work on, jump into, resume, check on, or interact with ANY existing project. You connect directly to Claude Code in that project and can read its response. Craft a clear prompt based on what the user wants. Examples:
"jump into client engine" → [ACTION:PROMPT_PROJECT] The Client Engine ||| What is the current state of this project? Summarize what was being worked on most recently.
@@ -814,7 +818,7 @@ def extract_action(response: str) -> tuple[str, dict | None]:
Returns (clean_text_for_tts, action_dict_or_none).
"""
match = _action_re.search(
- r'\[ACTION:(BUILD|BROWSE|RESEARCH|OPEN_TERMINAL|PROMPT_PROJECT|ADD_TASK|ADD_NOTE|COMPLETE_TASK|REMEMBER|CREATE_NOTE|READ_NOTE|SCREEN)\]\s*(.*?)$',
+ r'\[ACTION:(BUILD|BROWSE|RESEARCH|OPEN_TERMINAL|PROMPT_PROJECT|ADD_TASK|ADD_NOTE|COMPLETE_TASK|REMEMBER|CREATE_NOTE|READ_NOTE|SCREEN|CALENDAR|MAIL|VAULT)\]\s*(.*?)$',
response, _action_re.DOTALL,
)
if match:
@@ -1677,6 +1681,9 @@ async def _lookup_and_report(lookup_type: str, lookup_fn, ws, history: list[dict
"status": "working",
"started": time.time(),
}
+ # Remember which utterance asked for this, so the collision guard below can
+ # tell "the user has since said something new" from "the user asked me this".
+ asked_at = voice_state.get("last_user_time", 0) if voice_state else 0
try:
# Run the async lookup directly — these functions already use
@@ -1688,9 +1695,17 @@ async def _lookup_and_report(lookup_type: str, lookup_fn, ws, history: list[dict
_active_lookups[lookup_id]["status"] = "done"
- # Speak the result — skip audio if user spoke recently to avoid collision
- if voice_state and time.time() - voice_state["last_user_time"] < 3:
- log.info(f"Skipping lookup audio for {lookup_type} — user spoke recently")
+ # Speak the result, unless the user has started a new turn while we
+ # worked — answering that is more use than talking over them.
+ #
+ # This compares against the request's own timestamp rather than the
+ # clock. The previous check suppressed anything finishing within 3s of
+ # the last utterance, but the utterance it measured was the one that
+ # ordered the lookup, so every fast lookup silenced itself. Calendar
+ # and mail hid it by being slow; a vault search takes 30ms and was
+ # never once heard.
+ if voice_state and voice_state.get("last_user_time", 0) > asked_at:
+ log.info(f"Skipping lookup audio for {lookup_type} — user has spoken since")
# Result is still stored in history below
else:
tts = strip_markdown_for_tts(result_text)
@@ -1731,6 +1746,12 @@ async def _lookup_and_report(lookup_type: str, lookup_fn, ws, history: list[dict
_active_lookups.pop(lookup_id, None)
+async def _do_vault_lookup(query: str) -> str:
+ """Search the Obsidian vault — walks the notes off the main path."""
+ hits = await search_vault(query, limit=3)
+ return format_search_for_voice(hits, query)
+
+
async def _do_calendar_lookup() -> str:
"""Slow calendar fetch — runs in thread."""
await refresh_calendar_cache()
@@ -2249,6 +2270,12 @@ async def _send_greeting():
response_text = "On it, sir."
elif action_type == "research":
response_text = "Looking into that now, sir."
+ elif action_type == "calendar":
+ response_text = "Checking your calendar now, sir."
+ elif action_type == "mail":
+ response_text = "Checking your inbox now, sir."
+ elif action_type == "vault":
+ response_text = "Searching your notes, sir."
else:
response_text = "Right away, sir."
@@ -2349,6 +2376,16 @@ async def _send_greeting():
asyncio.create_task(create_apple_note("JARVIS Note", target))
elif embedded_action["action"] == "screen":
asyncio.create_task(_lookup_and_report("screen", _do_screen_lookup, ws, history=history, voice_state=voice_state))
+ elif embedded_action["action"] == "calendar":
+ asyncio.create_task(_lookup_and_report("calendar", _do_calendar_lookup, ws, history=history, voice_state=voice_state))
+ elif embedded_action["action"] == "mail":
+ asyncio.create_task(_lookup_and_report("mail", _do_mail_lookup, ws, history=history, voice_state=voice_state))
+ elif embedded_action["action"] == "vault":
+ _query = embedded_action["target"].strip()
+ asyncio.create_task(_lookup_and_report(
+ "vault", lambda: _do_vault_lookup(_query), ws,
+ history=history, voice_state=voice_state,
+ ))
elif embedded_action["action"] == "read_note":
# Read note in background and report back
async def _read_and_report(search_term, _ws):
@@ -2542,6 +2579,9 @@ async def api_settings_status():
except Exception: pass
try: await get_recent_notes(count=1); notes_ok = True
except Exception: pass
+ vault = {"configured": False, "notes": 0, "path": ""}
+ try: vault = await vault_stats()
+ except Exception: pass
memory_count = task_count = 0
try: memory_count = len(get_important_memories(limit=9999))
except Exception: pass
@@ -2552,6 +2592,8 @@ async def api_settings_status():
"calendar_accessible": calendar_ok,
"mail_accessible": mail_ok,
"notes_accessible": notes_ok,
+ "vault_accessible": vault["configured"],
+ "vault_notes": vault["notes"],
"memory_count": memory_count,
"task_count": task_count,
"server_port": 8340,