Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +123,7 @@ function buildPanelHTML(): string {
<div class="status-row"><span class="status-dot" id="status-calendar"></span><span>Apple Calendar</span></div>
<div class="status-row"><span class="status-dot" id="status-mail"></span><span>Apple Mail</span></div>
<div class="status-row"><span class="status-dot" id="status-notes"></span><span>Apple Notes</span></div>
<div class="status-row"><span class="status-dot" id="status-vault"></span><span>Obsidian Vault</span><span class="status-detail" id="status-vault-detail"></span></div>
<div class="status-row"><span class="status-dot" id="status-server"></span><span>Server</span><span class="status-detail" id="status-server-detail"></span></div>
</div>
</section>
Expand Down Expand Up @@ -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");
Expand Down
207 changes: 207 additions & 0 deletions obsidian_access.py
Original file line number Diff line number Diff line change
@@ -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
54 changes: 48 additions & 6 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down