diff --git a/.gitignore b/.gitignore
index 353e610..41cf296 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,10 @@ cortex.toml
.DS_Store
.idea/
.vscode/
+
+# Frontend build artefacts. ``frontend/dist`` is regenerated by
+# ``npm run build``; ``node_modules`` by ``npm install``. commit only the
+# source under frontend/src.
+frontend/dist/
+frontend/**/node_modules/
+frontend/**/*.tsbuildinfo
diff --git a/README.md b/README.md
index 3263e05..c29083a 100644
--- a/README.md
+++ b/README.md
@@ -137,10 +137,37 @@ cortex status # index size, thermal state
cortex providers # who may see private content, and why
cortex graph --note "Retrieval.md" # inspect wikilinks
cortex watch # run continuously
+cortex serve # local web app + voice + chat UI
```
Everything works without a model configured — pass `--offline` to use a deterministic hashing embedder. Useful for trying the pipeline before pulling gigabytes.
+### Talk to it in the browser — `cortex serve`
+
+A Vite-built React app runs alongside the Python backend on one port. Streaming
+chat, citations, voice input (browser Web Speech API), TTS read-aloud, a vault
+search sidebar and a memory-folder browser — all in one window, all local.
+
+```bash
+uv pip install -e ".[server]" # adds fastapi + uvicorn
+cd frontend && npm install && npm run build # one-time, builds the SPA
+cd .. && cortex serve # opens http://127.0.0.1:7331
+```
+
+The mobile layout collapses the left rail into a slide-in drawer and pins a
+tab bar to the bottom of the viewport; on desktop the sidebar stays put.
+
+**Voice.** Hold the mic button (or type) and the browser's Web Speech API
+transcribes in real time. A "Read aloud" button on each assistant message
+plays it back via the browser's `SpeechSynthesis` with the voice you pick in
+Settings. Both rely on browser-native engines; for fully local STT/TTS, route
+audio to a local Whisper / Piper daemon on the server and we'll hook them up.
+
+**Privacy, in the UI.** Every assistant message carries a provider chip
+(`groq · no-train`, `nvidia · no-train`, `openrouter · zdr`, etc.) so you can
+see at a glance where each answer came from. The "Local only" toggle on the
+composer forces the request to stay on-device.
+
### Connect it to Antigravity
Cortex speaks MCP, so Antigravity can query your notes directly. Add to `~/.gemini/antigravity-cli/mcp_config.json`:
@@ -313,7 +340,7 @@ uv pip install -e ".[dev,all]"
make check # ruff + mypy --strict + pytest
```
-465 tests, mypy strict, zero lint warnings. The privacy gate is tested as a security boundary — including the subtle leak where a preferred provider fails and a naive chain falls through to a training one.
+The privacy gate is tested as a security boundary — including the subtle leak where a preferred provider fails and a naive chain falls through to a training one. The HTTP surface (`src/cortex/server/`) is covered by `tests/test_server.py` using httpx against the ASGI app directly, so the chat SSE event sequence is asserted in CI.
Architecture decisions and their tradeoffs are recorded in [`docs/adr/`](docs/adr/).
diff --git a/cortex.example.toml b/cortex.example.toml
index aa96f14..c1f6f63 100644
--- a/cortex.example.toml
+++ b/cortex.example.toml
@@ -65,18 +65,58 @@ critical_speed_limit = 60
min_battery_for_backfill = 30
sample_interval = 10.0
+[serve]
+# Settings for `cortex serve` -- the local web app (chat + voice + vault view).
+# Defaults assume you only ever reach it from the same machine; bind 0.0.0.0
+# only if you actually want other devices on the LAN talking to it, and then
+# only on a trusted network. There is no auth -- this is a single-user tool.
+host = "127.0.0.1"
+port = 7331
+open_browser = true
+# cors_origins = ["http://localhost:7331", "http://localhost:5173"] # add origins here
+
# Providers patch the shipped defaults -- name an existing one to override
# single fields, or give base_url + model to add a new endpoint.
#
+# Shipped chain (priority is "lower sorts earlier"):
+# * ollama -- local qwen3:4b. Hot path; ~no remote latency when the
+# model is already resident. On Apple Silicon without the
+# Metal JIT hang fix (``OLLAMA_NUM_GPU=0``), this is
+# CPU-only -- still very fast for short completions.
+# * groq -- llama-3.3-70b-versatile on the LPU. ~350 tok/s. Best
+# quality-per-second at this tier; default for synthesis
+# that doesn't fit the local context window.
+# * nvidia -- Llama-3.3-70B-Instruct on NVIDIA NIM. Overflow when
+# Groq is rate-limited; same capability, slower.
+# * openrouter -- llama-3.1-8b-instruct with the ZDR contract enforced
+# on the wire. Pinned (not ``openrouter/free``) so the
+# model identifier survives the weekly free roster
+# rotation. ``meta-llama/*`` is one of the few model
+# families consistently served by ZDR-endpoint providers
+# on the lower credit tiers.
+#
+# On a fresh install, set the OPENROUTER ZDR contract before sending
+# private notes through it:
+#
# [[providers]]
# name = "openrouter"
# zdr_enabled = true # ONLY after enabling zero-data-retention on your account
#
+# To upgrade local hot-path capacity for a beefier machine, swap
+# the ollama model here without touching the shipped defaults:
+#
+# [[providers]]
+# name = "ollama"
+# model = "qwen3:8b"
+# priority = 0
+#
+# Adding a new endpoint (e.g. Cerebras) follows the same shape -- name
+# anything not in the shipped chain and we append it:
+#
# [[providers]]
-# name = "cerebras"
# base_url = "https://api.cerebras.ai/v1"
-# model = "llama3.1-8b"
+# model = "llama-3.3-70b"
# policy = "no_train"
# api_key_env = "CEREBRAS_API_KEY"
-# tpd = 1000000
+# rpm = 30
# priority = 15
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..57a7355
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,18 @@
+
+
+
+ Memory notes are written into the Memory/ folder of your
+ vault by the “remember” button on each assistant message. Open{' '}
+ Memory in Obsidian or the sidebar’s
+ Memory tab to read or edit them.
+
+ );
+}
+
+function WelcomePanel({
+ onSend,
+ disabled,
+}: {
+ onSend: (q: string) => void;
+ disabled: boolean;
+}) {
+ const suggestions = useMemo(
+ () => [
+ 'What did I learn last week?',
+ 'Summarise today\u2019s daily note',
+ 'Where did I keep the chunking rationale?',
+ 'What decisions do I regret, by project?',
+ ],
+ [],
+ );
+ return (
+
+
+
+ Cortex online
+
+
+ Talk to your second brain.
+
+
+ Ask in plain language; Cortex reads your Obsidian vault, finds the sources, writes an
+ answer with citations you can one-click into Obsidian. Nothing leaves the machine unless
+ you turn that off in Settings.
+
+
+
+ );
+}
+
+function SurfaceTabs({
+ surface,
+ onSelect,
+}: {
+ surface: Surface;
+ onSelect: (s: Surface) => void;
+}) {
+ return (
+
+ );
+}
+
+function SidebarFooter({ status }: { status: StatusSnapshot | null }) {
+ const notes = status?.notes_indexed ?? 0;
+ const chunks = status?.chunks ?? 0;
+ const memories = status?.memory_count ?? 0;
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Sidebar/GraphView.tsx b/frontend/src/components/Sidebar/GraphView.tsx
new file mode 100644
index 0000000..63aebef
--- /dev/null
+++ b/frontend/src/components/Sidebar/GraphView.tsx
@@ -0,0 +1,472 @@
+/**
+ * Live wikilink graph for the sidebar.
+ *
+ * Why a radial layout, not force-directed? Two reasons specific to a personal
+ * vault:
+ *
+ * 1. Determinism. A force simulation settles slightly differently each run,
+ * which makes a sidebar widget feel "jumpy". Radial-from-hub is identical
+ * every render -- the only thing that changes is which note is the hub.
+ * 2. Signal density. The most useful observation on a personal vault is
+ * "what does this hub connect to"; a ringed layout makes that
+ * first-glance obvious in a way an organic mesh does not.
+ *
+ * The component fetches the ``/api/graph`` snapshot on mount, then re-polls
+ * every 60 s. Network blips are silent (the client returns ``null``) so a
+ * missing frame does not look like an error -- the graph view is decorative,
+ * not authoritative.
+ *
+ * ``reindexEpoch`` is a number the parent bumps whenever the user reindexes;
+ * it is part of the polling effect's dep list so a manual reindex refreshes
+ * the graph immediately rather than waiting for the next tick.
+ */
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { RefreshCcw, Network } from 'lucide-react';
+
+import { getGraphSnapshot } from '../../api/client';
+import type { GraphEdge, GraphNode, GraphSnapshot } from '../../types';
+
+const POLL_INTERVAL_MS = 60_000;
+const DEFAULT_LIMIT = 60;
+
+// ViewBox is a fixed-shape canvas we scale responsively. The dimensions are
+// chosen so a ring of 60 nodes at radius 200 leaves comfortable padding while
+// staying below one full sidebar panel height -- the sidebar already scrolls.
+const SVG_WIDTH = 600;
+const SVG_HEIGHT = 360;
+const CENTER_X = SVG_WIDTH / 2;
+const CENTER_Y = SVG_HEIGHT / 2;
+// Radii sized so 4 concentric rings (centre, neighbours, 2-hop, 3-hop) all
+// fit inside the SVG with margins. Ring spacing widens slightly as the
+// radius grows so labels at the outer edge stay readable.
+const RING_RADII = [80, 145, 200, 240];
+// Visual bounds. Nodes grow with degree but we clamp to keep tiny hubs and
+// sprawling megahubs both legible.
+const MIN_RADIUS = 4;
+const MAX_RADIUS = 14;
+
+export interface GraphViewProps {
+ /** The vault's display name -- needed to build ``obsidian://`` URIs. */
+ vaultName: string;
+ /**
+ * Bumps whenever the user reindexes. Used as part of the polling effect's
+ * dep list so the panel refetches without the Sidebar's other panels also
+ * tearing down on every edit.
+ */
+ reindexEpoch?: number;
+}
+
+export function GraphView({ vaultName, reindexEpoch = 0 }: GraphViewProps) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+ const [manualTick, setManualTick] = useState(0);
+
+ const fetchGraph = useCallback(async () => {
+ const snap = await getGraphSnapshot({ limit: DEFAULT_LIMIT });
+ if (snap === null) {
+ setError(true);
+ setLoading(false);
+ return;
+ }
+ setSnapshot(snap);
+ setError(false);
+ setLoading(false);
+ }, []);
+
+ // Initial fetch + 60 s polling. The polling cadence is a deliberate
+ // trade-off: the graph mutates only on real edits, which are rarer than
+ // chats, but the user should *feel* that it is alive. 60 s is cheap (~30
+ // bytes of network unless a reindex lands) and short enough that a fresh
+ // note shows up before the next time the user glances at the sidebar.
+ useEffect(() => {
+ void fetchGraph();
+ const handle = window.setInterval(() => {
+ void fetchGraph();
+ }, POLL_INTERVAL_MS);
+ return () => {
+ window.clearInterval(handle);
+ };
+ }, [fetchGraph, reindexEpoch, manualTick]);
+
+ const manualRefresh = useCallback(() => {
+ setLoading(true);
+ setManualTick((t) => t + 1);
+ }, []);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Sidebar/SettingsPane.tsx b/frontend/src/components/Sidebar/SettingsPane.tsx
new file mode 100644
index 0000000..fd20431
--- /dev/null
+++ b/frontend/src/components/Sidebar/SettingsPane.tsx
@@ -0,0 +1,201 @@
+import { useEffect, useState } from 'react';
+import { Mic, Volume2, ShieldOff, RefreshCw, Cloud, Smartphone } from 'lucide-react';
+
+import { getStatus, reindex } from '../../api/client';
+import type { StatusSnapshot } from '../../types';
+
+import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis';
+
+interface SettingsPaneProps {
+ /**
+ * Called after a successful reindex. Used to bump the App-level
+ * ``reindexEpoch`` so other panels (the live graph view in the sidebar)
+ * refetch without having to wait for their next polling tick.
+ */
+ onAfterReindex?: () => void;
+}
+
+export function SettingsPane({ onAfterReindex }: SettingsPaneProps = {}) {
+ const [status, setStatus] = useState(null);
+ const [reindexMsg, setReindexMsg] = useState(null);
+ const [reindexing, setReindexing] = useState(false);
+ const tts = useSpeechSynthesis();
+
+ // We ship a chat controller-less settings view so this can render without
+ // the chat pane being mounted; in practice Settings and Chat coexist via the
+ // top-level App layout switch.
+ // ``useChatController`` would read here except that would force the user to
+ // instantiate a controller when they click "Settings" without first opening
+ // the chat. Instead, the local-only toggle stays in the ChatInput until the
+ // user wants a global override.
+
+ useEffect(() => {
+ let cancelled = false;
+ void getStatus().then((s) => {
+ if (!cancelled) setStatus(s);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return (
+
+
Settings
+
+ Local-first defaults; turn clouds on only where you need them.
+
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useChatController.ts b/frontend/src/hooks/useChatController.ts
new file mode 100644
index 0000000..8a6a42d
--- /dev/null
+++ b/frontend/src/hooks/useChatController.ts
@@ -0,0 +1,278 @@
+/**
+ * Chat controller hook -- the brain of the chat pane.
+ * Owns the message list, the in-flight SSE stream, and the local-only toggle
+ * for each turn. Components mount one ``useChatController`` and read the
+ * returned state.
+ *
+ * The hook intentionally does NOT import React state machinery like
+ * useReducer -- the messages are append-only during a running turn, so a
+ * rolling bag of small setState calls is clearer than a reducer.
+ */
+
+import { useCallback, useRef, useState } from 'react';
+
+import { openChatStream, rememberExchange } from '../api/client';
+import type { ChatEvent, ChatMessage, Citation } from '../types';
+
+export interface ChatTurn {
+ id: string;
+ question: string;
+ answer: string;
+ citations: Citation[];
+ provider?: string;
+ policy?: string;
+ escalated?: boolean;
+ retrievalMs?: number;
+ retrievalMatched?: number;
+ reranked?: boolean;
+ dateWindow?: string | null;
+ error?: { type?: string; message: string };
+ memorySaved?: string;
+ done: boolean;
+}
+
+export interface ChatState {
+ messages: ChatTurn[];
+ streaming: boolean;
+ aborted: boolean;
+ localOnly: boolean;
+ voiceAutoSend: boolean;
+ canSpeak: boolean;
+}
+
+export interface ChatController {
+ state: ChatState;
+ lastCitations: Citation[];
+ send: (prompt: string, opts?: { remember?: boolean }) => Promise;
+ stop: () => void;
+ clear: () => void;
+ setLocalOnly: (v: boolean) => void;
+ setVoiceAutoSend: (v: boolean) => void;
+ remember: (turn: ChatTurn) => Promise;
+}
+
+const STARTER: ChatState = {
+ messages: [],
+ streaming: false,
+ aborted: false,
+ localOnly: false,
+ voiceAutoSend: false,
+ canSpeak: typeof window !== 'undefined' && Boolean(window.speechSynthesis),
+};
+
+export function useChatController(): ChatController {
+ const [state, setState] = useState(STARTER);
+ const controllerRef = useRef(null);
+
+ const patch = useCallback((p: Partial) => {
+ setState((prev) => ({ ...prev, ...p }));
+ }, []);
+
+ const stop = useCallback(() => {
+ controllerRef.current?.abort();
+ controllerRef.current = null;
+ patch({ streaming: false, aborted: true });
+ // Anchor the last turn as ``done`` so its stream text stays where it is
+ // rather than appearing to disappear mid-assist.
+ setState((s) => ({
+ ...s,
+ messages: s.messages.map((m, i) =>
+ i === s.messages.length - 1 ? { ...m, done: true } : m,
+ ),
+ }));
+ }, [patch]);
+
+ const send = useCallback(
+ async (prompt: string, opts?: { remember?: boolean }) => {
+ const text = prompt.trim();
+ if (!text) return;
+ const id = `t-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+ const placeholder: ChatTurn = {
+ id,
+ question: text,
+ answer: '',
+ citations: [],
+ done: false,
+ };
+
+ // Build the wire-format history (last 8 turns is plenty; older context
+ // belongs on disk in a memory note if it's still relevant).
+ const history: ChatMessage[] = state.messages.flatMap((turn) => [
+ { role: 'user' as const, content: turn.question },
+ { role: 'assistant' as const, content: turn.answer },
+ ]);
+ history.push({ role: 'user', content: text });
+
+ setState((s) => ({
+ ...s,
+ messages: [...s.messages, placeholder],
+ streaming: true,
+ aborted: false,
+ }));
+
+ const ac = new AbortController();
+ controllerRef.current = ac;
+
+ const apply = (mut: (t: ChatTurn) => ChatTurn) =>
+ setState((s) => ({
+ ...s,
+ messages: s.messages.map((m) => (m.id === id ? mut(m) : m)),
+ }));
+
+ try {
+ for await (const event of openChatStream(
+ {
+ messages: history,
+ local_only: state.localOnly,
+ remember: opts?.remember ?? false,
+ top_k: 8,
+ },
+ ac.signal,
+ )) {
+ applyEvent(event, apply);
+ if (event.type === 'done' || event.type === 'error') break;
+ }
+ } catch (err) {
+ if ((err as Error)?.name === 'AbortError') {
+ // ``stop()`` already set state; this branch is the catch for an
+ // observer that does not call stop().
+ } else {
+ apply((t) => ({
+ ...t,
+ error: {
+ type: 'network',
+ message: (err as Error)?.message ?? 'Stream failed',
+ },
+ done: true,
+ }));
+ }
+ } finally {
+ controllerRef.current = null;
+ setState((s) => ({
+ ...s,
+ streaming: false,
+ messages: s.messages.map((m) => (m.id === id ? { ...m, done: true } : m)),
+ }));
+ }
+ },
+ [state.localOnly, state.messages],
+ );
+
+ const clear = useCallback(() => {
+ controllerRef.current?.abort();
+ controllerRef.current = null;
+ setState((s) => ({ ...s, messages: [], streaming: false, aborted: false }));
+ }, []);
+
+ const setLocalOnly = useCallback((v: boolean) => {
+ setState((s) => ({ ...s, localOnly: v }));
+ }, []);
+
+ const setVoiceAutoSend = useCallback((v: boolean) => {
+ setState((s) => ({ ...s, voiceAutoSend: v }));
+ }, []);
+
+ const remember = useCallback(async (turn: ChatTurn) => {
+ try {
+ const saved = await rememberExchange({
+ question: turn.question,
+ answer: turn.answer,
+ sources: turn.citations.map((c) => c.note_id),
+ provider: turn.provider,
+ });
+ setState((s) => ({
+ ...s,
+ messages: s.messages.map((m) =>
+ m.id === turn.id ? { ...m, memorySaved: saved.saved } : m,
+ ),
+ }));
+ } catch (err) {
+ setState((s) => ({
+ ...s,
+ messages: s.messages.map((m) =>
+ m.id === turn.id
+ ? { ...m, error: { type: 'memory', message: (err as Error)?.message ?? 'remember failed' } }
+ : m,
+ ),
+ }));
+ }
+ }, []);
+
+ const lastCitations = state.messages[state.messages.length - 1]?.citations ?? [];
+
+ return { state, lastCitations, send, stop, clear, setLocalOnly, setVoiceAutoSend, remember };
+}
+
+function applyEvent(
+ event: ChatEvent,
+ apply: (mut: (t: ChatTurn) => ChatTurn) => void,
+): void {
+ switch (event.type) {
+ case 'provider':
+ if (event.data.name) {
+ apply((t) => ({
+ ...t,
+ provider: event.data.name,
+ policy: event.data.policy ?? t.policy,
+ escalated: event.data.escalated ?? t.escalated,
+ }));
+ }
+ break;
+ case 'retrieval':
+ apply((t) => ({
+ ...t,
+ retrievalMs: event.data.elapsed_ms,
+ retrievalMatched: event.data.matched,
+ reranked: event.data.reranked,
+ dateWindow: event.data.date_filter,
+ }));
+ break;
+ case 'citation':
+ apply((t) =>
+ t.citations.find((c) => c.note_id === event.data.note_id)
+ ? t
+ : {
+ ...t,
+ citations: [
+ ...t.citations,
+ {
+ index: event.data.index,
+ note_id: event.data.note_id,
+ obsidian_uri: event.data.obsidian_uri,
+ title: event.data.title,
+ snippet: event.data.snippet,
+ score: event.data.score,
+ tags: event.data.tags ?? [],
+ },
+ ].sort((a, b) => a.index - b.index),
+ },
+ );
+ break;
+ case 'text':
+ apply((t) => ({ ...t, answer: t.answer + event.data.delta }));
+ break;
+ case 'memory':
+ apply((t) => ({ ...t, memorySaved: event.data.saved }));
+ break;
+ case 'done':
+ apply((t) => ({
+ ...t,
+ answer: event.data.answer || t.answer,
+ provider: event.data.provider || t.provider,
+ policy: event.data.policy ?? t.policy,
+ escalated: event.data.escalated ?? t.escalated,
+ done: true,
+ }));
+ break;
+ case 'error':
+ apply((t) => ({
+ ...t,
+ error: {
+ type: event.data.type,
+ message: event.data.message,
+ },
+ done: true,
+ }));
+ break;
+ }
+}
diff --git a/frontend/src/hooks/useChatScroll.ts b/frontend/src/hooks/useChatScroll.ts
new file mode 100644
index 0000000..5264837
--- /dev/null
+++ b/frontend/src/hooks/useChatScroll.ts
@@ -0,0 +1,49 @@
+import { useCallback, useEffect, useState } from 'react';
+import type { RefObject } from 'react';
+
+/**
+ * Pin-to-bottom behaviour for a chat thread. Tracks whether the user has
+ * scrolled *away* from the bottom; if they have, we leave their position
+ * alone. When they are near the bottom (within ~120 px) we automatically
+ * follow new tokens.
+ */
+export function useChatScroll(
+ ref: RefObject,
+ messageCount: number,
+) {
+ const [atBottom, setAtBottom] = useState(true);
+
+ const onScroll = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
+ setAtBottom(distance < 120);
+ }, [ref]);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.addEventListener('scroll', onScroll, { passive: true });
+ return () => el.removeEventListener('scroll', onScroll);
+ }, [onScroll, ref]);
+
+ const scrollToBottom = useCallback(
+ (behavior: ScrollBehavior = 'smooth') => {
+ const el = ref.current;
+ if (!el) return;
+ el.scrollTo({ top: el.scrollHeight, behavior });
+ },
+ [ref],
+ );
+
+ // Whenever a new message is appended we scroll once -- only if the user
+ // was already at the bottom, so their position is respected if they had
+ // scrolled up to read older context.
+ useEffect(() => {
+ if (atBottom) scrollToBottom('auto');
+ // We depend on messageCount rather than the messages list to avoid
+ // triggering on every text-delta re-render; one scroll per turn.
+ }, [messageCount, atBottom, scrollToBottom]);
+
+ return { atBottom, scrollToBottom };
+}
diff --git a/frontend/src/hooks/useSpeechRecognition.ts b/frontend/src/hooks/useSpeechRecognition.ts
new file mode 100644
index 0000000..a269087
--- /dev/null
+++ b/frontend/src/hooks/useSpeechRecognition.ts
@@ -0,0 +1,147 @@
+/**
+ * Push-to-talk speech recognition via the browser Web Speech API.
+ *
+ * Why push-to-talk and not always-on listening:
+ * - Always-on requires fine-tuning VAD and permissions; a simple hold-to-
+ * record button works first time and is what most chat apps use.
+ * - Always-on STT must keep a microphone open even when the user is not
+ * speaking, which costs battery and creates a real privacy surface.
+ *
+ * Caveats the caller needs to know:
+ * - Chromium and Safari ship SpeechRecognition; Firefox does not. The hook
+ * reports ``available: false`` in that case so the UI can hide the mic.
+ * - Where it does work, the actual recognition may be cloud STT (Chrome
+ * uses Google's; Safari uses Apple's). The component surfaces this fact
+ * in the settings panel so the user is not confused about what "local" means.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+interface SpeechRecognitionResult {
+ isFinal: boolean;
+ 0: { transcript: string };
+}
+
+interface SpeechRecognitionEventLike {
+ results: ArrayLike;
+ resultIndex: number;
+}
+
+interface SpeechRecognitionLike extends EventTarget {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ start(): void;
+ stop(): void;
+ abort(): void;
+ onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
+ onerror: ((ev: { error: string; message?: string }) => void) | null;
+ onend: (() => void) | null;
+ onstart: (() => void) | null;
+}
+
+type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
+
+declare global {
+ interface Window {
+ SpeechRecognition?: SpeechRecognitionCtor;
+ webkitSpeechRecognition?: SpeechRecognitionCtor;
+ }
+}
+
+export interface UseSpeechRecognition {
+ available: boolean;
+ listening: boolean;
+ interim: string;
+ error: string | null;
+ start: () => void;
+ stop: () => void;
+ abort: () => void;
+}
+
+export function useSpeechRecognition(opts?: {
+ lang?: string;
+ onFinal?: (text: string) => void;
+}): UseSpeechRecognition {
+ const lang = opts?.lang ?? (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
+ const onFinal = opts?.onFinal;
+ const [available, setAvailable] = useState(false);
+ const [listening, setListening] = useState(false);
+ const [interim, setInterim] = useState('');
+ const [error, setError] = useState(null);
+ const recognitionRef = useRef(null);
+ const onFinalRef = useRef(onFinal);
+ onFinalRef.current = onFinal;
+
+ useEffect(() => {
+ const Ctor = typeof window !== 'undefined'
+ ? (window.SpeechRecognition ?? window.webkitSpeechRecognition)
+ : undefined;
+ setAvailable(Boolean(Ctor));
+ return () => {
+ try {
+ recognitionRef.current?.abort();
+ } catch {
+ // ignore
+ }
+ };
+ }, []);
+
+ const start = useCallback(() => {
+ if (!available) return;
+ if (listening) return;
+ const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
+ if (!Ctor) return;
+ setError(null);
+ setInterim('');
+ const rec = new Ctor();
+ rec.continuous = false;
+ rec.interimResults = true;
+ rec.lang = lang;
+ rec.onstart = () => setListening(true);
+ rec.onresult = (ev) => {
+ let interimText = '';
+ let finalText = '';
+ for (let i = ev.resultIndex; i < ev.results.length; i++) {
+ const r = ev.results[i];
+ if (r.isFinal) finalText += r[0].transcript;
+ else interimText += r[0].transcript;
+ }
+ if (interimText) setInterim(interimText);
+ if (finalText) {
+ setInterim('');
+ onFinalRef.current?.(finalText.trim());
+ }
+ };
+ rec.onerror = (ev) => {
+ setError(ev.error + (ev.message ? `: ${ev.message}` : ''));
+ setListening(false);
+ };
+ rec.onend = () => {
+ setListening(false);
+ setInterim('');
+ };
+ recognitionRef.current = rec;
+ rec.start();
+ }, [available, lang, listening]);
+
+ const stop = useCallback(() => {
+ try {
+ recognitionRef.current?.stop();
+ } catch {
+ // ignore
+ }
+ }, []);
+
+ const abort = useCallback(() => {
+ try {
+ recognitionRef.current?.abort();
+ } catch {
+ // ignore
+ }
+ setListening(false);
+ setInterim('');
+ }, []);
+
+ return { available, listening, interim, error, start, stop, abort };
+}
diff --git a/frontend/src/hooks/useSpeechSynthesis.ts b/frontend/src/hooks/useSpeechSynthesis.ts
new file mode 100644
index 0000000..b2cbff7
--- /dev/null
+++ b/frontend/src/hooks/useSpeechSynthesis.ts
@@ -0,0 +1,91 @@
+/**
+ * Browser SpeechSynthesis wrapper for the assistant "TTS play" button on
+ * each message.
+ *
+ * Browser TTS is free, local-ish (some browsers do fetch voice data), and
+ * zero install. Where the chrome is poor (older voices, robotic defaults)
+ * we pick the best match from the available list rather than accepting
+ * whatever the OS hands us. The actual quality lives at the OS level; this
+ * hook just keeps the surface predictable.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+export interface UseSpeechSynthesis {
+ available: boolean;
+ speaking: boolean;
+ voices: Array;
+ speak: (text: string, opts?: { voiceURI?: string; rate?: number }) => void;
+ cancel: () => void;
+}
+
+export function useSpeechSynthesis(): UseSpeechSynthesis {
+ const [available, setAvailable] = useState(false);
+ const [speaking, setSpeaking] = useState(false);
+ const [voices, setVoices] = useState>([]);
+ const utteranceRef = useRef(null);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) {
+ setAvailable(false);
+ return;
+ }
+ setAvailable(true);
+ const refresh = () => {
+ const list = window.speechSynthesis?.getVoices() ?? [];
+ setVoices([...list]);
+ };
+ refresh();
+ // Some browsers (Chromium) populate ``getVoices`` asynchronously. The
+ // ``voiceschanged`` event fires once that initial fetch resolves.
+ window.speechSynthesis.onvoiceschanged = refresh;
+ return () => {
+ window.speechSynthesis?.cancel();
+ if (window.speechSynthesis) {
+ window.speechSynthesis.onvoiceschanged = null;
+ }
+ };
+ }, []);
+
+ const speak = useCallback(
+ (text: string, opts?: { voiceURI?: string; rate?: number }) => {
+ if (!available || typeof window === 'undefined' || !window.speechSynthesis) return;
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ const synth = window.speechSynthesis;
+ const UtteranceCtor = window.SpeechSynthesisUtterance;
+ if (!UtteranceCtor) return;
+ synth.cancel();
+ const u = new UtteranceCtor(trimmed);
+ let voice = voices.find((v) => v.voiceURI === opts?.voiceURI) ?? null;
+ if (!voice) {
+ const userLang = navigator.language || 'en-US';
+ voice =
+ voices.find((v) => v.lang === userLang) ??
+ voices.find((v) => v.lang.startsWith(userLang.split('-')[0])) ??
+ voices[0] ??
+ null;
+ }
+ if (voice) u.voice = voice;
+ u.lang = voice?.lang ?? navigator.language ?? 'en-US';
+ u.rate = opts?.rate ?? 1.0;
+ u.pitch = 1.0;
+ u.volume = 1.0;
+ u.onstart = () => setSpeaking(true);
+ u.onend = () => setSpeaking(false);
+ u.onerror = () => setSpeaking(false);
+ utteranceRef.current = u;
+ synth.speak(u);
+ },
+ [available, voices],
+ );
+
+ const cancel = useCallback(() => {
+ if (!available || typeof window === 'undefined') return;
+ window.speechSynthesis?.cancel();
+ setSpeaking(false);
+ }, [available]);
+
+ return { available, speaking, voices, speak, cancel };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..779e64a
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,77 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Base typography and color scheme. Tailwind's preflight already handles the
+ heavy lifting, but we want a few opinionated defaults. */
+@layer base {
+ html {
+ -webkit-text-size-adjust: 100%;
+ text-rendering: optimizeLegibility;
+ /* The user is going to live in this app; OS scrollbar styling keeps the
+ chrome thin on both webkit and gecko. */
+ scrollbar-width: thin;
+ scrollbar-color: rgba(143, 110, 255, 0.35) transparent;
+ }
+ body {
+ font-feature-settings: 'cv11', 'ss01';
+ overscroll-behavior-y: none;
+ }
+ /* Stream incoming assistant text with a subtle caret so the user always
+ knows where the next token will land, even between SSE events. */
+ .streaming-caret::after {
+ content: '▍';
+ margin-left: 2px;
+ opacity: 0.6;
+ animation: caret_blink 1.1s steps(2, end) infinite;
+ }
+ @keyframes caret_blink {
+ to {
+ opacity: 0;
+ }
+ }
+ /* Webkit scrollbar — narrow, slightly violet to match the brand chrome. */
+ ::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+ ::-webkit-scrollbar-thumb {
+ background-color: rgba(143, 110, 255, 0.25);
+ border-radius: 999px;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(143, 110, 255, 0.45);
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+}
+
+/* Reusable composite components the markdown stream + chat bubbles both use. */
+@layer components {
+ .glass {
+ @apply bg-ink-900/55 backdrop-blur-md border border-ink-700/60;
+ }
+ .chip {
+ @apply inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5
+ text-[11px] font-medium uppercase tracking-wide
+ border border-ink-700/70 bg-ink-800/70 text-ink-200;
+ }
+ .chip-violet {
+ @apply border-violet-700/70 bg-violet-900/30 text-violet-200;
+ }
+ .chip-sage {
+ @apply border-sage-500/40 bg-sage-500/15 text-sage-400;
+ }
+ .chip-amber {
+ @apply border-amber-400/50 bg-amber-500/15 text-amber-400;
+ }
+ .chip-rose {
+ @apply border-rose-500/50 bg-rose-500/15 text-rose-400;
+ }
+ .focus-ring {
+ @apply focus-visible:outline-none focus-visible:ring-2
+ focus-visible:ring-violet-500/60 focus-visible:ring-offset-2
+ focus-visible:ring-offset-ink-950;
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..9acb9f1
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import App from './App';
+import './index.css';
+
+const rootElement = document.getElementById('root');
+if (!rootElement) {
+ throw new Error('#root container missing in index.html');
+}
+
+createRoot(rootElement).render(
+
+
+ ,
+);
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
new file mode 100644
index 0000000..8db749c
--- /dev/null
+++ b/frontend/src/types.ts
@@ -0,0 +1,126 @@
+/**
+ * Cortex frontend types -- shared with the FastAPI wire format.
+ *
+ * Keep this file in lockstep with `src/cortex/server/schemas.py`. The names
+ * are deliberately aligned so a reader moving between server and client
+ * sees ``CitationOut``->``Citation`` and not two different shapes for the
+ * same concept.
+ */
+
+export type ChatRole = 'user' | 'assistant' | 'system';
+
+export interface ChatMessage {
+ role: ChatRole;
+ content: string;
+ ts?: string;
+}
+
+export interface Citation {
+ index: number;
+ note_id: string;
+ obsidian_uri: string;
+ title: string;
+ snippet: string;
+ score: number;
+ tags?: string[];
+}
+
+export interface ProviderStatus {
+ name: string;
+ model: string;
+ policy: string;
+ priority: number;
+ eligible_private: boolean;
+ reason?: string | null;
+ config_issue?: string | null;
+}
+
+export interface ThermalStatus {
+ state: string;
+ power?: string | null;
+ cpu_speed_limit?: number | null;
+ battery_percent?: number | null;
+ workers: number;
+ may_backfill: boolean;
+ available: boolean;
+ reason?: string | null;
+}
+
+export interface StatusSnapshot {
+ vault_path: string;
+ vault_name: string;
+ notes_indexed: number;
+ chunks: number;
+ providers: ProviderStatus[];
+ thermal: ThermalStatus;
+ memory_count: number;
+ memory_enabled: boolean;
+}
+
+export interface MemoryNoteOut {
+ path: string;
+ title: string;
+ created: string;
+ question: string;
+ answer: string;
+ sources: string[];
+ obsidian_uri: string;
+}
+
+export interface WhoAmI {
+ vault_name: string;
+ vault_path: string;
+ providers: Array<{
+ name: string;
+ model: string;
+ policy: string;
+ configured: boolean;
+ }>;
+ memory_enabled: boolean;
+ local_only: boolean;
+}
+
+// SSE event variants emitted by ``POST /api/chat``. Plain discriminated union
+// so an ``EventSource``'s listener gets ``type`` for free.
+export type ChatEvent =
+ | { type: 'provider'; data: { deciding?: boolean; name?: string; model?: string; escalated?: boolean; policy?: string; elapsed_ms?: number; local_only_enforced?: boolean } }
+ | { type: 'retrieval'; data: { query: string; elapsed_ms: number; retrievers: Record; date_filter: string | null; reranked: boolean; matched: number } }
+ | { type: 'citation'; data: Citation }
+ | { type: 'text'; data: { delta: string } }
+ | { type: 'memory'; data: { saved: string; obsidian_uri: string } }
+ | { type: 'done'; data: { provider: string; model?: string; escalated?: boolean; policy?: string; elapsed_ms?: number; answer: string; total_elapsed_ms?: number } }
+ | { type: 'error'; data: { type?: string; message: string } };
+
+export interface ChatRequest {
+ messages: ChatMessage[];
+ local_only?: boolean;
+ remember?: boolean;
+ top_k?: number;
+}
+
+// -- Wikilink graph -------------------------------------------------------
+
+export interface GraphNode {
+ id: string;
+ title: string;
+ degree: number;
+ /** Top-level tag, reserved for a follow-up that carries note-level tags. */
+ tag?: string | null;
+ is_hub: boolean;
+}
+
+export type GraphEdgeKind = 'link' | 'embed';
+
+export interface GraphEdge {
+ source: string;
+ target: string;
+ kind: GraphEdgeKind;
+}
+
+export interface GraphSnapshot {
+ stats: { notes: number; linked_notes: number; edges: number; embeds: number; tags: number };
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+ center: string | null;
+ truncated: boolean;
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..cfb2e58
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,88 @@
+/** @type {import('tailwindcss').Config} */
+
+// Cortex palette: deep neutrals for a "console" feel that does not fatigue
+// during long sessions, with a small set of accent hues that surface state
+// (provider badge, citation, error, memory) without shouting.
+export default {
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ ink: {
+ 50: '#f6f5f7',
+ 100: '#e9e7ec',
+ 200: '#c8c4d2',
+ 300: '#9c95ad',
+ 400: '#6f6783',
+ 500: '#524a66',
+ 600: '#3d3650',
+ 700: '#2c2640',
+ 800: '#1d1930',
+ 900: '#100d20',
+ 950: '#0a0816',
+ },
+ violet: {
+ 50: '#f3f0ff',
+ 100: '#e8e2ff',
+ 200: '#d2c5ff',
+ 300: '#b39eff',
+ 400: '#8e6dff',
+ 500: '#6f3cff',
+ 600: '#5a25e8',
+ 700: '#4a1dbf',
+ 800: '#3c1899',
+ 900: '#2c126e',
+ },
+ sage: {
+ 400: '#7ad0a8',
+ 500: '#4fbf87',
+ 600: '#3aa66e',
+ },
+ amber: {
+ 400: '#f4b967',
+ 500: '#e89e3a',
+ },
+ rose: {
+ 400: '#f06b94',
+ 500: '#dc467a',
+ },
+ },
+ fontFamily: {
+ sans: [
+ 'Inter',
+ '-apple-system',
+ 'BlinkMacSystemFont',
+ 'SF Pro Text',
+ 'Helvetica Neue',
+ 'sans-serif',
+ ],
+ mono: ['JetBrains Mono', 'SF Mono', 'Menlo', 'monospace'],
+ },
+ boxShadow: {
+ glow: '0 0 32px -8px rgba(143, 110, 255, 0.45)',
+ ring: '0 0 0 1px rgba(143, 110, 255, 0.25), 0 0 24px -4px rgba(143, 110, 255, 0.35)',
+ },
+ backgroundImage: {
+ 'radial-fade':
+ 'radial-gradient(ellipse at top, rgba(143, 110, 255, 0.18), transparent 60%), radial-gradient(ellipse at bottom, rgba(74, 29, 191, 0.12), transparent 50%)',
+ },
+ keyframes: {
+ pulse_ring: {
+ '0%': { transform: 'scale(0.8)', opacity: '0.7' },
+ '70%': { transform: 'scale(1.4)', opacity: '0' },
+ '100%': { transform: 'scale(0.8)', opacity: '0' },
+ },
+ breathe: {
+ '0%, 100%': { opacity: '0.5' },
+ '50%': { opacity: '1' },
+ },
+ },
+ animation: {
+ pulse_ring: 'pulse_ring 1.6s ease-out infinite',
+ breathe: 'breathe 2.4s ease-in-out infinite',
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..46f75ed
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..96decab
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// Vite dev server proxies API calls to the FastAPI backend at 127.0.0.1:7331.
+// In production ``cortex serve`` mounts the built dist/ on / so the proxy is
+// unused -- but having it here makes ``npm run dev`` immediately useful even
+// before the build is wired up.
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:7331',
+ changeOrigin: true,
+ // SSE needs raw passthrough -- the default stream buffering breaks
+ // EventSource if there's any handle on the response.
+ ws: false,
+ configure: (proxy) => {
+ proxy.on('proxyReq', (proxyReq) => {
+ proxyReq.setHeader('Connection', 'keep-alive');
+ });
+ },
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ target: 'es2022',
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
index 887b4e2..311d0d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,9 @@ rerank = ["sentence-transformers>=3.0"]
# PDF / EPUB / HTML ingestion. pymupdf4llm emits markdown with headings intact,
# which the structure-aware chunker then splits properly.
documents = ["pymupdf4llm>=0.0.17"]
+# HTTP server backing `cortex serve` -- FastAPI and uvicorn. Kept optional so a
+# pure-Cortex install stays light and only imports the web runtime on demand.
+server = ["fastapi>=0.115", "uvicorn[standard]>=0.30"]
dev = [
"pytest>=8.2",
"pytest-cov>=5.0",
@@ -50,7 +53,7 @@ dev = [
"mypy>=1.10",
"types-PyYAML",
]
-all = ["cortex-brain[index,mcp,rerank,documents]"]
+all = ["cortex-brain[index,mcp,rerank,documents,server]"]
[project.scripts]
cortex = "cortex.cli:app"
@@ -99,6 +102,7 @@ addopts = "-q --strict-markers"
markers = [
"integration: requires optional native deps (lancedb) or network",
]
+asyncio_mode = "auto"
[tool.coverage.run]
source = ["src/cortex"]
diff --git a/src/cortex/cli.py b/src/cortex/cli.py
index bf67cc9..c9816c6 100644
--- a/src/cortex/cli.py
+++ b/src/cortex/cli.py
@@ -1,13 +1,15 @@
"""Command-line interface.
-cortex index incremental index of the vault
-cortex ask "..." grounded answer with citations
-cortex search "..." raw retrieval, no synthesis
-cortex status index, thermal and routing state
-cortex providers which providers may see private content, and why
-cortex graph wikilink graph statistics
-cortex watch run the incremental indexer continuously
-cortex serve-mcp MCP stdio server for Antigravity
+cortex doctor diagnose environment, configuration, dependencies
+cortex index incremental index of the vault
+cortex ask "..." grounded answer with citations
+cortex search "..." raw retrieval, no synthesis
+cortex status index, thermal and routing state
+cortex providers which providers may see private content, and why
+cortex graph wikilink graph statistics
+cortex watch run the incremental indexer continuously
+cortex serve run the local web app (chat, voice, vault browser)
+cortex serve-mcp MCP stdio server for Antigravity
"""
from __future__ import annotations
@@ -73,6 +75,87 @@ def version() -> None:
console.print(f"cortex {__version__}")
+@app.command()
+def doctor(
+ config: ConfigOpt = None,
+ vault: VaultOpt = None,
+) -> None:
+ """Diagnose environment, configuration, and dependencies.
+
+ Runs vault, config, Ollama, vector store, MCP, optional extras, daemon and
+ memory-folder sanity checks -- without entering the indexing pipeline --
+ and prints a single ``Ready`` / ``N issues`` summary line. Use this when
+ the bootstrap script ended and you want to know whether indexing will
+ actually work, or when something downstream failed mysteriously and you
+ want a labelled view of which subsystem is the suspect.
+ """
+ from cortex.config import load_settings
+ from cortex.doctor import Status, run_doctor
+
+ settings = load_settings(config)
+ if vault is not None:
+ settings.vault_path = vault.expanduser()
+
+ report = run_doctor(settings)
+
+ table = Table(title="cortex doctor", show_header=False, box=None, padding=(0, 2))
+ table.add_column("Status")
+ table.add_column("Check")
+ table.add_column("Detail")
+
+ status_icon = {
+ Status.PASS: "[green]\u2713[/green]",
+ Status.INFO: "[blue]i[/blue]",
+ Status.WARN: "[yellow]![/yellow]",
+ Status.FAIL: "[red]\u2717[/red]",
+ }
+ detail_colour = {
+ Status.PASS: "green",
+ Status.INFO: "blue",
+ Status.WARN: "yellow",
+ Status.FAIL: "red",
+ }
+
+ for check in report.checks:
+ table.add_row(
+ status_icon[check.status],
+ check.name,
+ f"[{detail_colour[check.status]}]{check.message}[/{detail_colour[check.status]}]",
+ )
+ for line in check.details:
+ table.add_row("", "", f" [dim]\u2022 {line}[/dim]")
+ if check.hint:
+ colour = detail_colour[check.status]
+ table.add_row("", "", f" [{colour}]\u2192 {check.hint}[/{colour}]")
+
+ console.print(table)
+
+ if report.blocking == 0 and report.warnings == 0:
+ console.print("\n[bold green]Ready[/bold green]")
+ return
+
+ # The headline count and the breakdown must agree: an "issue" is a WARN or
+ # a FAIL; an INFO is just an observation that goes on its own line below.
+ # Mixing them into the breakdown makes "1 issue (1 blocking, 1 warning,
+ # 1 info)" -- an arithmetic puzzle where the count and the parts disagree.
+ parts: list[str] = []
+ if report.blocking:
+ parts.append(f"[bold red]{report.blocking} blocking[/bold red]")
+ if report.warnings:
+ warnings_word = "warning" if report.warnings == 1 else "warnings"
+ parts.append(f"[yellow]{report.warnings} {warnings_word}[/yellow]")
+ console.print(f"\n[bold]{report.summary_line()}[/bold] ({', '.join(parts)})")
+
+ if report.infos:
+ console.print(f"[blue]{report.infos} info[/blue]")
+
+ # Exit non-zero only for blocking issues; warnings are operationally fine
+ # and the user asked for a single summary line, not a failure code on a
+ # cosmetic gap like a missing daemon.
+ if report.blocking:
+ raise typer.Exit(code=1)
+
+
@app.command()
def index(
config: ConfigOpt = None,
@@ -480,8 +563,8 @@ def bench(
ablation = Table(title="Ablation - what each component contributes")
ablation.add_column("Disabled")
ablation.add_column("recall@5", justify="right")
- ablation.add_column("Δ recall", justify="right")
- ablation.add_column("Δ nDCG", justify="right")
+ ablation.add_column("\u0394 recall", justify="right")
+ ablation.add_column("\u0394 nDCG", justify="right")
ablation.add_column("ms saved", justify="right")
for row in rows:
delta = float(row["recall_delta"])
@@ -495,7 +578,7 @@ def bench(
)
console.print(ablation)
console.print(
- "[dim]Positive Δ means the component helps: disabling it lost that "
+ "[dim]Positive \u0394 means the component helps: disabling it lost that "
"much recall. Negative means it is hurting you -- turn it off.[/dim]"
)
@@ -598,6 +681,135 @@ def serve_mcp(
run_stdio(config_path=config, vault=vault, offline=offline)
+@app.command("serve")
+def serve(
+ config: ConfigOpt = None,
+ vault: VaultOpt = None,
+ offline: OfflineOpt = False,
+ host: Annotated[
+ str | None,
+ typer.Option("--host", help="Bind address (overrides [serve].host in cortex.toml)."),
+ ] = None,
+ port: Annotated[
+ int | None,
+ typer.Option("--port", "-p", help="TCP port (overrides [serve].port in cortex.toml)."),
+ ] = None,
+ open_browser: Annotated[
+ bool | None,
+ typer.Option(
+ "--open/--no-open",
+ help="Open the local URL in your default browser on startup.",
+ ),
+ ] = None,
+ reload: Annotated[
+ bool, typer.Option("--reload", help="Auto-reload on source changes (dev only).")
+ ] = False,
+) -> None:
+ """Run the local web app: FastAPI backend + bundled SPA + chat UI.
+
+ One command starts the API, the chat server, and the built frontend on a
+ single port. Reach it at http://127.0.0.1:7331 by default. Use --port to
+ pick another if 7331 is busy; use --offline to run without model servers
+ (the hashing embedder is good enough for the UI demos).
+
+ The MCP server is unaffected by this -- it's still ``cortex serve-mcp`` for
+ LLM clients. The HTTP server is for humans.
+ """
+ from dataclasses import replace
+
+ from cortex.config import ServeConfig, load_settings
+
+ try:
+ import uvicorn
+ except ImportError as exc: # pragma: no cover
+ raise typer.BadParameter(
+ "cortex serve needs the optional [server] extra. "
+ "Install with: pip install 'cortex-brain[server]'"
+ ) from exc
+
+ settings = load_settings(config)
+ if vault is not None:
+ settings.vault_path = vault.expanduser()
+ if host is not None or port is not None or open_browser is not None:
+ settings = replace(
+ settings,
+ serve=ServeConfig(
+ host=host if host is not None else settings.serve.host,
+ port=port if port is not None else settings.serve.port,
+ open_browser=(
+ open_browser if open_browser is not None else settings.serve.open_browser
+ ),
+ cors_origins=settings.serve.cors_origins,
+ ),
+ )
+
+ # Build the runtime once eagerly so the API endpoints see it consistently
+ # (and so we can print a friendly "ready" line rather than letting the first
+ # request discover what failed).
+ from cortex.runtime import build_runtime
+ from cortex.server.dependencies import set_runtime
+
+ rt = build_runtime(settings=settings, offline=offline)
+ set_runtime(rt)
+
+ url = f"http://{settings.serve.host}:{settings.serve.port}"
+
+ from cortex.server.app import DEFAULT_FRONTEND_DIST
+
+ frontend_built = (
+ DEFAULT_FRONTEND_DIST.exists() and (DEFAULT_FRONTEND_DIST / "index.html").exists()
+ )
+ frontend_lines: list[str] = []
+ if not frontend_built:
+ frontend_lines = [
+ "",
+ "[yellow]! frontend/dist/index.html not found.[/yellow]",
+ " The web UI will not load. To build it:",
+ " [cyan]cd frontend && npm install && npm run build[/cyan]",
+ f" The API works at [dim]/api/*[/dim] -- e.g. [cyan]{url}/api/whoami[/cyan]",
+ ]
+
+ console.print(
+ Panel(
+ f"[bold green]Cortex is listening at[/bold green] [cyan]{url}[/cyan]\n\n"
+ f"Vault: [dim]{rt.settings.vault_path}[/dim]\n"
+ f"Provider chain: [dim]"
+ + ", ".join(p.spec.name for p in rt.router.providers)
+ + "[/dim]"
+ + "\n".join(frontend_lines),
+ title="cortex serve",
+ title_align="left",
+ )
+ )
+
+ if settings.serve.open_browser:
+ import contextlib
+ import threading
+ import time
+ import webbrowser
+
+ def _open() -> None:
+ # Give uvicorn a beat to bind the port before the browser asks, so
+ # the EventSource handshake doesn't race the first listener.
+ time.sleep(0.6)
+ with contextlib.suppress(Exception):
+ webbrowser.open(url)
+
+ threading.Thread(target=_open, daemon=True).start()
+
+ try:
+ uvicorn.run(
+ "cortex.server.app:create_app",
+ host=settings.serve.host,
+ port=settings.serve.port,
+ reload=reload,
+ log_level="info",
+ factory=True,
+ )
+ finally:
+ rt.close()
+
+
def main() -> None:
app()
diff --git a/src/cortex/config.py b/src/cortex/config.py
index 4aa464f..a1ef166 100644
--- a/src/cortex/config.py
+++ b/src/cortex/config.py
@@ -32,14 +32,28 @@
def default_providers() -> list[ProviderSpec]:
"""The shipped fallback chain.
- Ordering is local-first, then by free-tier generosity. Groq leads the remote
- tier because 14,400 requests/day on llama-3.1-8b is the most generous free
- allowance available and it does not train on submitted data.
+ Ordering is local-first, then by remote provider speed at acceptable cost.
+ On Apple Silicon with ``OLLAMA_NUM_GPU=0`` (the documented workaround
+ for the Metal shader JIT hang), local inference is CPU-bound and around
+ 4B parameters is the most a 16 GB machine can comfortably warm-start,
+ so the cloud tier does the heavy lifting once local falls behind.
+
+ The remote chain leads with Groq because its LPU chip returns tokens
+ minutes faster than any hosted GPU. OpenRouter comes behind it pinned
+ to a fast model with a 1 M-token context -- the second-brain synthesis
+ step routinely eats full document chunks plus 100+ retrieved passages,
+ so the larger context budget is the operative constraint. NVIDIA is
+ last -- also fast, but its rate limits are narrower and per-region
+ capacity more variable, so we keep it as overflow.
"""
return [
ProviderSpec(
name="ollama",
base_url="http://localhost:11434/v1",
+ # 4B is the sweet spot for hot-path synthesis on a 16 GB Air:
+ # qwen3:4b is ~2.5 GB resident, leaves room for the embedder,
+ # and the small step from 1.7B has a measurable quality bump on
+ # the dropped-fill detail common in Obsidian notes.
model="qwen3:4b",
policy=DataPolicy.LOCAL,
max_context=32_768,
@@ -48,6 +62,9 @@ def default_providers() -> list[ProviderSpec]:
ProviderSpec(
name="groq",
base_url="https://api.groq.com/openai/v1",
+ # ~350 tok/s on Groq's LPU. The 70B model is the right ceiling
+ # for synthesis: query expansion, memory rewriting, and answer
+ # generation all need stronger reasoning than an 8B gives.
model="llama-3.3-70b-versatile",
policy=DataPolicy.NO_TRAIN,
api_key_env="GROQ_API_KEY",
@@ -69,13 +86,19 @@ def default_providers() -> list[ProviderSpec]:
ProviderSpec(
name="openrouter",
base_url="https://openrouter.ai/api/v1",
- # The free roster rotates weekly, so pin the auto-router rather
- # than a slug that will 404 in a fortnight. openrouter/free selects
- # from whatever free models are currently available.
- model="openrouter/free",
+ # 8B Instruct is the right ceiling for OpenRouter under ZDR:
+ # cheap (~$0.05/MTok prompt), ~550 ms median round trip, and
+ # ``meta-llama/*`` is one of the few model families served by
+ # ZDR-endpoint providers on the lower credit tiers -- probed
+ # mid-2026, successors to ``qwen/qwen3.7-flash`` 404'd under
+ # ZDR-strict filtering. ``requires_zdr=True`` keeps the gate
+ # honest: cortex sends ``zdr=true`` on the wire and the
+ # provider refuses any traffic that the user has not opted in
+ # at https://openrouter.ai/settings/privacy.
+ model="meta-llama/llama-3.1-8b-instruct",
policy=DataPolicy.NO_TRAIN_IF_ZDR,
api_key_env="OPENROUTER_API_KEY",
- max_context=128_000,
+ max_context=131_072,
# 20 RPM / 50 RPD on a free account; 1,000 RPD once any credit has
# ever been purchased. The conservative figure is the default.
rpm=20,
@@ -91,6 +114,42 @@ def default_providers() -> list[ProviderSpec]:
]
+@dataclass(frozen=True, slots=True)
+class ServeConfig:
+ """Configuration for the local HTTP server.
+
+ Defaults to localhost-only on a non-privileged port. Binding to 0.0.0.0 is
+ a deliberate decision the user has to make in cortex.toml; we do not pop
+ the door open by accident on first launch.
+ """
+
+ host: str = "127.0.0.1"
+ port: int = 7331
+ open_browser: bool = True
+ """If true, ``cortex serve`` opens the local URL in your default browser."""
+
+ cors_origins: tuple[str, ...] = (
+ "http://localhost:7331",
+ "http://127.0.0.1:7331",
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ )
+ """Origins allowed to call the API.
+
+ The first two cover the bundled production build (``cortex serve``).
+ The last two cover the Vite dev server (``npm run dev`` in
+ ``frontend/``) so a developer iterating on the UI does not hit CORS
+ preflight failures.
+
+ Extend this list in cortex.toml when deploying to a different origin.
+ Evidence for the safety of these defaults: every entry is a loopback
+ URL on the same machine; a browser extension on ``localhost`` is the
+ only attack surface, and ``localhost`` already implies physical /
+ remote access to the machine -- past the threat model this tool sits
+ in.
+ """
+
+
@dataclass(slots=True)
class Settings:
"""Resolved runtime configuration."""
@@ -151,8 +210,15 @@ class Settings:
governor: GovernorConfig = field(default_factory=GovernorConfig)
providers: list[ProviderSpec] = field(default_factory=default_providers)
+ serve: ServeConfig = field(default_factory=ServeConfig)
+ """Settings for ``cortex serve``: the local web / HTTP surface.
+
+ Kept on the same Settings object rather than a separate one so the
+ composition root stays simple -- one place where everything the program
+ could ever need to look at lives.
+ """
+
local_only: bool = False
- """Hard switch. When true nothing leaves the machine, regardless of policy."""
exclude_globs: list[str] = field(
default_factory=lambda: [
@@ -326,6 +392,22 @@ def load_settings(
settings.providers = _providers_from_toml(data.get("providers"), default_providers())
+ serve = data.get("serve", {})
+ if isinstance(serve, dict):
+ serve_base = ServeConfig()
+ port_val = serve.get("port", serve_base.port)
+ settings.serve = ServeConfig(
+ host=str(serve.get("host", serve_base.host)),
+ port=int(port_val) if port_val is not None else serve_base.port,
+ open_browser=bool(serve.get("open_browser", serve_base.open_browser)),
+ )
+ origins_raw = serve.get("cors_origins", list(serve_base.cors_origins))
+ if isinstance(origins_raw, list):
+ settings.serve = replace(
+ settings.serve,
+ cors_origins=tuple(str(o) for o in origins_raw),
+ )
+
# Environment overrides win.
if "CORTEX_VAULT" in env:
settings.vault_path = _coerce_path(env["CORTEX_VAULT"])
diff --git a/src/cortex/doctor.py b/src/cortex/doctor.py
new file mode 100644
index 0000000..62a59e1
--- /dev/null
+++ b/src/cortex/doctor.py
@@ -0,0 +1,1346 @@
+"""Pre-flight diagnostics for the second-brain setup.
+
+``cortex doctor`` runs nine small checks against the *world* -- is Ollama up,
+are extras installed, is the LaunchDaemon plist present, can memory notes be
+written -- and emits a single ``Ready`` or ``N issues`` summary line on top of a
+detailed list. It deliberately does **not** build a full ``Runtime`` for these
+checks: a user running ``cortex doctor`` for the first time may not have an
+Obsidian vault configured yet, and the doctor should still surface what's
+wrong so they have something to act on.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import plistlib
+import re
+import shutil
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+from cortex.config import Settings
+from cortex.llm.providers import build_chat_providers
+from cortex.llm.router import Router
+from cortex.models import Sensitivity
+
+logger = logging.getLogger(__name__)
+
+__all__ = ["Check", "DoctorReport", "Status", "run_doctor"]
+
+
+class Status(Enum):
+ """Severity of an individual check.
+
+ Distinct from ``exit code``: doctor exits non-zero only on ``FAIL``, never
+ on ``WARN`` or ``INFO``. The colour is what users see; the enum name is the
+ contract for assertions in tests.
+ """
+
+ PASS = "green"
+ INFO = "blue"
+ WARN = "yellow"
+ FAIL = "red"
+
+ @property
+ def icon(self) -> str:
+ return {Status.PASS: "✓", Status.INFO: "i", Status.WARN: "!", Status.FAIL: "✗"}[self]
+
+
+@dataclass(frozen=True, slots=True)
+class Check:
+ """One row in the diagnostic table.
+
+ ``hint`` is a one-line remediation; ``details`` are extra breadcrumbs on
+ failed checks (the refused providers, the path that wasn't writable, etc.).
+ Keep messages short enough to fit a 90-char terminal.
+ """
+
+ name: str
+ status: Status
+ message: str
+ hint: str | None = None
+ details: list[str] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class DoctorReport:
+ """Aggregate of all checks. The single thing callers print."""
+
+ checks: list[Check]
+
+ @property
+ def blocking(self) -> int:
+ return sum(1 for c in self.checks if c.status is Status.FAIL)
+
+ @property
+ def warnings(self) -> int:
+ return sum(1 for c in self.checks if c.status is Status.WARN)
+
+ @property
+ def infos(self) -> int:
+ return sum(1 for c in self.checks if c.status is Status.INFO)
+
+ def summary_line(self) -> str:
+ """Single 'Ready / N issues' line the user asked for."""
+ if self.blocking == 0 and self.warnings == 0:
+ return "Ready"
+ total = self.blocking + self.warnings
+ return f"{total} issue{'s' if total != 1 else ''}"
+
+
+# ---------------------------------------------------------------------------
+# individual checks
+# ---------------------------------------------------------------------------
+
+
+def _vault_summary(vault: Path) -> str:
+ """Best-effort count of notes and wikilinks, never raises."""
+ try:
+ notes = list(vault.rglob("*.md"))
+ except OSError:
+ return "found"
+
+ # Limit the wikilink scan: probing ten thousand files for "[[" on every
+ # doctor invocation would be silly. The flag doesn't need to be exact.
+ sample = notes[:200]
+ with_links = 0
+ for path in sample:
+ try:
+ if "[[" in path.read_text(encoding="utf-8", errors="ignore"):
+ with_links += 1
+ except OSError:
+ continue
+ return f"{len(notes)} notes, {with_links}/{len(sample)} sampled have wikilinks"
+
+
+def check_config(settings: Settings) -> Check:
+ # If we got a Settings object at all, it parsed without raising. Trust that.
+ _ = settings # explicit non-use to avoid 'unused' lint; presence is the check
+ return Check("Config", Status.PASS, "configuration loaded")
+
+
+def check_vault(settings: Settings) -> Check:
+ vault = settings.vault_path
+ if not vault.exists():
+ return Check(
+ "Vault",
+ Status.FAIL,
+ f"path does not exist: {vault}",
+ hint="Set CORTEX_VAULT, --vault, or vault_path in cortex.toml",
+ )
+ if not vault.is_dir():
+ return Check(
+ "Vault",
+ Status.FAIL,
+ f"path is not a directory: {vault}",
+ hint="Point vault_path to the folder containing your markdown notes",
+ )
+ return Check("Vault", Status.PASS, f"{vault} ({_vault_summary(vault)})")
+
+
+def check_ollama(settings: Settings) -> Check:
+ """Probe Ollama's ``/api/tags`` with a tight timeout.
+
+ Uses stdlib ``urllib`` rather than the cortex ``OllamaEmbedder`` on purpose:
+ that client has a 120s default timeout, which is the wrong budget for a
+ diagnostic. A 2s ceiling is loud enough to catch a hung daemon and quiet
+ enough to stay unnoticeable when nothing is wrong.
+ """
+ url = settings.ollama_url.rstrip("/")
+ try:
+ req = urllib.request.Request(f"{url}/api/tags")
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
+ payload = json.loads(resp.read())
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ return Check(
+ "Ollama",
+ Status.FAIL,
+ f"unreachable at {url}",
+ hint="Start Ollama: `ollama serve`",
+ details=[f"{type(exc).__name__}: {exc}"],
+ )
+ except (ValueError, json.JSONDecodeError) as exc:
+ return Check(
+ "Ollama",
+ Status.FAIL,
+ "/api/tags returned a malformed response",
+ details=[f"{type(exc).__name__}: {exc}"],
+ )
+
+ models = sorted({m.get("name", "") for m in payload.get("models", []) if m.get("name")})
+ target = settings.embed_model
+ pulled = target in models
+
+ # ``/api/ps`` lists models currently loaded into memory -- a stronger
+ # signal than /api/tags, which only reports what has been pulled. Combine
+ # both into the message so the user knows whether the model is on disk or
+ # resident right now. A loaded model is almost certainly callable; a
+ # pulled-but-cold model has to spool on first inference, which on a
+ # fanless Air can exceed the cortex 120s embed timeout when called from
+ # indexing. Knowledge of either state is the right level of honesty here.
+ loaded: set[str] = set()
+ try:
+ with urllib.request.urlopen(f"{url}/api/ps", timeout=1.0) as resp:
+ loaded_payload = json.loads(resp.read())
+ loaded = {m.get("name", "") for m in loaded_payload.get("models", []) if m.get("name")}
+ except (OSError, ValueError, json.JSONDecodeError):
+ # /api/ps is a diagnostic nicety, not a gate: ignore its failures.
+ pass
+
+ if pulled and target in loaded:
+ return Check(
+ "Ollama",
+ Status.PASS,
+ f"{target} pulled and currently loaded ({len(models)} pulled total)",
+ )
+ if pulled:
+ return Check(
+ "Ollama",
+ Status.PASS,
+ f"{target} pulled ({len(models)} total); not currently loaded -- first inference "
+ "will spool",
+ )
+ return Check(
+ "Ollama",
+ Status.WARN,
+ f"reachable but {target} not pulled",
+ hint=f"Run: ollama pull {target}",
+ details=[f"available: {', '.join(models[:6])}{'...' if len(models) > 6 else ''}"],
+ )
+
+
+# Pattern for a single assignment inside an ``Environment=`` line. systemd
+# accepts whitespace-separated lists (``Environment=K1=v1 K2=v2``), so the
+# outer caller splits on whitespace and feeds each token here.
+_SYSTEMD_ASSIGNMENT = re.compile(r"""^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*"?\s*$""")
+
+
+def _candidate_paths() -> tuple[tuple[Path, str], ...]:
+ """Resolve the per-platform ollama daemon config paths lazily.
+
+ Building the tuple at module import time would call ``Path.home()``, which
+ raises ``RuntimeError`` when ``$HOME`` is unset -- e.g. when Cortex is run
+ from inside a sandbox or a system service. Running the resolution on
+ each call costs ~10 stat calls total and keeps the import side-effect
+ free. Tests monkeypatch ``_OLLAMA_DAEMON_PATHS`` (a snapshot of this
+ tuple's signature) to inject fixtures without touching the real
+ ``~/Library`` directory.
+ """
+ home: Path | None = None
+ try:
+ # Probe ``Path.home()`` AND a stat on its result in one block so a
+ # sandbox with no ``$HOME`` (RuntimeError) AND a sandbox where
+ # ``$HOME`` points at an unreadable directory (OSError) both fall
+ # back to the more conservative system paths. Bundling them keeps
+ # mypy happy with a single ``Path | None`` annotation rather than
+ # two narrowed branches.
+ candidate = Path.home()
+ candidate.exists()
+ home = candidate
+ except (RuntimeError, OSError):
+ home = None
+ entries: list[tuple[Path, str]] = []
+ if home is not None:
+ # macOS — Homebrew user-scope daemon (foreground or background).
+ entries.append((home / "Library/LaunchAgents/homebrew.mxcl.ollama.plist", "plist"))
+ # macOS — official Ollama.app install.
+ entries.append((home / "Library/LaunchAgents/com.ollama.ollama.plist", "plist"))
+ # Linux — systemd user-scoped unit.
+ entries.append((home / ".config/systemd/user/ollama.service", "systemd"))
+ # Systemwide installs (no $HOME dependency).
+ entries.append((Path("/Library/LaunchDaemons/homebrew.mxcl.ollama.plist"), "plist"))
+ entries.append((Path("/Library/LaunchDaemons/com.ollama.ollama.plist"), "plist"))
+ entries.append((Path("/etc/systemd/system/ollama.service"), "systemd"))
+ entries.append((Path("/lib/systemd/system/ollama.service"), "systemd"))
+ return tuple(entries)
+
+
+OLLAMA_DAEMON_PATHS: tuple[tuple[Path, str], ...] = _candidate_paths()
+
+
+def _read_plist_env(path: Path) -> dict[str, str]:
+ """Return a plist's ``EnvironmentVariables`` dict as plain strings.
+
+ Reads binary or XML plists uniformly. Returns an empty dict if the file
+ has no env block. We never raise: a crash here would mask the more useful
+ fact that the file is unparseable, which the caller records as FAIL.
+ """
+ try:
+ with path.open("rb") as fh:
+ data = plistlib.load(fh)
+ except (OSError, plistlib.InvalidFileException, ValueError):
+ return {}
+ env = data.get("EnvironmentVariables")
+ if not isinstance(env, dict):
+ return {}
+ return {str(k): str(v) for k, v in env.items()}
+
+
+_SYSTEMD_ENV_PREFIX = re.compile(r"^\s*Environment\s*=\s*(.*)$")
+# Inline comments must be preceded by whitespace so values containing ``#``
+# (e.g. ``VAR=val#tag``) survive intact.
+_SYSTEMD_INLINE_COMMENT = re.compile(r"\s+#.*$")
+
+
+# Pattern for an entry inside a ``launchctl print`` environment block. The
+# launchd output uses ``KEY => VALUE`` (rocket arrow) rather than ``KEY=VALUE``.
+_LAUNCHCTL_KV_LINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=>\s*(.*?)\s*$")
+
+
+def _launchctl_braces_open(line: str) -> bool:
+ """True if *line* opens a launchd environment block (``... = {``)."""
+ return line.rstrip().endswith("{")
+
+
+def _is_launchctl_block_close(line: str) -> bool:
+ """True if *line* is a closing brace on its own."""
+ return line.strip() == "}"
+
+
+def _parse_launchctl_env_blocks(output: str) -> dict[str, str]:
+ """Parse every ``KEY => VALUE`` line inside launchctl environment blocks.
+
+ ``launchctl print`` emits up to three blocks -- inherited environment,
+ default environment, environment -- each opened by `` = {`` and
+ closed by ``}``. We merge all entries into a single dict with later keys
+ winning; this matches what the running process actually sees.
+ """
+ env: dict[str, str] = {}
+ in_block = False
+ for line in output.splitlines():
+ if _launchctl_braces_open(line):
+ in_block = True
+ continue
+ if _is_launchctl_block_close(line):
+ if in_block:
+ in_block = False
+ continue
+ if not in_block:
+ continue
+ match = _LAUNCHCTL_KV_LINE.match(line)
+ if match is None:
+ continue
+ env[match.group(1)] = match.group(2)
+ return env
+
+
+def _safe_run(args: tuple[str, ...], timeout: float) -> str | None:
+ """Run *args* via ``subprocess.run`` and return stdout.
+
+ Returns ``None`` for any failure mode the diagnostic must treat as
+ "could not probe": missing binary, OS error, timeout, non-zero exit.
+ Never raises.
+ """
+ try:
+ completed = subprocess.run(
+ list(args),
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ except OSError:
+ return None
+ if completed.returncode != 0 or not completed.stdout:
+ return None
+ return completed.stdout
+
+
+def _probe_launchctl_live() -> dict[str, str] | None:
+ """Read the running ollama daemon's environment via ``launchctl print``.
+
+ Tries every conventional label for the homebrew and official Ollama.app
+ installs, in user scope first (the homebrew default) and then system
+ scope (LaunchDaemon installs). Returns the merged env dict if at least
+ one target parses; ``None`` when no ollama service is loaded or the
+ tool is unavailable.
+ """
+ # ``_live_ollama_env`` only routes to this branch when
+ # ``sys.platform == 'darwin'``; ``os.getuid`` is always available on
+ # macOS. The unguarded call keeps mypy happy -- wrapping in a try/except
+ # for OSError is unreachable on the platforms we actually run on.
+ uid = os.getuid()
+ targets: tuple[str, ...] = (
+ f"gui/{uid}/homebrew.mxcl.ollama",
+ f"gui/{uid}/com.ollama.ollama",
+ "system/homebrew.mxcl.ollama",
+ "system/com.ollama.ollama",
+ )
+ for target in targets:
+ out = _safe_run(("launchctl", "print", target), timeout=3.0)
+ if out is None:
+ continue
+ env = _parse_launchctl_env_blocks(out)
+ # ``_safe_run`` already gates on non-zero exit code / empty stdout,
+ # so we trust launchctl's word: if it printed anything, we read it.
+ # An empty dict here means "the daemon is up but no env landed in
+ # any of the three blocks" -- treat that as a successful probe,
+ # not a failure, because users care about *negative* results too
+ # (``launchctl unsetenv OLLAMA_NUM_GPU``).
+ return env
+ return None
+
+
+def _parse_systemctl_env_lines(output: str) -> dict[str, str]:
+ """Parse every ``Environment=KEY=value ...`` line from systemctl output.
+
+ Each line begins with ``Environment=`` and contains a space-separated
+ list of ``KEY=value`` tokens. We reuse the systemd tokeniser regex so
+ static-file and live-probe semantics stay in lock-step.
+ """
+ env: dict[str, str] = {}
+ for line in output.splitlines():
+ stripped = line.strip()
+ if not stripped.startswith("Environment="):
+ continue
+ body = stripped[len("Environment=") :]
+ for token in body.split():
+ token = token.strip('"')
+ match = _SYSTEMD_ASSIGNMENT.match(token)
+ if match is None:
+ continue
+ env[match.group(1)] = match.group(2)
+ return env
+
+
+def _probe_systemctl_live() -> dict[str, str] | None:
+ """Read the running ollama daemon's environment via ``systemctl show``.
+
+ Tries the conventional unit names. Returns ``None`` if no ollama unit
+ is loaded or ``systemctl`` is unavailable.
+ """
+ for unit in ("ollama.service", "homebrew-mxcl-ollama.service"):
+ # ``--property=Environment`` asks systemd for the merged env it would
+ # pass to ``ExecStart=``; ``--no-pager`` keeps the output unframed.
+ argv = ("systemctl", "show", unit, "--property=Environment", "--no-pager")
+ out = _safe_run(argv, timeout=3.0)
+ if out is None:
+ continue
+ # ``_safe_run`` already gates on non-zero exit / empty stdout. An
+ # empty dict from the parser means systemd didn't set Environment=
+ # for this unit -- still a successful probe, not a failure.
+ return _parse_systemctl_env_lines(out)
+ return None
+
+
+def _live_ollama_env() -> dict[str, str] | None:
+ """Probe the *running* ollama daemon's actual environment.
+
+ macOS reads via ``launchctl print``; Linux via ``systemctl show``. We
+ return the merged env dict for any successful probe, ``None`` if no
+ probe picked up an ollama service or the platform has none of these
+ tools. Catches drift between the plist/unit value and the env the
+ kernel actually launched the daemon with (``brew services restart``
+ not run after editing the plist, ``launchctl setenv`` overrides,
+ shell-launched ``ollama serve``).
+ """
+ # Widening the platform value to ``str`` keeps mypy from collapsing
+ # this function to "always returns a probe" and flagging the defensive
+ # ``return None`` below as unreachable. ``sys.platform`` is typed as a
+ # Literal on supported interpreters.
+ platform: str = sys.platform
+ if platform == "darwin":
+ return _probe_launchctl_live()
+ if platform.startswith("linux"):
+ return _probe_systemctl_live()
+ # The remaining ``sys.platform`` values (``win32``, ``freebsd``, ...)
+ # have neither launchctl nor systemd; surfacing ``None`` lets the static
+ # check still report without making this an FAIL.
+ return None
+
+
+def _read_systemd_env(path: Path) -> dict[str, str]:
+ """Parse ``Environment=KEY=value`` lines from a systemd unit.
+
+ systemd accepts a *whitespace-separated* list per line, and a value may
+ contain escaped whitespace (``\\\\ ``) or quoted segments. We handle the
+ common forms:
+
+ * ``Environment=KEY=value`` → ``{KEY: value}``
+ * ``Environment="KEY=value"`` → ``{KEY: value}``
+ * ``Environment=K1=v1 K2=v2`` → ``{K1: v1, K2: v2}``
+ * trailing ``# comment`` (whitespace-prefixed) stripped
+ * line continuations (``\\\\``) joined before parsing
+
+ We deliberately do *not* follow ``EnvironmentFile=`` directives: doing so
+ would mean reading additional files and any ambiguity there would be a
+ misleading signal. The doctor's job is to surface whether the user
+ *explicitly* disabled GPU -- which they generally do inline. The
+ EnvironmentFile caveat is surfaced in the user-visible message instead.
+ """
+ env: dict[str, str] = {}
+ try:
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ return env
+
+ # Join systemd line continuations first so a single logical Environment=
+ # line spans multiple physical lines.
+ joined: list[str] = []
+ buffer = ""
+ for raw in text.splitlines():
+ stripped = raw.rstrip()
+ if stripped.endswith("\\"):
+ buffer += stripped.rstrip("\\")
+ continue
+ if buffer:
+ buffer += stripped
+ joined.append(buffer)
+ buffer = ""
+ else:
+ joined.append(stripped)
+ if buffer:
+ joined.append(buffer)
+
+ for line in joined:
+ # Comment stripping only past whitespace, so ``KEY=foo#bar`` survives.
+ line = _SYSTEMD_INLINE_COMMENT.sub("", line)
+ prefix_match = _SYSTEMD_ENV_PREFIX.match(line)
+ if prefix_match is None:
+ continue
+ # Each token may itself be ``KEY=value`` or quoted ``"KEY=value"``.
+ # We don't fully implement systemd's tokeniser (multi-quote, embedded
+ # spaces) -- a best-effort split on whitespace outside quotes is
+ # enough for OLLAMA_NUM_GPU=0, which never contains spaces or quotes.
+ tail = prefix_match.group(1)
+ for token in tail.split():
+ token = token.strip('"')
+ match = _SYSTEMD_ASSIGNMENT.match(token)
+ if match is None:
+ continue
+ key, value = match.group(1), match.group(2)
+ # Last assignment wins, matching systemd override semantics.
+ env[key] = value
+ return env
+
+
+@dataclass(frozen=True, slots=True)
+class _OllamaSupervisor:
+ """Snapshot of where and how OLLAMA_NUM_GPU is set on this machine.
+
+ Shared by ``check_gpu_path`` (writes the supervisor-side WARN) and
+ ``check_inference_path`` (writes the ollama-ps cross-check WARN). The
+ boolean ``disabled`` is True iff *any* source -- plist, systemd unit,
+ launchctl print, or systemctl show -- sets OLLAMA_NUM_GPU to a falsy
+ value. ``UNSET`` here means the source could not be probed, not that
+ the value is missing.
+ """
+
+ static_value: str | None # value from plist / unit, or None if no key
+ static_path: Path | None # path we read from
+ static_kind: str | None # "plist" or "systemd" for the file we read
+ static_file_present: bool # was any candidate file found?
+ live_value: str | None # value from running daemon env
+ live_probe_succeeded: bool # False if launchctl/systemctl call failed
+
+ @property
+ def disabled(self) -> bool:
+ """True iff any source we got evidence from says 0 / falsy.
+
+ A successful probe that finds no override contributes ENABLED;
+ a failed probe is treated as no evidence. If static says 0 the
+ supervisor is disabled even when the live probe fails, because the
+ on-disk config is the user's stated intent.
+ """
+ return (self.static_value is not None and self.static_value == "0") or (
+ self.live_value is not None and self.live_value == "0"
+ )
+
+ @property
+ def evidence(self) -> bool:
+ """True iff we have any successful probe, regardless of findings.
+
+ "Probed the supervisor and didn't find the OLLAMA_NUM_GPU key"
+ is itself evidence: the supervisor is policy-enabled by default
+ when no override exists. We surface a contradiction against
+ ``ollama ps`` only when we have at least one successful probe;
+ if every probe failed, the doctor says ``INFO`` and asks the
+ user to re-run.
+ """
+ return self.static_file_present or self.live_probe_succeeded
+
+
+def _read_ollama_supervisor(_settings: Settings) -> _OllamaSupervisor:
+ """Probe plist + unit + launchctl + systemctl for OLLAMA_NUM_GPU.
+
+ First existing file wins -- same semantics as ``check_gpu_path``:
+ once we find a real file on disk we trust its full env block,
+ including the absence of the OLLAMA_NUM_GPU key, as a statement of
+ intent. Scanning further would silently shadow the user's
+ user-scope plist with a system-wide one. Never raises.
+ """
+ static_path: Path | None = None
+ static_kind: str | None = None
+ static_value: str | None = None
+ static_file_present = False
+ for path, kind in globals()["OLLAMA_DAEMON_PATHS"]:
+ try:
+ if not path.exists():
+ continue
+ except OSError:
+ continue
+ static_file_present = True
+ env = _read_plist_env(path) if kind == "plist" else _read_systemd_env(path)
+ static_path = path
+ static_kind = kind
+ static_value = env.get("OLLAMA_NUM_GPU")
+ break
+
+ live_probe_succeeded = True
+ live_value: str | None
+ _live_env = _live_ollama_env()
+ if _live_env is None:
+ live_value = None
+ live_probe_succeeded = False
+ else:
+ live_value = _live_env.get("OLLAMA_NUM_GPU")
+
+ return _OllamaSupervisor(
+ static_value=static_value,
+ static_path=static_path,
+ static_kind=static_kind,
+ static_file_present=static_file_present,
+ live_value=live_value,
+ live_probe_succeeded=live_probe_succeeded,
+ )
+
+
+# Sentinel for which compute path a loaded model is on, as exposed by
+# ``ollama ps``'s PROCESSOR column. We classify by *qualitative* path,
+# not by reading percentage fields -- Ollama has shipped several formats
+# ("100% GPU", "32% GPU / 68% CPU", "GPU") and parsing all of them is
+# brittle.
+class _ModelProcessorKind(Enum):
+ GPU = "gpu" # any GPU contribution
+ CPU = "cpu" # any CPU contribution
+ MIXED = "mixed" # partial offload; both GPU and CPU mentioned
+ UNKNOWN = "unknown"
+
+
+_OLLAMA_PS_PROC_PATTERN = re.compile(r"(\d+)\s*%\s*(GPU|CPU)")
+
+
+def _parse_ollama_ps_processor(proc_str: str) -> _ModelProcessorKind:
+ """Classify an ``ollama ps`` PROCESSOR cell.
+
+ The format Ollama 0.32 emits is ``100% GPU`` for typical cases, but
+ partial offload prints ``48% GPU / 52% CPU``. Some earlier releases
+ just wrote ``GPU`` or ``CPU``. The presence of *both* terms is
+ reported as MIXED so the doctor can flag partial offload
+ separately from a clean GPU or CPU run.
+ """
+ norm = proc_str.strip().lower()
+ if not norm:
+ return _ModelProcessorKind.UNKNOWN
+ has_gpu = "gpu" in norm
+ has_cpu = "cpu" in norm
+ if has_gpu and has_cpu:
+ return _ModelProcessorKind.MIXED
+ if has_gpu:
+ return _ModelProcessorKind.GPU
+ if has_cpu:
+ return _ModelProcessorKind.CPU
+ return _ModelProcessorKind.UNKNOWN
+
+
+# Classifies a PROCESSOR cell on a single line. Reads ``100% GPU``,
+# ``50% GPU / 50% CPU``, ``48% GPU / 52% CPU``, ``GPU``, ``CPU`` and
+# any similar textual form. The regex is conservative: we anchor on
+# word boundaries so ``CUSTOM`` is not accidentally matched as ``CPU``.
+_OLLAMA_PS_PROCESSOR_FRAGMENT = re.compile(
+ r"(? list[dict[str, str]]:
+ """Parse ``ollama ps`` into one dict per data row.
+
+ Returns entries keyed by ``NAME`` and ``PROCESSOR`` -- the only
+ columns ``check_inference_path`` reads. ``UNTIL`` is multi-word on
+ recent ollama builds, so cropping the row by token index would
+ silently misclassify; we crop by *regex match* against the
+ PROCESSOR fragment and let the first whitespace token carry the
+ name. Header rows are recognised by the literal ``PROCESSOR``
+ column name and skipped.
+ """
+ rows: list[dict[str, str]] = []
+ for raw in output.splitlines():
+ stripped = raw.strip()
+ if not stripped or "PROCESSOR" in stripped.split():
+ # Header row or blank.
+ continue
+ first_space = stripped.find(" ")
+ if first_space == -1:
+ # Single-token row -- treat it as a NAME with no PROCESSOR.
+ rows.append({"NAME": stripped, "PROCESSOR": ""})
+ continue
+ name = stripped[:first_space].strip()
+ proc_match = _OLLAMA_PS_PROCESSOR_FRAGMENT.search(stripped)
+ proc = proc_match.group(1).strip() if proc_match else ""
+ rows.append({"NAME": name, "PROCESSOR": proc})
+ return rows
+
+
+def _ollama_binary_path() -> str | None:
+ """Return the absolute path of ``ollama`` on PATH, or ``None``."""
+ return shutil.which("ollama")
+
+
+def check_inference_path(settings: Settings) -> Check:
+ """Cross-check ``ollama ps`` against the supervisor's GPU policy.
+
+ Surfaces two classes of failure the static probe can't catch:
+
+ 1. The supervisor says GPU is *enabled* (no override) but every
+ loaded model reports zero GPU layers. This is the canonical
+ symptom of a Metal/CUDA backend crash, a model too big for VRAM,
+ a transient service exception, or a num_layers override that
+ only kicks in at run-time.
+ 2. The supervisor says GPU is *enabled* and at least one model
+ still uses some CPU -- typical of partial offload when VRAM is
+ tight. We surface this so the user knows inference isn't getting
+ the full speed-up they expected.
+
+ If ollama isn't installed, the daemon isn't reachable, or no model
+ is loaded, we degrade gracefully to INFO so this check never
+ pretends to know something we don't.
+ """
+ if _ollama_binary_path() is None:
+ return Check(
+ "Inference path",
+ Status.INFO,
+ "ollama binary not on PATH",
+ hint=(
+ "Install Ollama from https://ollama.com to enable "
+ "runtime inference path cross-check."
+ ),
+ )
+
+ out = _safe_run(("ollama", "ps"), timeout=3.0)
+ if out is None:
+ return Check(
+ "Inference path",
+ Status.INFO,
+ "ollama ps did not respond",
+ hint="Is `ollama serve` running?",
+ )
+
+ rows = _parse_ollama_ps_rows(out)
+ if not rows:
+ return Check(
+ "Inference path",
+ Status.PASS,
+ "no models currently loaded",
+ details=[
+ "ollama ps returned the header row but no data",
+ "load a model to engage the runtime cross-check",
+ ],
+ )
+
+ has_gpu_only = False
+ has_cpu_only = False
+ has_mixed = False
+ has_unknown_proc = False
+ per_model_kinds: list[tuple[str, _ModelProcessorKind]] = []
+ for row in rows:
+ name = row.get("NAME", "").strip()
+ proc = row.get("PROCESSOR", "").strip()
+ kind = _parse_ollama_ps_processor(proc)
+ per_model_kinds.append((name, kind))
+ if kind is _ModelProcessorKind.GPU:
+ has_gpu_only = True
+ elif kind is _ModelProcessorKind.CPU:
+ has_cpu_only = True
+ elif kind is _ModelProcessorKind.MIXED:
+ has_mixed = True
+ else:
+ has_unknown_proc = True
+
+ supervisor = _read_ollama_supervisor(settings)
+ details = [
+ f"{name}: {row.get('PROCESSOR', '?')}"
+ for (name, _kind), row in zip(per_model_kinds, rows, strict=False)
+ ] # Cross-check against supervisor policy.
+ if supervisor.disabled:
+ # Supervisor says CPU. *Any* GPU contribution while the user has
+ # explicitly disabled GPU is the contradiction we want to
+ # surface -- even if the same ollama ps output also lists CPU-
+ # only or partial-offload models (mixed-model load). On Apple
+ # Silicon this is the misreporting symptom; on Linux it's a
+ # misread of the disabled key.
+ if has_gpu_only:
+ return Check(
+ "Inference path",
+ Status.WARN,
+ "OLLAMA_NUM_GPU=0 (supervisor) but loaded models report GPU compute",
+ hint=(
+ "The supervisor explicitly disabled GPU, but ollama ps "
+ "shows the loaded model still using GPU. On Apple "
+ "Silicon with unified memory this may be a misleading "
+ "report: verify with `time ollama run ` for "
+ "actual inference latency. If the report proves wrong, "
+ "this is the symptom of ollama misreporting on your "
+ "Ollama build."
+ ),
+ details=[
+ *details,
+ "supervisor: GPU disabled",
+ "loaded model(s): GPU compute reported",
+ "verify with `time ollama run ` for actual latency",
+ ],
+ )
+ if has_cpu_only or has_mixed:
+ return Check(
+ "Inference path",
+ Status.PASS,
+ "loaded models consistent with OLLAMA_NUM_GPU=0",
+ details=details,
+ )
+ return Check(
+ "Inference path",
+ Status.PASS,
+ "ollama ps reports no recognisable PROCESSOR for loaded models",
+ details=details,
+ )
+
+ # Supervisor not disabled (no override found). Default expectation is
+ # that loaded models should run on GPU.
+ if not supervisor.evidence:
+ # Couldn't even tell what supervisor says. Don't pretend to know.
+ return Check(
+ "Inference path",
+ Status.INFO,
+ "ollama ps available but supervisor state unknown",
+ hint=(
+ "Could not read either the ollama daemon config or "
+ "`launchctl print`. Re-run with verbose logs."
+ ),
+ details=details,
+ )
+
+ if has_cpu_only and not has_gpu_only:
+ # The classic "GPU acceleration silently failed" case: the
+ # supervisor says enabled, the model loaded, but it's running
+ # entirely on CPU. The user typically had no idea.
+ return Check(
+ "Inference path",
+ Status.WARN,
+ "loaded models report zero GPU compute despite no NUM_GPU override",
+ hint=(
+ "GPU acceleration has silently fallen back to CPU. Common "
+ "causes: a Metal/CUDA backend crash at startup, a model "
+ "exceeding VRAM, an environment variable from a host-side "
+ "launchctl deeper than the daemon's own env, or a build of "
+ "Ollama that doesn't ship a working backend. Verify the "
+ "running log under /opt/homebrew/var/log/ollama.log for "
+ "Metal/CUDA compilation errors."
+ ),
+ details=[
+ *details,
+ "supervisor: GPU enabled (no OLLAMA_NUM_GPU override)",
+ "loaded model(s): CPU compute reported",
+ "fixing ollama's backend typically returns ~5x throughput",
+ ],
+ )
+
+ if has_mixed:
+ # Partial offload. The supervisor says GPU but the model spills
+ # some layers onto CPU -- the user is paying wall-clock latency
+ # they didn't sign up for.
+ return Check(
+ "Inference path",
+ Status.WARN,
+ "loaded models use partial CPU compute (offload)",
+ hint=(
+ "Some layers spilled to CPU -- inference will be slower "
+ "than a fully GPU-loaded model. Either shrink the context "
+ "size, drop the offload floor, or run a smaller quant."
+ ),
+ details=[
+ *details,
+ "supervisor: GPU enabled (no OLLAMA_NUM_GPU override)",
+ "loaded model(s): partial GPU/CPU compute",
+ ],
+ )
+
+ if has_unknown_proc and not has_cpu_only and not has_gpu_only and not has_mixed:
+ return Check(
+ "Inference path",
+ Status.PASS,
+ "ollama ps reported no recognisable PROCESSOR for loaded models",
+ details=details,
+ )
+
+ return Check(
+ "Inference path",
+ Status.PASS,
+ "all loaded models compute on GPU (consistent with no supervisor override)",
+ details=details,
+ )
+
+
+def check_gpu_path(_settings: Settings) -> Check:
+ """Warn when ``OLLAMA_NUM_GPU`` is set in the ollama daemon config.
+
+ `OLLAMA_NUM_GPU=0` is the standard workaround for the Apple-M5 / Tahoe
+ Metal-shader JIT hang: it forces llama.cpp onto the slower compute
+ buffers and unblocks inference, at the cost of ~2-5x latency. A user
+ who *configured* that override months ago may have forgotten it's still
+ active; this check makes the consequence visible every time they run
+ ``cortex doctor``.
+
+ We compare *two* sources of truth:
+
+ * **Static** -- the plist on disk or the systemd unit file. This is
+ what the user typically edits.
+ * **Live** -- ``launchctl print`` on macOS, ``systemctl show
+ --property=Environment`` on Linux. This is what the running
+ daemon actually loaded. The two can drift: a missed ``brew
+ services restart``, a transient ``launchctl setenv``, or a manual
+ ``ollama serve`` launched from a shell.
+
+ Severity ladder:
+
+ * static file present, key unset, live daemon also unset
+ -> PASS (default GPU acceleration)
+ * static file absent, live daemon also unset
+ -> INFO (no evidence either way)
+ * static value present, live value present and equal
+ -> WARN, hint about Metal bug
+ * static value present, live value differs
+ -> WARN, drift explanation
+ * static value present, live value unknown (probe failed)
+ -> WARN, hint to ``brew services restart``
+ * static value absent, live value present
+ -> WARN, hint about transient ``launchctl setenv`` / shell
+ """
+ last_err: str | None = None
+ # Look the snapshot up via ``globals()`` so tests that monkeypatch the
+ # module attribute ``OLLAMA_DAEMON_PATHS`` are honoured. We're not
+ # caching the path list across calls because that would freeze
+ # ``Path.home()`` against the module-import snapshot -- defeating the
+ # purpose of building the tuple lazily inside ``_candidate_paths()``.
+ # The semantics are *first existing file wins*: the user-installed plist
+ # precedes system-wide plists, and once we have a real file on disk we
+ # trust its env block alone -- a later candidate does not silently
+ # shadow it. This matches the original doctor contract and the test
+ # ``test_first_existing_path_wins``.
+ static_reading: tuple[Path, str, dict[str, str]] | None = None
+ for path, kind in globals()["OLLAMA_DAEMON_PATHS"]:
+ try:
+ exists = path.exists()
+ except OSError as exc:
+ last_err = f"{type(exc).__name__}: {exc}"
+ continue
+ if not exists:
+ continue
+ env = _read_plist_env(path) if kind == "plist" else _read_systemd_env(path)
+ static_reading = (path, kind, env)
+ break
+ static_file_present = static_reading is not None
+ static_source: tuple[str, Path, str] | None = None
+ static_path: Path | None
+ static_value: str | None
+ if static_reading is not None:
+ spath, skind, senv = static_reading
+ svalue = senv.get("OLLAMA_NUM_GPU")
+ if svalue is not None:
+ static_source = (svalue, spath, skind)
+ # First-existing-file wins: keep the path for the PASS message
+ # even when the env does not set the key, so the user can see
+ # which file we inspected.
+ static_path = spath
+ static_value = svalue
+ static_label = f"{spath.name} ({skind})"
+ else:
+ static_path = None
+ static_value = None
+ static_label = "(no static config)"
+
+ # Probe the running daemon env. Tolerate every failure mode -- the
+ # static source alone is useful enough that we shouldn't fail the whole
+ # check on a hiccup in ``launchctl`` or ``systemctl``.
+ live_value: str | None
+ live_probe_succeeded = True
+ _live_env = _live_ollama_env()
+ if _live_env is None:
+ live_value = None
+ live_probe_succeeded = False
+ else:
+ live_value = _live_env.get("OLLAMA_NUM_GPU")
+
+ if static_source is not None and live_value is not None:
+ static_value, static_path, _static_kind = static_source
+ if static_value == live_value:
+ return Check(
+ "GPU path",
+ Status.WARN,
+ f"OLLAMA_NUM_GPU={static_value} --- inference is CPU-bound "
+ f"(static and live daemon agree)",
+ hint=(
+ "CPU-only inference is the standard workaround for the Apple "
+ "M5 / macOS Metal shader JIT hang. Remove OLLAMA_NUM_GPU "
+ f"from {static_path} once llama.cpp fixes the upstream bug. "
+ "(EnvironmentFile= contents are not consulted by this check.)"
+ ),
+ details=[
+ f"static: {static_label}",
+ f"live: {live_value!r}",
+ "static and live match: both CPU-bound",
+ ],
+ )
+ # Drift between static and live.
+ return Check(
+ "GPU path",
+ Status.WARN,
+ f"drift between static ({static_value!r}) and live ({live_value!r})",
+ hint=(
+ f"Static config in {static_path} says OLLAMA_NUM_GPU={static_value} "
+ f"but the running daemon has OLLAMA_NUM_GPU={live_value}. Restart "
+ "the daemon (`brew services restart ollama`) to bring them into "
+ "sync, or fix whichever value is correct. (EnvironmentFile= "
+ "contents are not consulted by this check.)"
+ ),
+ details=[
+ f"static: {static_label}",
+ f"live: {live_value!r}",
+ "static and live disagree --- one of the two needs a refresh",
+ ],
+ )
+
+ if static_source is not None and live_value is None:
+ static_value, static_path, _static_kind = static_source
+ if live_probe_succeeded:
+ return Check(
+ "GPU path",
+ Status.WARN,
+ f"OLLAMA_NUM_GPU={static_value} set in {static_path.name} but "
+ "live daemon env lacks the override",
+ hint=(
+ f"`brew services restart ollama` (or the platform equivalent) "
+ f"to apply {static_path} to the running process. "
+ "(EnvironmentFile= contents are not consulted by this check.)"
+ ),
+ details=[
+ f"static: {static_label}",
+ "live: no override",
+ "static override not yet picked up by the running daemon",
+ ],
+ )
+ # Probe failed badly enough that we couldn't tell; degrade to a
+ # softer WARN without pretending we know the live state.
+ return Check(
+ "GPU path",
+ Status.WARN,
+ f"OLLAMA_NUM_GPU={static_value} --- inference is CPU-bound",
+ hint=(
+ "CPU-only inference is the standard workaround for the Apple "
+ "M5 / macOS Metal shader JIT hang. Remove OLLAMA_NUM_GPU from "
+ f"{static_path} once llama.cpp fixes the upstream bug. "
+ "Could not probe the live daemon env to confirm it picked "
+ "the override up; the value here reflects the file on disk. "
+ "(EnvironmentFile= contents are not consulted by this check.)"
+ ),
+ details=[
+ f"static: {static_label}",
+ "live probe: failed or unavailable",
+ ],
+ )
+
+ if static_source is None and live_value is not None:
+ # Transient override: live has it, no static config backs it.
+ return Check(
+ "GPU path",
+ Status.WARN,
+ f"live daemon env has OLLAMA_NUM_GPU={live_value} but no static config backs it",
+ hint=(
+ "This is consistent with a *transient* override: `launchctl "
+ "setenv OLLAMA_NUM_GPU=` (macOS) or a shell-launched `ollama "
+ "serve` (Linux). Transient overrides do not survive logout, "
+ "reboot, or service restart. Persist the setting in a plist "
+ "or systemd unit if you want it to stick. "
+ "(EnvironmentFile= contents are not consulted by this check.)"
+ ),
+ details=[
+ f"static: {static_label}",
+ f"live: {live_value!r}",
+ "transient override without a backing config file",
+ ],
+ )
+
+ # Both unset. Distinguish "we never found a config or live daemon" (INFO)
+ # from "config exists but does not set the key, and live also has no
+ # override" (PASS).
+ if not static_file_present and not live_probe_succeeded:
+ if last_err is not None:
+ return Check(
+ "GPU path",
+ Status.FAIL,
+ "could not probe any ollama daemon config path",
+ details=[last_err],
+ )
+ return Check(
+ "GPU path",
+ Status.INFO,
+ "no ollama daemon config detected; cannot verify OLLAMA_NUM_GPU override",
+ hint=(
+ "If you manage ollama manually with `OLLAMA_NUM_GPU` or via "
+ "EnvironmentFile=, the diagnostic won't see it."
+ ),
+ )
+
+ detail_lines = ["EnvironmentFile= is not read by this check; inline Environment= is."]
+ if static_source is not None:
+ detail_lines.insert(0, f"static: {static_label}")
+ if live_probe_succeeded:
+ detail_lines.append("live: no override")
+ else:
+ detail_lines.append("live probe: failed or unavailable")
+ return Check(
+ "GPU path",
+ Status.PASS,
+ (
+ f"default GPU acceleration ({static_path.name} has no OLLAMA_NUM_GPU; "
+ "live daemon env also unset)"
+ if static_path is not None
+ else (
+ "default GPU acceleration (no static config file found; live daemon env also unset)"
+ )
+ ),
+ details=detail_lines,
+ )
+
+
+def check_vector_store(_settings: Settings) -> Check:
+ try:
+ import lancedb # noqa: F401
+ except ImportError:
+ return Check(
+ "Vector store",
+ Status.WARN,
+ "LanceDB not installed; index will be in-memory and lost on restart",
+ hint="Install with: pip install 'cortex-brain[index]'",
+ )
+ return Check("Vector store", Status.PASS, "LanceDB installed")
+
+
+def check_mcp_wire(_settings: Settings) -> Check:
+ """Instantiate a Server without wiring it to a transport.
+
+ The MCP library version that ships with Cortex may not match the one an
+ end user installs; the *constructor API* is what we care about, not the
+ transport. Catching the crash here turns "MCP silently no-ops" into a loud
+ diagnostic.
+ """
+ try:
+ from mcp.server import Server
+ except ImportError:
+ return Check(
+ "MCP",
+ Status.INFO,
+ "mcp extra not installed",
+ hint="Install with: pip install 'cortex-brain[mcp]' if you want MCP integration",
+ )
+
+ # Real wire-format regressions are caught by what handlers the Server
+ # constructor accepts, not by whether ``Server("name")`` alone builds. We
+ # construct a Server with the on_list_tools / on_call_tool kwargs the
+ # actual cortex MCP server uses; if the installed mcp version has drifted
+ # far enough those kwargs are no longer accepted, doctor reports it before
+ # a downstream call_site does. Type the callables as ``Any`` deliberately:
+ # mypy's stubs demand precise ServerRequestContext and the concrete
+ # parameter types, but those are not what this check is exercising.
+ import typing as _typing
+
+ async def _list(_ctx: Any, _params: Any) -> Any: # pragma: no cover
+ return None
+
+ async def _call(_ctx: Any, _params: Any) -> Any: # pragma: no cover
+ return None
+
+ try:
+ Server(
+ "cortex-doctor",
+ on_list_tools=_typing.cast("Any", _list),
+ on_call_tool=_typing.cast("Any", _call),
+ )
+ except (TypeError, AttributeError, ValueError) as exc:
+ return Check(
+ "MCP",
+ Status.FAIL,
+ "MCP server constructor rejected the installed mcp version",
+ details=[f"{type(exc).__name__}: {exc}"],
+ )
+ return Check("MCP", Status.PASS, "server constructor accepts the installed mcp version")
+
+
+def check_rerank_extra(settings: Settings) -> Check:
+ if not getattr(settings, "rerank_enabled", False):
+ return Check("Rerank extra", Status.INFO, "rerank is disabled in config")
+ try:
+ import sentence_transformers # noqa: F401
+ except ImportError:
+ return Check(
+ "Rerank extra",
+ Status.WARN,
+ "rerank_enabled=true but sentence-transformers is missing",
+ hint="Install with: pip install 'cortex-brain[rerank]'",
+ )
+ return Check("Rerank extra", Status.PASS, "sentence-transformers installed")
+
+
+def check_documents_extra(settings: Settings) -> Check:
+ if not getattr(settings, "ingest_documents", False):
+ return Check("Documents extra", Status.INFO, "ingest_documents is disabled")
+ try:
+ import pymupdf4llm # noqa: F401
+ except ImportError:
+ return Check(
+ "Documents extra",
+ Status.WARN,
+ "ingest_documents=true but pymupdf4llm is missing",
+ hint="Install with: pip install 'cortex-brain[documents]'",
+ )
+ return Check("Documents extra", Status.PASS, "pymupdf4llm installed")
+
+
+def check_daemon(_settings: Settings) -> Check:
+ plist = Path("/Library/LaunchDaemons/com.jeevesh.cortex.plist")
+ if plist.exists():
+ return Check("Daemon", Status.PASS, f"installed at {plist}")
+ return Check(
+ "Daemon",
+ Status.WARN,
+ "background watcher not installed (indexing only happens when you run `cortex index`)",
+ hint="Install with: ./scripts/install-daemon.sh",
+ )
+
+
+def check_privacy(settings: Settings) -> Check:
+ """List eligible providers AND refused ones with their reason.
+
+ Showing the refused list is the whole point of ADR-0004: a trained-on-data
+ provider sitting next to a no-train one would still be silently downgraded
+ by a naive router, and surfacing *that* here is what gives the user a
+ way to fix it.
+ """
+ providers = build_chat_providers(settings.providers)
+ router = Router(providers)
+ explained = router.explain(Sensitivity.PRIVATE)
+
+ eligible = sorted(p for p, verdict in explained.items() if verdict.startswith("eligible"))
+ refused = sorted(f"{p}: {verdict}" for p, verdict in explained.items() if p not in eligible)
+
+ if not eligible:
+ return Check(
+ "Privacy",
+ Status.FAIL,
+ "no provider eligible for PRIVATE content",
+ hint="Set an API key for a no-training provider, or mark notes public in frontmatter",
+ details=refused,
+ )
+
+ detail_lines = [f"eligible: {', '.join(eligible)}"]
+ detail_lines.extend(f" - {r}" for r in refused)
+ return Check(
+ "Privacy",
+ Status.PASS,
+ f"{len(eligible)} provider{'s' if len(eligible) != 1 else ''} eligible for private",
+ details=detail_lines,
+ )
+
+
+def check_memory(settings: Settings) -> Check:
+ if not settings.memory_enabled:
+ return Check("Memory folder", Status.INFO, "memory feature is disabled in config")
+ if not settings.vault_path.exists():
+ return Check(
+ "Memory folder",
+ Status.INFO,
+ "skipped (vault path does not exist)",
+ )
+ mem = settings.vault_path / settings.memory_folder
+ try:
+ mem.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ return Check(
+ "Memory folder",
+ Status.FAIL,
+ f"cannot create {mem}",
+ details=[f"OSError: {exc}"],
+ )
+ # Round-trip a write inside the folder so a read-only mount surfaces as a
+ # check failure, not a runtime error three commands later.
+ probe = mem / ".cortex-doctor-probe"
+ try:
+ probe.write_text("ok", encoding="utf-8")
+ probe.unlink()
+ except OSError as exc:
+ return Check(
+ "Memory folder",
+ Status.FAIL,
+ f"cannot write inside {mem}",
+ details=[f"OSError: {exc}"],
+ )
+ return Check("Memory folder", Status.PASS, f"writable at {mem}")
+
+
+# ---------------------------------------------------------------------------
+# orchestrator
+# ---------------------------------------------------------------------------
+
+
+_CHECKS = (
+ check_config,
+ check_vault,
+ check_ollama,
+ check_gpu_path,
+ check_inference_path,
+ check_vector_store,
+ check_privacy,
+ check_mcp_wire,
+ check_rerank_extra,
+ check_documents_extra,
+ check_daemon,
+ check_memory,
+)
+
+
+def run_doctor(settings: Settings) -> DoctorReport:
+ """Run every check against *settings* and never raise.
+
+ A single network glitch or filesystem oddity must not abort the whole
+ diagnostic: failed checks are exactly the *signal* the user came for. The
+ swallow-and-record pattern here is the entire reason this function exists
+ in addition to the individual check helpers.
+ """
+ results: list[Check] = []
+ for check_fn in _CHECKS:
+ try:
+ results.append(check_fn(settings))
+ except Exception as exc:
+ # The doctor MUST NOT crash on a single bad check -- a glitching
+ # subprocess is exactly the signal the user came here for.
+ logger.debug("doctor check %s crashed", check_fn.__name__, exc_info=True)
+ results.append(
+ Check(
+ name=check_fn.__name__.removeprefix("check_").replace("_", " ").title(),
+ status=Status.FAIL,
+ message="check raised an unexpected exception",
+ details=[f"{type(exc).__name__}: {exc}"],
+ )
+ )
+ return DoctorReport(checks=results)
diff --git a/src/cortex/llm/protocol.py b/src/cortex/llm/protocol.py
index 556fba2..9a91cb8 100644
--- a/src/cortex/llm/protocol.py
+++ b/src/cortex/llm/protocol.py
@@ -154,6 +154,9 @@ class ChatProvider(Protocol):
spec: ProviderSpec
+ @property
+ def configured(self) -> bool: ...
+
def complete(
self,
messages: list[ChatMessage],
diff --git a/src/cortex/mcp_server.py b/src/cortex/mcp_server.py
index faebd02..203927a 100644
--- a/src/cortex/mcp_server.py
+++ b/src/cortex/mcp_server.py
@@ -255,13 +255,20 @@ def list_links(self, note_id: str) -> dict[str, Any]:
}
def vault_status(self) -> dict[str, Any]:
- self.rt.governor.sample(force=True)
+ # In-memory runtimes (test / CI) carry no live governor. Surface that
+ # honestly rather than crashing on a None attr -- the schema stays
+ # stable for callers regardless of runtime mode.
+ if self.rt.governor is not None:
+ self.rt.governor.sample(force=True)
+ thermal = self.rt.governor.describe()
+ else:
+ thermal = {"state": "unavailable", "reason": "in_memory runtime"}
counts = self.rt.catalog.stats()
return {
"vault_path": str(self.rt.settings.vault_path),
"notes_indexed": counts["notes"],
"chunks": counts["chunks"],
- "thermal": self.rt.governor.describe(),
+ "thermal": thermal,
"provider_routing_private": self.rt.router.explain(Sensitivity.PRIVATE),
}
@@ -350,34 +357,63 @@ def dispatch(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
def build_server(tools: CortexTools) -> Any:
- """Construct the MCP server. Requires the optional ``mcp`` extra."""
+ """Construct the MCP server. Requires the optional ``mcp`` extra.
+
+ The modern ``mcp`` library registers tool handlers via constructor
+ arguments (``on_list_tools``, ``on_call_tool``) rather than decorators.
+ Older ``Server.list_tools`` / ``Server.call_tool`` methods no longer
+ exist, so building the server via decorators would silently no-op.
+ """
try:
- from mcp.server import Server
- from mcp.types import TextContent, Tool
+ from mcp.server import Server, ServerRequestContext
+ from mcp.types import (
+ CallToolRequestParams,
+ CallToolResult,
+ ListToolsResult,
+ PaginatedRequestParams,
+ TextContent,
+ Tool,
+ )
except ImportError as exc: # pragma: no cover
raise ImportError(
"MCP support is not installed. Install with: pip install 'cortex-brain[mcp]'"
) from exc
- server = Server("cortex")
-
- @server.list_tools() # type: ignore[untyped-decorator]
- async def _list_tools() -> list[Tool]:
- return [
- Tool(
- name=spec["name"],
- description=spec["description"],
- inputSchema=spec["input_schema"],
+ async def _list_tools(
+ _ctx: ServerRequestContext[object, object],
+ _params: PaginatedRequestParams | None,
+ ) -> ListToolsResult:
+ # The on-the-wire Tool field is ``inputSchema`` (camelCase), but the
+ # pydantic stub mypy enforces declares it snake_case. Instantiation
+ # through ``model_validate`` accepts the wire spelling via the alias
+ # generator and satisfies the stub simultaneously, so we avoid the
+ # direct constructor's typing demerits over list-dict values.
+ tools_list: list[Tool] = [
+ Tool.model_validate(
+ {
+ "name": spec["name"],
+ "description": spec["description"],
+ "inputSchema": spec["input_schema"],
+ }
)
for spec in TOOL_DEFINITIONS
]
-
- @server.call_tool() # type: ignore[untyped-decorator]
- async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
- payload = tools.dispatch(name, arguments or {})
- return [TextContent(type="text", text=json.dumps(payload, indent=2, default=str))]
-
- return server
+ return ListToolsResult(tools=tools_list)
+
+ async def _call_tool(
+ _ctx: ServerRequestContext[object, object],
+ params: CallToolRequestParams,
+ ) -> CallToolResult:
+ payload = tools.dispatch(params.name, dict(params.arguments or {}))
+ return CallToolResult(
+ content=[TextContent(type="text", text=json.dumps(payload, indent=2, default=str))]
+ )
+
+ return Server(
+ "cortex",
+ on_list_tools=_list_tools,
+ on_call_tool=_call_tool,
+ )
def run_stdio(
diff --git a/src/cortex/retrieve/graph.py b/src/cortex/retrieve/graph.py
index 37bf877..175b72b 100644
--- a/src/cortex/retrieve/graph.py
+++ b/src/cortex/retrieve/graph.py
@@ -59,6 +59,12 @@ class LinkGraph:
_alias: dict[str, str] = field(default_factory=dict)
"""Maps a normalised link target to the note_id that resolves it."""
+ titles: dict[str, str] = field(default_factory=dict)
+ """``note_id -> display title``. Populated at build time so the graph view
+ on the UI does not have to walk the catalog for every label. Costs ~30
+ bytes per note (the rel_path is shorter than the title for most notes),
+ so it does not justify a lazy-resolution cache."""
+
tags: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
"""tag -> note_ids carrying it."""
@@ -71,6 +77,10 @@ def from_notes(cls, notes: Iterable[Note]) -> LinkGraph:
for note in notes:
graph._alias[_normalise(note.rel_path)] = note.note_id
graph._alias.setdefault(_normalise(note.title), note.note_id)
+ # Titles take priority over rel_paths for human-facing display, so
+ # we record them keyed by note_id; the rel_path is still useful as
+ # a fallback when no frontmatter title was set (then title == stem).
+ graph.titles.setdefault(note.note_id, note.title)
# Pass 2: resolve links. Unresolvable targets are dropped rather than
# creating phantom nodes -- Obsidian calls these "unresolved links" and
diff --git a/src/cortex/runtime.py b/src/cortex/runtime.py
index 3a0ee0e..ce9d984 100644
--- a/src/cortex/runtime.py
+++ b/src/cortex/runtime.py
@@ -31,13 +31,19 @@
@dataclass(slots=True)
class Runtime:
- """A fully wired Cortex instance."""
+ """A fully wired Cortex instance.
+
+ ``governor`` is None only when the runtime was built in-memory (test/CI
+ scaffolding). Production runtimes always carry a live ThermalGovernor so
+ interactive queries stay fast while background indexing yields when the
+ machine is hot or on low battery.
+ """
settings: Settings
catalog: Catalog
store: VectorStore
embedder: EmbeddingProvider
- governor: ThermalGovernor
+ governor: ThermalGovernor | None
router: Router
pipeline: IndexPipeline
graph: LinkGraph | None = None
@@ -124,7 +130,17 @@ def build_runtime(
catalog = Catalog(":memory:" if in_memory else settings.db_path)
store = MemoryStore() if in_memory else _build_store(settings)
embedder = _build_embedder(settings, offline=offline)
- governor = ThermalGovernor(probe=default_probe(), config=settings.governor)
+ # In-memory runtimes are test/CI scaffolding and must not depend on the
+ # developer's real battery or chassis state. The pipeline-level gate would
+ # otherwise refuse to index on a MacBook that ``pmset`` reports as
+ # throttling or on battery below ``min_battery_for_backfill`` -- which is
+ # exactly what happens on a developer laptop during a long test run. We
+ # therefore skip the governor entirely in test mode (None propagates into
+ # ``IndexPipeline``, whose ``respect_thermal`` gate becomes a no-op) and
+ # leave status display to surface that fact explicitly.
+ governor = (
+ ThermalGovernor(probe=default_probe(), config=settings.governor) if not in_memory else None
+ )
providers = build_chat_providers(settings.providers)
router = Router(providers)
diff --git a/src/cortex/server/__init__.py b/src/cortex/server/__init__.py
new file mode 100644
index 0000000..f331450
--- /dev/null
+++ b/src/cortex/server/__init__.py
@@ -0,0 +1,19 @@
+"""HTTP surface for the Cortex local web app.
+
+Mirror of :mod:`cortex.mcp_server` for a human-facing UI rather than an LLM
+agent. Same Runtime, same privacy gate, same routing decisions -- the only
+difference is the wire format (REST + SSE rather than JSON-RPC MCP) and the
+shape of the responses (linear, ordered, friendly to a chat pane rather than
+the formal tool call MCP expects).
+
+The package is intentionally thin. Tool implementations live next to the
+existing MCP tool handlers on :class:`cortex.runtime.Runtime`; the routers here
+are adapters that translate HTTP requests into those calls and shape the
+results for streaming.
+"""
+
+from __future__ import annotations
+
+from cortex.server.app import create_app
+
+__all__ = ["create_app"]
diff --git a/src/cortex/server/app.py b/src/cortex/server/app.py
new file mode 100644
index 0000000..0aab407
--- /dev/null
+++ b/src/cortex/server/app.py
@@ -0,0 +1,121 @@
+"""FastAPI application factory.
+
+A factory (``create_app``) rather than a module-level ``app`` so tests can
+build their own app with overrides -- the ``set_runtime`` hook in
+:mod:`cortex.server.dependencies` is the seam.
+
+The bundled Vite production build is mounted at ``/`` if it is present, so a
+single ``cortex serve`` starts the API and the UI in one process. When the
+build is missing (e.g. on first install with no frontend/ build yet), the API
+runs alone and the SPA is reached via the Vite dev server.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, JSONResponse, Response
+from fastapi.staticfiles import StaticFiles
+
+from cortex.config import Settings
+from cortex.server import routers
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_FRONTEND_DIST = Path(__file__).resolve().parents[3] / "frontend" / "dist"
+
+
+def create_app(settings: Settings | None = None) -> FastAPI:
+ """Build the FastAPI app, optionally mounting a built SPA on ``/``."""
+ from cortex.server.dependencies import get_runtime
+
+ app = FastAPI(
+ title="Cortex",
+ version="0.1.0",
+ description="Local-first second brain. Privacy-tiered RAG over your Obsidian vault.",
+ docs_url="/api/docs",
+ redoc_url=None,
+ openapi_url="/api/openapi.json",
+ )
+
+ _settings = settings or get_runtime().settings
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=list(_settings.serve.cors_origins),
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ expose_headers=["*"],
+ )
+
+ # Routers. Each one owns its prefix; together they form the public API.
+ app.include_router(routers.chat.router)
+ app.include_router(routers.search.router)
+ app.include_router(routers.system.router)
+ app.include_router(routers.memory.router)
+
+ @app.get("/api/whoami")
+ async def whoami() -> dict[str, object]:
+ """Single-shot snapshot of who I am talking to -- the UI's startup probe."""
+ from cortex.server.dependencies import get_runtime
+
+ rt = get_runtime()
+ return {
+ "vault_name": rt.settings.display_vault_name,
+ "vault_path": str(rt.settings.vault_path),
+ "providers": [
+ {
+ "name": p.spec.name,
+ "model": p.spec.model,
+ "policy": p.spec.policy.value,
+ "configured": p.configured,
+ }
+ for p in rt.router.providers
+ ],
+ "memory_enabled": bool(rt.memory is not None and rt.memory.enabled),
+ "local_only": rt.settings.local_only,
+ }
+
+ _mount_frontend(app, _settings)
+
+ return app
+
+
+def _mount_frontend(app: FastAPI, settings: Settings) -> None:
+ """Mount the Vite production build at ``/`` if the directory exists.
+
+ The mount deliberately happens after the API routers are registered so
+ /api/* is always served by the FastAPI app -- a missing SPA build must
+ never 404 an API call.
+ """
+ dist = DEFAULT_FRONTEND_DIST
+ if not dist.exists() or not (dist / "index.html").exists():
+ logger.info(
+ "no frontend/dist; the web UI is not bundled. Run it from frontend/ via npm run dev."
+ )
+ return
+
+ assets = dist / "assets"
+ if assets.exists():
+ # ``html=False`` here so a request for /assets/foo.js doesn't fall
+ # through to index.html with an SPA fallback -- we *want* a 404 when a
+ # hashed bundle is actually missing.
+ app.mount("/assets", StaticFiles(directory=str(assets), html=False), name="static")
+
+ @app.get("/", include_in_schema=False, response_model=None)
+ async def root_index() -> Response:
+ return FileResponse(str(dist / "index.html"))
+
+ @app.get("/{path:path}", include_in_schema=False, response_model=None)
+ async def spa_fallback(path: str) -> Response:
+ # Don't shadow the API: /api/* and /assets/* are already routed.
+ if path.startswith("api/") or path.startswith("assets/"):
+ return JSONResponse({"error": "not_found", "path": path}, status_code=404)
+ file_path = dist / path
+ if file_path.is_file():
+ return FileResponse(str(file_path))
+ return FileResponse(str(dist / "index.html"))
diff --git a/src/cortex/server/dependencies.py b/src/cortex/server/dependencies.py
new file mode 100644
index 0000000..9219f86
--- /dev/null
+++ b/src/cortex/server/dependencies.py
@@ -0,0 +1,88 @@
+"""Single shared ``Runtime`` per process, lazily built from settings.
+
+FastAPI lets us declare request handlers with type hints and resolve them via
+``Depends``. Putting a single Runtime behind a singleton here means the
+heavyweight graphs, embedders and reranker weights are reused across requests
+without each one paying the cold-start cost -- which is most of the budget on
+a fanless Air.
+
+Test fixtures pass their own Runtime through :func:`set_runtime`, so the
+production singleton never leaks into tests.
+"""
+
+from __future__ import annotations
+
+import threading
+from pathlib import Path
+from typing import Any
+
+from cortex.config import Settings, load_settings
+from cortex.runtime import Runtime, build_runtime
+
+__all__ = ["get_runtime", "reset_runtime", "set_runtime"]
+
+_lock = threading.Lock()
+_runtime: Runtime | None = None
+_settings: Settings | None = None
+
+
+def set_runtime(runtime: Runtime | None) -> None:
+ """Override the shared runtime. Tests and ``cortex serve --reload`` use this."""
+ global _runtime, _settings
+ with _lock:
+ _runtime = runtime
+ _settings = runtime.settings if runtime is not None else None
+
+
+def reset_runtime() -> None:
+ set_runtime(None)
+
+
+def _build_default_runtime() -> Runtime:
+ """Build a long-lived Runtime from the default config path.
+
+ Called once on the first request; subsequent requests reuse it.
+ """
+ settings = load_settings()
+ return build_runtime(settings=settings, offline=False, in_memory=False)
+
+
+def get_runtime() -> Runtime:
+ """Resolve the Runtime for the current request.
+
+ Built lazily so importing this module has no cost beyond a thread lock
+ -- the heaviest objects (embedder + reranker + Ollama connection) only
+ come into existence on first use.
+ """
+ global _runtime
+ if _runtime is not None:
+ return _runtime
+ with _lock:
+ if _runtime is None:
+ _runtime = _build_default_runtime()
+ return _runtime
+
+
+def current_settings() -> Settings:
+ return get_runtime().settings
+
+
+def resolve_vault(vault: str | None) -> Path:
+ """Validate a ``--vault`` override path. Used by CLI/server entrypoints."""
+ from cortex.cli import err_console # local to avoid heavy Rich dep at import
+
+ settings = current_settings()
+ if vault is None:
+ return settings.vault_path
+ candidate = Path(vault).expanduser()
+ if not candidate.exists():
+ err_console.print(f"[red]Vault not found:[/red] {candidate}")
+ raise ValueError(candidate)
+ return candidate
+
+
+def app_state_payload() -> dict[str, Any]:
+ return {
+ "vault_path": str(current_settings().vault_path),
+ "vault_name": current_settings().display_vault_name,
+ }
diff --git a/src/cortex/server/routers/__init__.py b/src/cortex/server/routers/__init__.py
new file mode 100644
index 0000000..7e0c90f
--- /dev/null
+++ b/src/cortex/server/routers/__init__.py
@@ -0,0 +1,10 @@
+"""HTTP routers, one per surface area.
+
+Grouped so a contributor adding, say, a new ``/api/recipes`` endpoint knows
+exactly where it goes without reading every router file. Each module owns its
+own schema variations and middleware (CORS is applied globally in app.py).
+"""
+
+from cortex.server.routers import chat, memory, search, system
+
+__all__ = ["chat", "memory", "search", "system"]
diff --git a/src/cortex/server/routers/chat.py b/src/cortex/server/routers/chat.py
new file mode 100644
index 0000000..f6010ba
--- /dev/null
+++ b/src/cortex/server/routers/chat.py
@@ -0,0 +1,360 @@
+"""Streaming chat endpoint.
+
+The wire format is Server-Sent Events. Each event has ``event: `` and
+``data: `` -- the EventSource protocol gives the browser ``event.type``
+for free, so a TypeScript switch handles dispatch with no parsing.
+
+The flow for a single chat turn::
+
+ provider -> {name, escalated, model} # before any text is generated
+ retrieval -> {query, elapsed_ms, date, count}
+ citation -> {index, note_id, uri, title} # once per chunk used
+ text -> {delta: "..."} # micro-chunks of the answer
+ done -> {provider, escalated, ms, ...} # final stats
+
+Errors mid-stream surface as ``event: error`` so the client can show them in
+the chat scroll rather than dropping the connection.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+
+from fastapi import APIRouter, Request
+from fastapi.responses import StreamingResponse
+
+from cortex.llm.protocol import PolicyViolation, ProviderError
+from cortex.memory import MemoryNote
+from cortex.models import obsidian_uri
+from cortex.server.dependencies import get_runtime
+from cortex.server.schemas import (
+ ChatRequest,
+ SSEEvent,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["chat"])
+
+
+@dataclass(slots=True)
+class _StreamContext:
+ request: ChatRequest
+ local_only: bool
+ runtime: object # Runtime, kept loose to import cheaply
+
+
+def _sse(event: SSEEvent) -> bytes:
+ return event.encode()
+
+
+async def _stream_chat(req: ChatRequest, signal: object | None = None) -> AsyncIterator[bytes]:
+ rt = get_runtime()
+ engine = rt.engine()
+
+ last_user = next((msg for msg in reversed(req.messages) if msg.role == "user"), None)
+ if last_user is None:
+ yield _sse(SSEEvent(type="error", data={"message": "no user message"}))
+ return
+
+ query = last_user.content.strip()
+ if not query:
+ yield _sse(SSEEvent(type="error", data={"message": "empty query"}))
+ return
+
+ rt.refresh_graph()
+
+ # Provider decision first -- so the UI can show "thinking with Groq" before
+ # retrieval results arrive.
+ # Send a "deciding" provider frame first so the UI can render the spinner
+ # on the provider chip without knowing which provider yet -- that frame
+ # is replaced by a real one after the synthesis call chooses the actual
+ # chain. Sending the final provider name up front would flicker: the
+ # engine short-circuits to provider="none" when retrieval produces zero
+ # chunks, which would otherwise look like a routing downgrade.
+ yielding_for_chrome = False or rt.settings.local_only or req.local_only
+ yield _sse(
+ SSEEvent(
+ type="provider",
+ data={
+ "deciding": True,
+ "local_only_enforced": yielding_for_chrome,
+ },
+ )
+ )
+
+ try:
+ result = engine.retrieve(query, top_k=req.top_k)
+ except Exception as exc: # retrieval itself failed
+ logger.exception("retrieval failed")
+ yield _sse(SSEEvent(type="error", data={"message": f"retrieval failed: {exc}"}))
+ return
+
+ yield _sse(
+ SSEEvent(
+ type="retrieval",
+ data={
+ "query": query,
+ "elapsed_ms": round(result.elapsed_ms, 1),
+ "retrievers": result.per_retriever,
+ "date_filter": str(result.date_range) if result.date_range else None,
+ "reranked": result.reranked,
+ "matched": len(result.chunks),
+ },
+ )
+ )
+
+ vault_name = rt.settings.display_vault_name
+ for i, scored in enumerate(result.chunks, start=1):
+ yield _sse(
+ SSEEvent(
+ type="citation",
+ data={
+ "index": i,
+ "note_id": scored.chunk.note_id,
+ "obsidian_uri": obsidian_uri(scored.chunk.note_id, vault_name),
+ "title": scored.chunk.citation,
+ "snippet": scored.chunk.text[:280].replace("\n", " "),
+ "score": round(scored.score, 5),
+ "tags": sorted(scored.chunk.tags),
+ },
+ )
+ )
+
+ if not result.chunks:
+ yield _sse(
+ SSEEvent(
+ type="text",
+ data={"delta": "Nothing in your notes matches that yet."},
+ )
+ )
+ yield _sse(
+ SSEEvent(
+ type="done",
+ data={
+ "provider": "none",
+ "escalated": False,
+ "elapsed_ms": round(result.elapsed_ms, 1),
+ "answer": "Nothing in your notes matches that yet.",
+ },
+ )
+ )
+ return
+
+ try:
+ from cortex.llm.protocol import ChatMessage
+
+ messages = [ChatMessage(role=m.role, content=m.content) for m in req.messages if m.content]
+ if not messages or messages[-1].role != "user":
+ messages = [*messages, ChatMessage(role="user", content=query)]
+
+ # We synthesise via the router (non-streaming), then chunk the text out
+ # as a controlled token stream. Each provider supports streaming over
+ # OpenAI-compat, but the chunking semantics differ enough (token
+ # boundaries, finish_reason probing) that faking a smooth stream from a
+ # finished completion is simpler and works for every backend in the
+ # shipped chain. The chunk size below is a balance between perceived
+ # smoothness and per-event SSE overhead.
+ chunks = [scored.chunk for scored in result.chunks]
+ sensitivity = engine.effective_sensitivity(chunks)
+
+ # Build the prompt via the engine (same source of truth as the MCP path)
+ from cortex.retrieve.engine import RetrievalEngine
+
+ prompt_messages = RetrievalEngine.build_prompt(query, chunks)
+ # Replace the last user-role message with the *full* conversation if the
+ # chat history has more than one turn -- otherwise the router sees only
+ # the bare question and forgets the prior turns.
+ if len(messages) > 1:
+ prompt_messages = [
+ ChatMessage(role="system", content=prompt_messages[0].content),
+ *[
+ ChatMessage(role=m.role, content=m.content)
+ for m in messages[1:]
+ if m.role in {"user", "assistant"}
+ ][-8:], # cap turns so the prompt can't grow unbounded
+ ]
+
+ completion, decision = rt.router.complete(
+ prompt_messages,
+ sensitivity=sensitivity,
+ max_tokens=900,
+ local_only=req.local_only or rt.settings.local_only,
+ temperature=0.2,
+ )
+ except PolicyViolation as exc:
+ yield _sse(SSEEvent(type="error", data={"type": "privacy_policy", "message": str(exc)}))
+ return
+ except ProviderError as exc:
+ err_payload = {"type": "provider_unavailable", "message": str(exc)}
+ yield _sse(SSEEvent(type="error", data=err_payload))
+ return
+ except Exception as exc: # last-resort guard around the synthesis call
+ logger.exception("synthesis failed")
+ yield _sse(SSEEvent(type="error", data={"type": "internal", "message": str(exc)}))
+ return
+
+ # Pull the actual policy off the spec that served this turn -- the
+ # RoutingDecision records *what happened* (provider, escalated), not the
+ # policy string the UI wants to display.
+ policy_value = ""
+ for provider in rt.router.providers:
+ if provider.spec.name == decision.provider:
+ policy_value = provider.spec.policy.value
+ break
+
+ yield _sse(
+ SSEEvent(
+ type="provider",
+ data={
+ "name": decision.provider,
+ "model": completion.model,
+ "escalated": decision.escalated,
+ "policy": policy_value,
+ "elapsed_ms": round(completion.elapsed_ms, 1),
+ },
+ )
+ )
+
+ # Micro-chunk the answer text. Roughly four-character deltas: small enough
+ # to feel live, large enough that SSE overhead stays under 1% of bandwidth.
+ full_text = completion.text.strip()
+ if not full_text:
+ full_text = "(empty response)"
+
+ step = 4
+ sent_index = 0
+ while sent_index < len(full_text):
+ # Yield a tiny slice, surrender the event loop so the client can flush.
+ yield _sse(SSEEvent(type="text", data={"delta": full_text[sent_index : sent_index + step]}))
+ sent_index += step
+ # ``await asyncio.sleep(0)`` yields control to the loop so the SSE
+ # packet can actually flush before the next slice lands.
+ await asyncio.sleep(0)
+ # Defense-in-depth: if the client disconnected mid-stream, stop
+ # emitting rather than throwing on a closed socket.
+ if signal and getattr(signal, "aborted", False):
+ return
+
+ yield _sse(
+ SSEEvent(
+ type="done",
+ data={
+ "provider": decision.provider,
+ "model": completion.model,
+ "escalated": decision.escalated,
+ "policy": policy_value,
+ "elapsed_ms": round(completion.elapsed_ms, 1),
+ "answer": full_text,
+ "total_elapsed_ms": round(completion.elapsed_ms + result.elapsed_ms, 1),
+ },
+ )
+ )
+
+ if req.remember and rt.memory is not None and rt.memory.enabled:
+ try:
+ written = rt.memory.write(
+ MemoryNote(
+ question=query,
+ answer=full_text,
+ sources=[c.note_id for c in chunks],
+ tags=["api"],
+ provider=decision.provider,
+ )
+ )
+ except (OSError, ValueError) as exc:
+ yield _sse(
+ SSEEvent(
+ type="error",
+ data={"type": "memory_failed", "message": str(exc)},
+ )
+ )
+ else:
+ if written is not None:
+ rel = written.relative_to(rt.settings.vault_path).as_posix()
+ yield _sse(
+ SSEEvent(
+ type="memory",
+ data={
+ "saved": rel,
+ "obsidian_uri": obsidian_uri(rel, vault_name),
+ },
+ )
+ )
+
+
+@router.post("/chat")
+async def chat(req: ChatRequest, request: Request) -> StreamingResponse:
+ """Stream a chat turn as Server-Sent Events."""
+ # FastAPI gives us the Request object so the streaming generator can
+ # call ``is_disconnected()`` between text micro-chunks. We pass it down
+ # rather than re-invent an abort signal of our own.
+ return StreamingResponse(
+ _stream_chat(req, signal=request),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache, no-transform",
+ "X-Accel-Buffering": "no", # disable buffering on common reverse proxies
+ },
+ )
+
+
+@router.post("/ask")
+async def ask(req: ChatRequest) -> dict[str, object]:
+ """Non-streaming variant of /chat for clients that can't read SSE.
+
+ Same logic, returns the assembled payload in one JSON response. Cheap
+ escape hatch: a CLI one-liner or a mobile app without EventSource support
+ can still drive the chat pipeline.
+ """
+ rt = get_runtime()
+ engine = rt.engine()
+
+ last_user = next((m for m in reversed(req.messages) if m.role == "user"), None)
+ if last_user is None:
+ return {"error": "no user message"}
+
+ query = last_user.content.strip()
+ if not query:
+ return {"error": "empty query"}
+
+ rt.refresh_graph()
+ try:
+ answer_obj = engine.ask(
+ query,
+ top_k=req.top_k,
+ local_only=req.local_only or rt.settings.local_only,
+ )
+ except PolicyViolation as exc:
+ return {"error": "privacy_policy", "message": str(exc)}
+ except ProviderError as exc:
+ return {"error": "provider_unavailable", "message": str(exc)}
+
+ vault_name = rt.settings.display_vault_name
+ citations = [
+ {
+ "index": i,
+ "note_id": chunk.note_id,
+ "obsidian_uri": obsidian_uri(chunk.note_id, vault_name),
+ "title": chunk.citation,
+ }
+ for i, chunk in enumerate(answer_obj.citations, start=1)
+ ]
+ return {
+ "answer": answer_obj.text,
+ "provider": answer_obj.provider,
+ "escalated": answer_obj.escalated,
+ "elapsed_ms": round(answer_obj.elapsed_ms, 1),
+ "citations": citations,
+ }
+
+
+# A trivial status probe for clients negotiating the protocol version before
+# opening an EventSource. Returns {"ok": true} immediately -- the SSE handlers
+# do the heavy lifting elsewhere.
+@router.get("/chat/health")
+async def chat_health() -> dict[str, bool]:
+ return {"ok": True}
diff --git a/src/cortex/server/routers/memory.py b/src/cortex/server/routers/memory.py
new file mode 100644
index 0000000..d187917
--- /dev/null
+++ b/src/cortex/server/routers/memory.py
@@ -0,0 +1,122 @@
+"""Memory endpoints.
+
+The memory folder is where "what the system has decided to remember" lives --
+Markdown notes in the vault, wikilinked to the sources they drew on, auditable
+in Obsidian. The MCP server already exposes ``remember``; the HTTP variant adds
+a list endpoint so the frontend can render the recent-memory strip without
+having to shell out to the CLI.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException
+
+from cortex.memory import MemoryNote
+from cortex.models import obsidian_uri
+from cortex.server.dependencies import get_runtime
+from cortex.server.schemas import MemoryNoteOut, RememberRequest
+
+router = APIRouter(prefix="/api", tags=["memory"])
+
+
+@router.post("/memory")
+async def remember(req: RememberRequest) -> dict[str, object]:
+ rt = get_runtime()
+ if rt.memory is None or not rt.memory.enabled:
+ raise HTTPException(status_code=409, detail="memory disabled in config")
+
+ try:
+ written = rt.memory.write(
+ MemoryNote(
+ question=req.question,
+ answer=req.answer,
+ sources=list(req.sources),
+ tags=list(req.tags) or ["api"],
+ provider=req.provider,
+ )
+ )
+ except (OSError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ if written is None:
+ return {"saved": None, "ok": True, "note": "nothing to write"}
+
+ rel = written.relative_to(rt.settings.vault_path).as_posix()
+ return {
+ "saved": rel,
+ "obsidian_uri": obsidian_uri(rel, rt.settings.display_vault_name),
+ "total": rt.memory.count(),
+ "ok": True,
+ }
+
+
+@router.get("/memory/recent", response_model=list[MemoryNoteOut])
+async def memory_recent(limit: int = 12) -> list[MemoryNoteOut]:
+ rt = get_runtime()
+ if rt.memory is None or not rt.memory.enabled:
+ return []
+
+ root = rt.memory.root
+ if not root.exists():
+ return []
+
+ notes = sorted(root.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]
+ out: list[MemoryNoteOut] = []
+ for path in notes:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ # Pull the title, the question, the answer out of the rendered memory
+ # note without dragging a full Markdown parser into the request path.
+ title = path.stem
+ created = None
+ question = ""
+ answer = ""
+ sources: list[str] = []
+ for line in text.splitlines():
+ if line.startswith("title:"):
+ title = line.split(":", 1)[1].strip().strip("\"'")
+ elif line.startswith("created:"):
+ try:
+ created = datetime.fromisoformat(line.split(":", 1)[1].strip())
+ except ValueError:
+ continue
+ elif line.startswith("- [[") and line.endswith("]]"):
+ sources.append(line[4:-2].strip())
+
+ sections = text.split("## ", 1)
+ if len(sections) == 2:
+ rest = sections[1]
+ # Best-effort -- the schema is ours, so the two sections exist.
+ for chunk in rest.split("## "):
+ head, _, body = chunk.partition("\n")
+ if head.strip() == "Asked":
+ question = body.strip()
+ elif head.strip() == "Answered":
+ answer = body.strip()
+
+ rel = path.relative_to(rt.settings.vault_path).as_posix()
+ # Fall back to mtime when frontmatter didn't carry `created:`, which is
+ # the older persistence shape.
+ if created is None:
+ created = datetime.fromtimestamp(path.stat().st_mtime)
+ out.append(
+ MemoryNoteOut(
+ path=rel,
+ title=title,
+ created=created,
+ question=question,
+ answer=answer,
+ sources=sources,
+ obsidian_uri=obsidian_uri(rel, rt.settings.display_vault_name),
+ )
+ )
+ return out
+
+
+@router.get("/memory/count")
+async def memory_count() -> dict[str, int]:
+ rt = get_runtime()
+ if rt.memory is None or not rt.memory.enabled:
+ return {"count": 0, "enabled": False}
+ return {"count": rt.memory.count(), "enabled": True}
diff --git a/src/cortex/server/routers/search.py b/src/cortex/server/routers/search.py
new file mode 100644
index 0000000..1300714
--- /dev/null
+++ b/src/cortex/server/routers/search.py
@@ -0,0 +1,219 @@
+"""Search and link-graph endpoints.
+
+``/api/search`` is the same hybrid retrieval the CLI's ``search`` command
+returns, but serialised as JSON. Used by the frontend's vault sidebar for raw
+browse -- chat is the natural UI for grounded Q&A, but sometimes you'll want
+to see a ranked list of the eight best passages without asking anything.
+
+``/api/links/{note_id}`` exposes the wikilink graph forward/back edges. The
+UI uses this for the "linked from" hint under each citation.
+
+``/api/graph`` returns a curated subgraph sized for the sidebar's radial
+view: top-``limit`` nodes by degree (or BFS-subgraph around ``center``),
+plus the edges that connect them, plus enough metadata to centre the radial
+layout and colour the outer ring by tag.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from pathlib import PurePosixPath
+
+from fastapi import APIRouter, Query
+
+from cortex.models import obsidian_uri
+from cortex.server.dependencies import get_runtime
+from cortex.server.schemas import (
+ CitationOut,
+ GraphEdge,
+ GraphNode,
+ GraphResponse,
+ SearchRequest,
+ SearchResponse,
+)
+
+router = APIRouter(prefix="/api", tags=["search"])
+
+
+@router.post("/search", response_model=SearchResponse)
+async def search(req: SearchRequest) -> SearchResponse:
+ rt = get_runtime()
+ engine = rt.engine()
+ rt.refresh_graph()
+
+ result = engine.retrieve(
+ req.query,
+ top_k=req.top_k,
+ use_graph=req.use_graph,
+ sensitivity=req.sensitivity,
+ )
+
+ vault_name = rt.settings.display_vault_name
+ citations = [
+ CitationOut(
+ index=i,
+ note_id=scored.chunk.note_id,
+ obsidian_uri=obsidian_uri(scored.chunk.note_id, vault_name),
+ title=scored.chunk.citation,
+ snippet=scored.chunk.text[:280].replace("\n", " "),
+ score=round(scored.score, 5),
+ tags=sorted(scored.chunk.tags),
+ )
+ for i, scored in enumerate(result.chunks, start=1)
+ ]
+
+ return SearchResponse(
+ query=req.query,
+ elapsed_ms=round(result.elapsed_ms, 1),
+ date_filter=str(result.date_range) if result.date_range else None,
+ retrievers=result.per_retriever,
+ results=citations,
+ )
+
+
+@router.get("/links/{note_id:path}")
+async def links(note_id: str) -> dict[str, object]:
+ rt = get_runtime()
+ rt.refresh_graph()
+ graph = rt.graph
+ if graph is None:
+ return {"note_id": note_id, "links_to": [], "linked_from": []}
+
+ return {
+ "note_id": note_id,
+ "links_to": sorted(graph.forward.get(note_id, set())),
+ "linked_from": sorted(graph.backward.get(note_id, set())),
+ }
+
+
+@router.get("/graph/stats")
+async def graph_stats() -> dict[str, object]:
+ rt = get_runtime()
+ return dict(rt.refresh_graph().stats)
+
+
+@router.get("/graph", response_model=GraphResponse)
+async def graph(
+ limit: int = Query(60, ge=1, le=400),
+ center: str | None = Query(
+ None,
+ description=(
+ "Optional note_id to focus on. When provided, the response contains "
+ "that note plus all of its one-hop neighbours (forward + backward) "
+ "rather than the global top-deegree subset."
+ ),
+ ),
+ include_embeds: bool = Query(
+ True,
+ description="Include transclusion edges (``![[Note]]``) separately from links.",
+ ),
+) -> GraphResponse:
+ """Wikilink subgraph sized for the sidebar's radial view.
+
+ The full graph on a real Obsidian vault can have thousands of nodes; sending
+ them all over the wire would be cheap but unpaintable. We need a *curated*
+ subgraph that shows the structure the user came for -- the hubs, the loops,
+ the well-trodden paths -- without dragging in nodes that have one weakly
+ connected link and would just clutter the canvas.
+
+ The default strategy is ``top-N by degree``. ``center`` switches to a
+ one-hop BFS so a user who clicks a search result can pivot from "this
+ answer" to "what is connected to this answer" without re-rendering the
+ whole layout.
+ """
+ rt = get_runtime()
+ graph = rt.refresh_graph()
+
+ # ----- degree table ---------------------------------------------------
+ # Degree counts every kind of directed incident edge: outgoing links,
+ # incoming backlinks, and embeds in either direction. Embeds count once
+ # in degree even though they are also returned as separate edge kind, so
+ # adding ``is_embed`` to an outgoing link does not inflate the node twice
+ # visually -- a hub with transclusions would otherwise dwarf everything else.
+ degree: dict[str, int] = defaultdict(int)
+ for src, targets in graph.forward.items():
+ degree[src] += len(targets)
+ for tgt in targets:
+ degree[tgt] += 1 # backlink weight
+ for src, targets in graph.embeds.items():
+ for tgt in targets:
+ degree[src] += 1
+ degree[tgt] += 1
+
+ if not degree:
+ # Empty vault (or only notes that are still unindexed). The UI renders
+ # an empty-state, not a degenerate radial layout with one ring.
+ return GraphResponse(stats=graph.stats, nodes=[], edges=[], center=None)
+
+ # ----- node selection -------------------------------------------------
+ selected: dict[str, int] # note_id -> degree
+ center_id: str | None
+
+ if center is not None and center in degree:
+ # Note present in the graph. Pull it plus all one-hop neighbours.
+ centre_set: set[str] = {center}
+ centre_set.update(graph.forward.get(center, set()))
+ centre_set.update(graph.backward.get(center, set()))
+ # Cap to ``limit`` by degree so a hive-mind hub does not drag in every
+ # weakly-connected note in the vault. The hovered centre is always kept.
+ if len(centre_set) > limit:
+ ranked = sorted(centre_set, key=lambda nid: degree.get(nid, 0), reverse=True)
+ centre_set = set(ranked[:limit])
+ centre_set.add(center)
+ selected = {nid: degree[nid] for nid in centre_set}
+ center_id = center
+ else:
+ # Top-N by degree. Ties broken by note_id so two equally-connected
+ # notes do not trade places between polls and visually jitter.
+ ordered = sorted(
+ degree.items(),
+ key=lambda kv: (-kv[1], kv[0]),
+ )[:limit]
+ selected = dict(ordered)
+ center_id = ordered[0][0] if ordered else None
+
+ # ----- node -> title resolution --------------------------------------
+ titles = graph.titles or {}
+
+ def _title(note_id: str) -> str:
+ fallback = PurePosixPath(note_id).stem.replace("-", " ").replace("_", " ")
+ return titles.get(note_id) or fallback
+
+ # ----- edges within the selected subset -------------------------------
+ edges: list[GraphEdge] = []
+ for src in selected:
+ for tgt in graph.forward.get(src, set()):
+ if tgt not in selected:
+ continue
+ is_embed = tgt in graph.embeds.get(src, set())
+ if is_embed:
+ edges.append(GraphEdge(source=src, target=tgt, kind="embed"))
+ elif include_embeds:
+ # Only emit a plain link when embeds are wanted at all; if
+ # ``include_embeds`` is False the caller wants a cleaner
+ # wikilink-only picture.
+ edges.append(GraphEdge(source=src, target=tgt, kind="link"))
+
+ truncated = len(selected) >= limit and (center is None or center not in selected)
+
+ nodes: list[GraphNode] = [
+ GraphNode(
+ id=nid,
+ title=_title(nid),
+ degree=d,
+ tag=None, # reserved for a follow-up that carries note-level tags
+ is_hub=(nid == center_id),
+ )
+ for nid, d in sorted(
+ selected.items(),
+ key=lambda kv: (-kv[1], kv[0]),
+ )
+ ]
+
+ return GraphResponse(
+ stats=graph.stats,
+ nodes=nodes,
+ edges=edges,
+ center=center_id,
+ truncated=truncated,
+ )
diff --git a/src/cortex/server/routers/system.py b/src/cortex/server/routers/system.py
new file mode 100644
index 0000000..0b20244
--- /dev/null
+++ b/src/cortex/server/routers/system.py
@@ -0,0 +1,145 @@
+"""System endpoints.
+
+Status and control surfaces that are not about a single query:
+
+* ``/api/status`` -- a single snapshot of everything the UI's sidebar wants to
+ know on each refresh: index size, thermal state, eligible providers, memory
+ count.
+* ``/api/reindex`` -- trigger the indexer. Used by a manual refresh button;
+ the watcher handles the automatic case.
+* ``/api/health`` -- cheap liveness probe for orchestrators and the frontend's
+ reconnect loop.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+from fastapi import APIRouter
+
+from cortex.models import Sensitivity
+from cortex.server.dependencies import get_runtime
+from cortex.server.schemas import (
+ ProviderStatus,
+ ReindexRequest,
+ StatusResponse,
+ ThermalStatus,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["system"])
+
+
+@router.get("/status", response_model=StatusResponse)
+async def status() -> StatusResponse:
+ rt = get_runtime()
+
+ # Router.explain returns a human-facing verdict per provider -- the cheapest
+ # way to know "is this provider allowed to see PRIVATE traffic" is to ask
+ # the gate directly and parse the verdict. A provider with no verdict is
+ # either disabled or absent from the configured chain.
+ verdict_map = rt.router.explain(Sensitivity.PRIVATE)
+ providers: list[ProviderStatus] = []
+ for provider in rt.router.providers:
+ spec = provider.spec
+ verdict = verdict_map.get(spec.name, "unknown")
+ eligible = verdict.startswith("eligible")
+ # Pull a friendly reason out of the verdict so the UI can show the
+ # *why*, not just the verdict's first word.
+ if "refused:" in verdict:
+ reason = verdict.split("refused:", 1)[1].strip()
+ elif verdict == "disabled":
+ reason = "disabled"
+ else:
+ reason = None
+ providers.append(
+ ProviderStatus(
+ name=spec.name,
+ model=spec.model,
+ policy=spec.policy.value,
+ priority=spec.priority,
+ eligible_private=eligible,
+ config_issue=None if eligible else verdict,
+ reason=reason,
+ )
+ )
+
+ if rt.governor is not None:
+ rt.governor.sample(force=True)
+ described = rt.governor.describe()
+ power_raw = described.get("power")
+ cpu_raw = described.get("cpu_speed_limit")
+ bat_raw = described.get("battery_percent")
+ w_raw = described.get("workers", 0)
+ thermal = ThermalStatus(
+ state=str(described.get("state", "unknown")),
+ power=str(power_raw) if power_raw is not None else None,
+ cpu_speed_limit=int(cpu_raw) if isinstance(cpu_raw, (int, str)) else None,
+ battery_percent=int(bat_raw) if isinstance(bat_raw, (int, str)) else None,
+ workers=int(w_raw) if isinstance(w_raw, (int, str)) else 0,
+ may_backfill=bool(described.get("may_backfill", False)),
+ available=True,
+ )
+ else:
+ thermal = ThermalStatus(
+ state="unavailable",
+ workers=0,
+ may_backfill=False,
+ available=False,
+ reason="in-memory runtime",
+ )
+
+ counts = rt.catalog.stats()
+ memory_count = rt.memory.count() if rt.memory is not None and rt.memory.enabled else 0
+
+ return StatusResponse(
+ vault_path=str(rt.settings.vault_path),
+ vault_name=rt.settings.display_vault_name,
+ notes_indexed=counts.get("notes", 0),
+ chunks=counts.get("chunks", 0),
+ providers=providers,
+ thermal=thermal,
+ memory_count=memory_count,
+ memory_enabled=bool(rt.memory is not None and rt.memory.enabled),
+ )
+
+
+@router.post("/reindex")
+async def reindex(req: ReindexRequest) -> dict[str, object]:
+ from cortex.ingest.pipeline import IndexReport
+
+ rt = get_runtime()
+
+ # Run in a worker thread so the event loop stays responsive. The pipeline
+ # does blocking I/O; leaving it on the loop would freeze the whole app.
+ def _run() -> IndexReport:
+ return rt.pipeline.run(
+ full=req.full,
+ respect_thermal=not req.ignore_thermal,
+ )
+
+ report = await asyncio.to_thread(_run)
+ rt.refresh_graph()
+
+ return {
+ "summary": report.summary(),
+ "scanned": report.scanned,
+ "indexed": report.indexed,
+ "skipped": report.skipped,
+ "deleted": report.deleted,
+ "chunks_written": report.chunks_written,
+ "paused_for_thermal": report.paused_for_thermal,
+ "errors": [{"note": n, "error": e} for n, e in report.errors[:20]],
+ }
+
+
+@router.get("/health")
+async def health() -> dict[str, object]:
+ rt = get_runtime()
+ return {
+ "ok": True,
+ "vault": str(rt.settings.vault_path),
+ "providers_configured": sum(1 for p in rt.router.providers if p.configured),
+ }
diff --git a/src/cortex/server/schemas.py b/src/cortex/server/schemas.py
new file mode 100644
index 0000000..5e01a69
--- /dev/null
+++ b/src/cortex/server/schemas.py
@@ -0,0 +1,226 @@
+"""Pydantic schemas for the HTTP wire format.
+
+Streaming chat uses one ``SSEEvent`` discriminant, with the payload variant
+encoded in ``type``. That keeps the schema flat on the wire (a mobile JS
+client can decode each event with one switch) and trivial to evolve. New event
+types add new unions; existing event types stay source-compatible.
+
+The Minkowski convention: snappy names that survive type-narrowing on the
+client without us re-stating fields in TypeScript.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from cortex.models import Sensitivity
+
+# -- Requests ---------------------------------------------------------------
+
+
+class ChatMessageIn(BaseModel):
+ """One message in a chat exchange.
+
+ ``role`` matches the OpenAI spelling so the same shape can be forwarded
+ verbatim into any provider that takes ``messages`` directly.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ role: Literal["user", "assistant", "system"]
+ content: str
+ ts: datetime | None = None
+
+
+class ChatRequest(BaseModel):
+ """Body for ``POST /api/chat``.
+
+ The full message history is replayed each turn -- the server is stateless
+ across requests, which matches how the MCP handlers behave. Conversation
+ memory (across turns / sessions) is the user's vault, via :meth:`remember`.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ messages: list[ChatMessageIn]
+ local_only: bool = False
+ remember: bool = False
+ top_k: int = Field(8, ge=1, le=50)
+
+
+class SearchRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ query: str
+ top_k: int = Field(8, ge=1, le=50)
+ use_graph: bool = True
+ sensitivity: Sensitivity | None = None
+
+
+class RememberRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ question: str
+ answer: str
+ sources: list[str] = []
+ tags: list[str] = []
+ provider: str = ""
+
+
+class ReindexRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ full: bool = False
+ ignore_thermal: bool = False
+
+
+# -- Responses --------------------------------------------------------------
+
+
+class CitationOut(BaseModel):
+ index: int
+ note_id: str
+ obsidian_uri: str
+ title: str
+ snippet: str
+ score: float
+ tags: list[str] = []
+
+
+class ProviderStatus(BaseModel):
+ name: str
+ model: str
+ policy: str
+ priority: int
+ eligible_private: bool
+ config_issue: str | None = None
+ reason: str | None = None
+
+
+class ThermalStatus(BaseModel):
+ state: str
+ power: str | None = None
+ cpu_speed_limit: int | None = None
+ battery_percent: int | None = None
+ workers: int
+ may_backfill: bool
+ available: bool = True
+ reason: str | None = None
+
+
+class MemoryNoteOut(BaseModel):
+ path: str
+ title: str
+ created: datetime
+ question: str
+ answer: str
+ sources: list[str]
+ obsidian_uri: str
+
+
+class StatusResponse(BaseModel):
+ vault_path: str
+ vault_name: str
+ notes_indexed: int
+ chunks: int
+ providers: list[ProviderStatus]
+ thermal: ThermalStatus
+ memory_count: int
+ memory_enabled: bool
+
+
+class SearchResponse(BaseModel):
+ query: str
+ elapsed_ms: float
+ date_filter: str | None = None
+ retrievers: dict[str, int]
+ results: list[CitationOut]
+
+
+class GraphNode(BaseModel):
+ """One node in the wikilink graph payload sent to the UI.
+
+ ``degree`` is the total number of edges incident on this node (forward +
+ backward + embeds to/from). The sidebar view sizes circles by degree so
+ visual mass corresponds to importance in the vault.
+
+ ``tag`` is the single top-level tag carried by this note (``# foo/bar``
+ becomes ``foo``), used by the radial layout to colour the outer ring. A
+ note with no tags gets ``tag=None`` and renders in the neutral palette.
+ """
+
+ id: str
+ title: str
+ degree: int
+ tag: str | None = None
+ is_hub: bool = False
+ """True when this node was selected as the centre of the radial layout
+ (top-degree note, or the ``center`` query parameter). The UI thickens its
+ ring so it reads as the focal point at a glance."""
+
+
+GraphEdgeKind = Literal["link", "embed"]
+
+
+class GraphEdge(BaseModel):
+ """A directed edge in the wikilink graph.
+
+ ``kind == "embed"`` is a transclusion (``![[Note]]``) -- a materially
+ stronger connection than a regular link, rendered differently on the UI.
+ """
+
+ source: str
+ target: str
+ kind: GraphEdgeKind = "link"
+
+
+class GraphResponse(BaseModel):
+ """Compact payload for the sidebar's graph panel.
+
+ Constants from the larger graph (stats, all node ids) are returned once at
+ the top; the visible subgraph is a curated subset of ``limit`` nodes by
+ default, expanded to include the immediate neighbours of any surface hub.
+ """
+
+ stats: dict[str, int]
+ nodes: list[GraphNode]
+ edges: list[GraphEdge]
+ # The note_id the UI should treat as the radial centre. Equal to ``center``
+ # when the client requested one, else the top-degree node. ``None`` when
+ # the graph has fewer than two nodes -- nothing meaningful to centre on.
+ center: str | None = None
+ truncated: bool = False
+ """True when more nodes exist than were included in the response."""
+
+
+# -- SSE event envelope -----------------------------------------------------
+
+
+ChatEventType = Literal["provider", "retrieval", "citation", "token", "text", "error", "done"]
+
+
+class SSEEvent(BaseModel):
+ """Discriminated union member. The full event on the wire is::
+
+ event:
+ data: {}
+
+ The ``type`` lives in the ``event:`` header, *not* inside the JSON
+ payload. The browser's ``EventSource`` raises the natural ``type`` for
+ free on the client (``es.addEventListener("text", ...)``), so the JSON
+ body is just the payload of that event -- no nested envelope to unwrap.
+ """
+
+ type: ChatEventType | Literal["status", "memory"]
+ data: dict[str, Any]
+
+ def encode(self) -> bytes:
+ import json
+
+ # ``data`` is the inner dict, not the SSEEvent itself. If we dumped
+ # the model, the JSON would carry a redundant ``type`` field that the
+ # frontend then has to ignore.
+ return f"event: {self.type}\ndata: {json.dumps(self.data)}\n\n".encode()
diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py
index 767e592..a87256d 100644
--- a/tests/test_cli_mcp.py
+++ b/tests/test_cli_mcp.py
@@ -179,7 +179,13 @@ def test_missing_vault_exits_cleanly(self, tmp_path: Path) -> None:
def test_index_then_search(self, vault: Path, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("CORTEX_DATA_DIR", str(tmp_path / "data"))
- indexed = runner.invoke(app, ["index", "--vault", str(vault), "--offline"])
+ # --ignore-thermal keeps this test independent of the developer's
+ # actual battery / chassis state, which pmset reports faithfully on
+ # macOS but is irrelevant to whether `cortex index` works.
+ indexed = runner.invoke(
+ app,
+ ["index", "--vault", str(vault), "--offline", "--ignore-thermal"],
+ )
assert indexed.exit_code == 0, indexed.stdout
assert "indexed" in indexed.stdout
@@ -206,6 +212,31 @@ def test_graph_stats(self, vault: Path, tmp_path: Path, monkeypatch) -> None: #
assert "Edges" in result.stdout
+class TestRuntimeConstruction:
+ """Lock in test-mode behaviour so a refactor can't silently regress."""
+
+ def test_in_memory_runtime_does_not_wire_a_governor_into_the_pipeline(
+ self, settings: Settings
+ ) -> None:
+ # Tests must not depend on the developer's actual battery / chassis
+ # state. The pipeline-level thermal gate is what kept failing tests on
+ # a low-battery laptop, so build_runtime(..., in_memory=True) deliberately
+ # leaves the pipeline governor-less. Pin this so a future refactor of
+ # IndexPipeline's constructor cannot silently re-introduce the gate.
+ from cortex.runtime import build_runtime
+
+ rt = build_runtime(settings=settings, offline=True, in_memory=True)
+ assert rt.pipeline.governor is None
+ # The runtime-level governor is also absent in test mode, so callers
+ # like vault_status() must learn to render 'unavailable' rather than
+ # crash. We test that contract in TestStatusTool below.
+ assert rt.governor is None
+ # Sanity: report.scanned still flows and indexing is non-paused.
+ report = rt.pipeline.run()
+ assert report.paused_for_thermal is False
+ assert report.scanned >= 1
+
+
class TestSettingsLoading:
def test_toml_roundtrip(self, tmp_path: Path) -> None:
config = tmp_path / "cortex.toml"
diff --git a/tests/test_doctor.py b/tests/test_doctor.py
new file mode 100644
index 0000000..289ffbb
--- /dev/null
+++ b/tests/test_doctor.py
@@ -0,0 +1,1084 @@
+"""Tests for the cortex doctor diagnostic module.
+
+These exercise each individual check with a constructed Settings; the
+`run_doctor` orchestrator test suite covers aggregation, severity ordering and
+crash containment.
+"""
+
+from __future__ import annotations
+
+import json as _json
+import plistlib
+import urllib.request
+from pathlib import Path
+
+import pytest
+
+from cortex import doctor as _doctor_module
+from cortex.config import Settings
+from cortex.doctor import Check, DoctorReport, Status, run_doctor
+from cortex.llm.protocol import ProviderSpec
+from cortex.models import DataPolicy
+
+
+@pytest.fixture
+def settings(tmp_path: Path) -> Settings:
+ s = Settings()
+ s.vault_path = tmp_path / "vault"
+ s.vault_path.mkdir()
+ s.data_dir = tmp_path / "data"
+ return s
+
+
+@pytest.fixture
+def empty_settings(tmp_path: Path) -> Settings:
+ """Settings pointing at a non-existent vault."""
+ s = Settings()
+ s.vault_path = tmp_path / "nope"
+ s.data_dir = tmp_path / "data"
+ return s
+
+
+class TestStatusSummary:
+ def test_ready_line_used_when_no_issues(self) -> None:
+ report = DoctorReport(checks=[Check("a", Status.PASS, ""), Check("b", Status.INFO, "")])
+ assert report.summary_line() == "Ready"
+
+ def test_singular_issue(self) -> None:
+ report = DoctorReport(checks=[Check("a", Status.PASS, ""), Check("b", Status.WARN, "x")])
+ assert report.summary_line() == "1 issue"
+
+ def test_plural_issues(self) -> None:
+ report = DoctorReport(
+ checks=[
+ Check("a", Status.WARN, "x"),
+ Check("b", Status.FAIL, "y"),
+ Check("c", Status.WARN, "z"),
+ ]
+ )
+ assert report.summary_line() == "3 issues"
+
+ def test_blocking_and_warnings_counted_separately(self) -> None:
+ report = DoctorReport(
+ checks=[
+ Check("a", Status.FAIL, "x"),
+ Check("b", Status.FAIL, "y"),
+ Check("c", Status.WARN, "z"),
+ ]
+ )
+ assert report.blocking == 2
+ assert report.warnings == 1
+ assert report.infos == 0
+
+
+class TestVaultCheck:
+ def test_passes_when_vault_has_notes(self, settings: Settings) -> None:
+ from cortex.doctor import check_vault
+
+ (settings.vault_path / "A.md").write_text("# A\n[[B]]\n", encoding="utf-8")
+ (settings.vault_path / "B.md").write_text("# B\n", encoding="utf-8")
+ result = check_vault(settings)
+ assert result.status is Status.PASS
+ assert "notes" in result.message
+
+ def test_non_linked_vault_still_passes(self, settings: Settings) -> None:
+ # An unlinked vault is degraded for graph retrieval but valid as a
+ # vault. Surfacing a WARN would mislead users into chasing a fix for
+ # something they chose to leave flat.
+ from cortex.doctor import check_vault
+
+ (settings.vault_path / "A.md").write_text("# A\npure prose\n", encoding="utf-8")
+ result = check_vault(settings)
+ assert result.status is Status.PASS
+
+ def test_fails_when_path_missing(self, empty_settings: Settings) -> None:
+ from cortex.doctor import check_vault
+
+ result = check_vault(empty_settings)
+ assert result.status is Status.FAIL
+ assert result.hint is not None
+ assert "CORTEX_VAULT" in result.hint or "--vault" in result.hint
+
+
+class TestOllamaCheck:
+ def test_fail_when_unreachable(self, settings: Settings, monkeypatch) -> None: # type: ignore[no-untyped-def]
+ from cortex import doctor
+
+ # Use a port nothing is listening on; connection refused returns fast
+ # so this test does not burn the 2-second doctor timeout budget.
+ settings.ollama_url = "http://127.0.0.1:1"
+
+ result = doctor.check_ollama(settings)
+ assert result.status is Status.FAIL
+ assert "unreachable" in result.message
+
+ def test_warn_when_ollama_up_but_model_missing(
+ self, settings: Settings, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ class _FakeResp:
+ def __init__(self, payload: dict[str, object]) -> None:
+ self._payload = payload
+
+ def read(self) -> bytes:
+ return _json.dumps(self._payload).encode("utf-8")
+
+ def __enter__(self) -> _FakeResp:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ return None
+
+ payloads = [
+ {"models": [{"name": "some-other-model"}]}, # /api/tags
+ {"models": []}, # /api/ps
+ ]
+ idx = 0
+
+ def _fake_urlopen(req: object, timeout: float = 2.0) -> _FakeResp: # type: ignore[no-untyped-def]
+ nonlocal idx
+ resp = _FakeResp(payloads[idx])
+ idx += 1
+ return resp
+
+ # ``monkeypatch.setattr`` restores itself on fixture teardown, so a
+ # failing assertion cannot leak the patch into the next test.
+ monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
+
+ result = doctor.check_ollama(settings)
+ assert result.status is Status.WARN
+ assert "not pulled" in result.message
+
+ def test_pass_message_reports_load_state(
+ self, settings: Settings, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ class _FakeResp:
+ def __init__(self, payload: dict[str, object]) -> None:
+ self._payload = payload
+
+ def read(self) -> bytes:
+ return _json.dumps(self._payload).encode("utf-8")
+
+ def __enter__(self) -> _FakeResp:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ return None
+
+ model = settings.embed_model
+
+ def _fake_urlopen(req: object, timeout: float = 2.0) -> _FakeResp: # type: ignore[no-untyped-def]
+ url = getattr(req, "full_url", "")
+ if url.endswith("/api/ps"):
+ return _FakeResp({"models": [{"name": model}]}) # currently loaded
+ return _FakeResp({"models": [{"name": model}]}) # pulled
+
+ monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
+ result = doctor.check_ollama(settings)
+ assert result.status is Status.PASS
+ assert "currently loaded" in result.message
+
+
+class TestMcpCheck:
+ def test_passes_when_mcp_present_and_compatible(self) -> None:
+ # If the user has installed cortex-brain[mcp], the constructor accepts
+ # a name-only Server() call -- meaning the wire format matches their
+ # installed mcp version. This session has mcp installed.
+ from cortex.doctor import check_mcp_wire
+
+ result = check_mcp_wire(Settings())
+ assert result.status in {Status.PASS, Status.FAIL}
+ # Only PASS if mcp installed (this test env has it).
+ assert result.name == "MCP"
+
+
+class TestDaemonCheck:
+ def test_warns_when_plist_not_installed(self) -> None:
+ from cortex.doctor import check_daemon
+
+ result = check_daemon(Settings())
+ # The user's machine does not have the plist installed in this test
+ # environment, so we expect WARN. (Reality: tracks the host.)
+ assert result.status in {Status.WARN, Status.PASS}
+ assert result.name == "Daemon"
+
+
+class TestPrivacyCheck:
+ def test_passes_when_local_provider_present(self, settings: Settings) -> None:
+ from cortex.doctor import check_privacy
+
+ settings.providers = [
+ ProviderSpec(
+ name="ollama",
+ base_url="http://localhost:11434",
+ model="x",
+ policy=DataPolicy.LOCAL,
+ )
+ ]
+ result = check_privacy(settings)
+ assert result.status is Status.PASS
+
+ def test_fails_when_only_training_provider(self, settings: Settings) -> None:
+ from cortex.doctor import check_privacy
+
+ settings.providers = [
+ ProviderSpec(
+ name="zen",
+ base_url="https://example.invalid/v1",
+ model="m",
+ policy=DataPolicy.TRAINS,
+ )
+ ]
+ result = check_privacy(settings)
+ assert result.status is Status.FAIL
+ assert "no provider" in result.message.lower()
+
+
+class TestMemoryCheck:
+ def test_warns_when_disabled(self, settings: Settings) -> None:
+ from cortex.doctor import check_memory
+
+ settings.memory_enabled = False
+ result = check_memory(settings)
+ assert result.status is Status.INFO
+
+ def test_passes_when_vault_writable(self, settings: Settings) -> None:
+ from cortex.doctor import check_memory
+
+ settings.memory_enabled = True
+ result = check_memory(settings)
+ assert result.status is Status.PASS
+ assert result.message.startswith("writable") or "writable" in result.message
+
+ def test_skips_when_vault_missing_to_avoid_cascade(self, empty_settings: Settings) -> None:
+ from cortex.doctor import check_memory
+
+ empty_settings.memory_enabled = True
+ result = check_memory(empty_settings)
+ assert result.status is Status.INFO
+ assert "skipped" in result.message.lower() or "vault" in result.message.lower()
+
+
+def _write_plist(path: Path, env: dict[str, str]) -> None:
+ """Helper: dump a minimal but valid plist with the given env block."""
+ payload = {
+ "Label": "test.ollama",
+ "ProgramArguments": ["/usr/bin/true"],
+ "EnvironmentVariables": env,
+ }
+ with path.open("wb") as fh:
+ plistlib.dump(payload, fh)
+
+
+def _write_systemd_unit(path: Path, lines: list[str]) -> None:
+ """Helper: write a minimal systemd unit file with the given Environment= lines."""
+ sections: list[str] = ["[Unit]", "Description=test", "", "[Service]"]
+ sections.extend(lines)
+ sections.extend(["", "[Install]", "WantedBy=default.target"])
+ path.write_text("\n".join(sections) + "\n", encoding="utf-8")
+
+
+class TestGpuPathCheck:
+ """Test the GPU-path probe with monkeypatched candidate paths.
+
+ Each test points ``doctor.OLLAMA_DAEMON_PATHS`` at fixture files in
+ ``tmp_path`` so we never touch the real ``~/Library`` directory. Stale
+ binaries that pre-date this feature would otherwise drift with every
+ developer machine.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _stub_live_probe(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Default the live probe to ``None`` so tests do not bleed into the
+ developer's real launchctl/systemctl state.
+
+ Tests that care about the live env explicitly call
+ ``monkeypatch.setattr`` to override this fixture's stub. The autouse
+ hook means every GPT test here gets the safe default and we never
+ write a test that accidentally relies on the host's daemon env.
+ """
+ monkeypatch.setattr(_doctor_module, "_live_ollama_env", lambda: None)
+
+ def _candidate_for(self, tmp_path: Path, kind: str, env: dict[str, str]) -> Path:
+ if kind == "plist":
+ plist = tmp_path / "ollama.plist"
+ _write_plist(plist, env)
+ return plist
+ unit = tmp_path / "ollama.service"
+ env_lines = [f'Environment="{k}={v}"' for k, v in env.items()]
+ _write_systemd_unit(unit, env_lines)
+ return unit
+
+ def test_no_daemon_config_returns_info(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ # Every candidate is a path inside tmp_path that does NOT exist.
+ # The check should resolve to INFO (no evidence) rather than FAIL.
+ fake_paths = tuple((tmp_path / f"missing-{i}.plist", "plist") for i in range(3))
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", fake_paths)
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.INFO
+ assert result.hint is not None
+ assert "no ollama daemon config" in result.message.lower()
+
+ def test_plist_with_num_gpu_zero_returns_warn(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((plist, "plist"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert "OLLAMA_NUM_GPU=0" in result.message
+ assert result.hint is not None
+ # The remediation path names the file the user needs to edit.
+ assert str(plist) in result.hint
+ # Details should expose the static value for the user.
+ joined = " ".join(result.details)
+ assert "static" in joined.lower()
+ # The exact wording varies by live-probe result; just confirm the
+ # surface area recognises the value. Either the live row spells
+ # ``'0'`` outright (live matches), or the message flags the
+ # discrepancy, the missing override, the daemon-bound value, or
+ # (worst case) the live probe failed.
+ assert any(
+ token in joined.lower()
+ for token in (
+ "'0'",
+ "live: no override",
+ "disagree",
+ "static override",
+ "live probe: failed",
+ )
+ )
+
+ def test_plist_without_num_gpu_returns_pass(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_FLASH_ATTENTION": "1"})
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((plist, "plist"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.PASS
+ assert "default GPU acceleration" in result.message
+ assert plist.name in result.message
+
+ def test_systemd_unit_with_num_gpu_zero_returns_warn(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ unit = self._candidate_for(tmp_path, "systemd", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((unit, "systemd"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert "OLLAMA_NUM_GPU=0" in result.message
+ # systemd-derived details must surface the unit kind so the user
+ # can tell which kind of config file controls inference.
+ joined = " ".join(result.details)
+ assert "systemd" in joined
+
+ def test_first_existing_path_wins(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # When the priority list has multiple real files, the first one wins.
+ # Regression: an old broken plist at position 0 must not be silently
+ # shadowed by a healthy plist at position 1.
+ from cortex import doctor
+
+ healthy_plist = tmp_path / "00-healthy.plist"
+ _write_plist(healthy_plist, {"OLLAMA_FLASH_ATTENTION": "1"})
+ cpu_plist = tmp_path / "99-cpu.plist"
+ _write_plist(cpu_plist, {"OLLAMA_NUM_GPU": "0"})
+
+ # Order matters: healthy is FIRST, then cpu. The first existing file
+ # should win.
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ (
+ (healthy_plist, "plist"),
+ (cpu_plist, "plist"),
+ ),
+ )
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.PASS
+ assert healthy_plist.name in result.message
+
+ # Flip the order. cpu is now first.
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ (
+ (cpu_plist, "plist"),
+ (healthy_plist, "plist"),
+ ),
+ )
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert cpu_plist.name in str(result.details)
+
+ def test_corrupt_plist_returns_pass_empty_env(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # A garbage plist is treated as having no env. The user will see a
+ # PASS for the GPU path -- but the daemon itself will probably fail
+ # to load on the next launch; that's a separate diagnostic concern
+ # the ollama check already covers.
+ from cortex import doctor
+
+ bad = tmp_path / "garbage.plist"
+ bad.write_bytes(b"this is not a plist")
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((bad, "plist"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.PASS
+ assert "default GPU acceleration" in result.message
+
+ def test_systemd_multi_assignment_parses_each_token(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # systemd accepts whitespace-separated ``Environment=K1=v1 K2=v2`` and
+ # we should treat each token as its own assignment. Regression for
+ # the regex-grep bug where the entire RHS was captured as one value.
+ from cortex import doctor
+
+ unit = tmp_path / "multi.service"
+ _write_systemd_unit(
+ unit,
+ ['Environment="OLLAMA_NUM_GPU=0 OLLAMA_DEBUG=1"'],
+ )
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((unit, "systemd"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ # MUST capture ``0``, not the full tail ``0 OLLAMA_DEBUG=1``.
+ assert "OLLAMA_NUM_GPU=0" in result.message
+ assert "OLLAMA_DEBUG" not in result.message
+
+ def test_systemd_inline_hash_in_value_is_preserved(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # A trivial guard against the comment-strip eating values that
+ # legitimately contain ``#``. The diagnostic doesn't run on such
+ # values, never; this just verifies the parser doesn't silently
+ # truncate.
+ from cortex import doctor
+
+ unit = tmp_path / "hash.service"
+ # ``OLLAMA_NUM_GPU=foo#bar`` — hypothetical setting with a ``#``
+ # inside the value. The parser must NOT return ``foo``.
+ _write_systemd_unit(
+ unit,
+ ["Environment=OLLAMA_NUM_GPU=foo#bar"],
+ )
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((unit, "systemd"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ # We don't expect a WARN here (the degenerate value isn't ``0``),
+ # but if the parser mangled ``foo#bar`` to ``foo``, the doctor would
+ # either WARN spuriously (with value=foo) or PASS spuriously. We
+ # assert strictly: the value captured must round-trip the full
+ # ``foo#bar`` form, so neither spuriously warns nor spuriously passes.
+ joined = " ".join(result.details) + " " + result.message
+ assert "foo" in joined
+ # Specifically NOT truncated: the parser must round-trip ``foo#bar``,
+ # not just ``foo``. If the parser mangled it, ``foo#bar`` would be
+ # missing and the doctor would have spuriously warned; either
+ # branch below rejects the malformed outcome.
+ assert "foo#bar" in joined or (
+ result.status is not Status.WARN and "drift" not in result.message.lower()
+ )
+
+ def test_pass_message_acknowledges_environmentfile(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Issue surfaced by code review: a unit using ``EnvironmentFile=``
+ # would silently read as PASS-with-no-cav. We surface the caveat in
+ # the details so a careful user sees the rounding.
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_FLASH_ATTENTION": "1"})
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((plist, "plist"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.PASS
+ assert any("EnvironmentFile=" in line for line in result.details)
+
+ def test_warn_hint_acknowledges_environmentfile(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # The hint should explicitly mention EnvironmentFile= so a user whose
+ # config relies on it isn't silently misled by a PASS.
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ ((plist, "plist"),),
+ )
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert result.hint is not None
+ assert "EnvironmentFile=" in result.hint
+
+ def test_launchctl_parser_recovers_all_three_blocks(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Lock the launchctl parser against drift by feeding it a verbatim
+ # capture of the blocks launchctl print emits: inherited, default,
+ # environment. The user's exact machine output -- regression.
+ from cortex import doctor
+
+ sample = """
+ inherited environment = {
+ \tSSH_AUTH_SOCK => /var/run/ssh-listener
+ \tPATH => /usr/bin:/bin
+ }
+
+ default environment = {
+ \tPATH => /usr/bin:/bin:/usr/sbin:/sbin
+ }
+
+ environment = {
+ \tOSLogRateLimit => 64
+ \tOLLAMA_FLASH_ATTENTION => 1
+ \tOLLAMA_NUM_GPU => 0
+ \tOLLAMA_KV_CACHE_TYPE => q8_0
+ \tXPC_SERVICE_NAME => homebrew.mxcl.ollama
+ }
+
+ pid = 12345
+ """
+ monkeypatch.setattr(
+ doctor, "_live_ollama_env", lambda: doctor._parse_launchctl_env_blocks(sample)
+ )
+ # Belt-and-braces: also stub the helper, in case ``_live_ollama_env``
+ # is ever re-routed through it.
+ monkeypatch.setattr(
+ doctor, "_probe_launchctl_live", lambda: doctor._parse_launchctl_env_blocks(sample)
+ )
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ (),
+ )
+ result = doctor.check_gpu_path(settings)
+ # Live probe succeeded and found NUM_GPU=0; no static config.
+ assert result.status is Status.WARN
+ assert (
+ "transient override" in " ".join(result.details).lower()
+ or "live" in result.message.lower()
+ )
+ # Look up the live env value in details; should collapse to '0'.
+ assert any("live: '0'" in line for line in result.details)
+
+ def test_inherited_environment_only_block_surfaces_transient(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Critical regression for ``launchctl setenv OLLAMA_NUM_GPU=0``: that
+ # override doesn't land in ``environment = { ... }`` -- it lands in
+ # ``inherited environment = { ... }`` because launchd propagates
+ # domain-level setenv through the inherited block. The probe must
+ # surface the value; an earlier filter ("only return env if there
+ # was an explicit environment block") silently dropped the value
+ # and the very case this test guards was invisible to the doctor.
+
+ from cortex import doctor
+
+ sample = """
+ inherited environment = {
+ \tPATH => /usr/bin:/bin
+ \tOLLAMA_NUM_GPU => 0
+ \tSSH_AUTH_SOCK => /var/run/listener
+ }
+
+ default environment = {
+ \tPATH => /usr/bin:/bin:/usr/sbin:/sbin
+ }
+
+ pid = 12345
+ """
+ monkeypatch.setattr(
+ doctor, "_live_ollama_env", lambda: doctor._parse_launchctl_env_blocks(sample)
+ )
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ (),
+ )
+ result = doctor.check_gpu_path(settings)
+ # Static absent, live asserts OLLAMA_NUM_GPU=0 -> transient override.
+ assert result.status is Status.WARN
+ joined = " ".join(result.details) + " " + result.message
+ assert "live: '0'" in joined
+ assert "transient" in joined.lower() or "setenv" in joined.lower()
+
+ def test_static_and_live_match_returns_warn(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ # Live agrees with static.
+ monkeypatch.setattr(doctor, "_live_ollama_env", lambda: {"OLLAMA_NUM_GPU": "0"})
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert "static and live daemon agree" in result.message
+ # Details must show both sides for transparency.
+ joined = " ".join(result.details)
+ assert "static:" in joined
+ assert "live:" in joined
+ assert "match" in joined
+
+ def test_static_and_live_drift_returns_warn(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # plist was edited but the daemon wasn't restarted -> static=0,
+ # live=1 (stale from before the edit). Catch this user-hostile
+ # state explicitly.
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ monkeypatch.setattr(doctor, "_live_ollama_env", lambda: {"OLLAMA_NUM_GPU": "1"})
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert "drift" in result.message.lower()
+ assert "static (0)" in result.message or "'0'" in result.message
+ assert "live (1)" in result.message or "'1'" in result.message
+ # Hint names the remediation.
+ assert result.hint is not None
+ assert "brew services restart" in result.hint
+ joined = " ".join(result.details)
+ assert "disagree" in joined
+
+ def test_static_set_live_unset_returns_warn_restart_hint(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Live probe ran and reported no override. Plist sets NUM_GPU=0.
+ # The running daemon is out of sync -- most likely the user added
+ # the override but never restarted, or the override applies only at
+ # service-start and hasn't been triggered since.
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ monkeypatch.setattr(doctor, "_live_ollama_env", lambda: {})
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert result.hint is not None
+ assert "brew services restart" in result.hint
+ assert "live daemon env lacks" in result.message
+
+ def test_static_unset_live_set_returns_warn_transient(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Common footgun on macOS: ``launchctl setenv OLLAMA_NUM_GPU=0`` in
+ # the user's shell last week. Static config knows nothing of it; the
+ # running daemon sees the override until reboot / logout. Warn
+ # loudly so the user knows it's not going to persist.
+ from cortex import doctor
+
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ())
+ monkeypatch.setattr(doctor, "_live_ollama_env", lambda: {"OLLAMA_NUM_GPU": "0"})
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert result.hint is not None
+ assert "transient" in result.hint.lower() or "setenv" in result.hint.lower()
+ # The hint should not pretend this is a persistent config.
+ assert "plist" in result.hint.lower() or "systemd unit" in result.hint.lower()
+
+ def test_live_probe_failure_falls_back_to_static_only(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Probe failure (timeout, missing binary, no service loaded) must
+ # not FAIL the whole check -- the static source is enough to keep
+ # the WARN meaningful, and we say so in the hint.
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ monkeypatch.setattr(doctor, "_live_ollama_env", lambda: None)
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.WARN
+ assert result.hint is not None
+ # The hint must explicitly mention the probe failure so the user
+ # doesn't naively trust a missing-override outcome.
+ assert "live" in result.hint.lower() and "probe" in result.hint.lower()
+ joined = " ".join(result.details)
+ assert "live probe" in joined.lower()
+
+ def test_static_unset_live_unset_with_static_file_returns_pass(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Static file exists, has env, but doesn't set NUM_GPU. Live daemon
+ # also doesn't have it. PASS -- default GPU acceleration is in
+ # effect on both sides, with the live probe confirming.
+ from cortex import doctor
+
+ plist = self._candidate_for(tmp_path, "plist", {"OLLAMA_FLASH_ATTENTION": "1"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ monkeypatch.setattr(doctor, "_live_ollama_env", lambda: {"OLLAMA_FLASH_ATTENTION": "1"})
+
+ result = doctor.check_gpu_path(settings)
+ assert result.status is Status.PASS
+ joined = " ".join(result.details)
+ assert "live" in joined.lower()
+ assert "no override" in joined.lower()
+
+
+class TestInferencePathCheck:
+ """Tests for the live inference cross-check against supervisor policy.
+
+ Each test stubs ``_safe_run`` to return canned ``ollama ps`` output --
+ we don't actually invoke the user's ollama here because tests would
+ race against model loads/unloads. The autouse fixture on this class
+ also stubs ``_live_ollama_env`` so tests don't bleed into the host's
+ real launchctl/systemctl state.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _stub_external_probes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Default ``_live_ollama_env`` to ``{}`` -- the live probe
+ succeeded with no override. This gives the cross-check a known
+ baseline (supervisor enabled by default) without leaking the
+ developer's actual launchctl/systemctl state into tests.
+
+ Tests that want a *different* supervisor picture override this
+ fixture's stub with an explicit ``monkeypatch.setattr`` after
+ the rest of the fixture has run.
+ """
+ monkeypatch.setattr(_doctor_module, "_live_ollama_env", lambda: {})
+
+ def _fake_ollama_ps(self, monkeypatch: pytest.MonkeyPatch, body: str) -> None:
+ """Replace ``_safe_run`` so the only command we ever answer is the
+ ``ollama ps`` invocation. Everything else returns None (timeout)
+ so a typo in the test surface as a degraded probe rather than a
+ real subprocess call."""
+ from cortex import doctor
+
+ def _fake(args: tuple[str, ...], timeout: float = 3.0) -> str | None: # type: ignore[no-untyped-def]
+ if args and args[0:2] == ("ollama", "ps"):
+ return body
+ return None
+
+ monkeypatch.setattr(doctor, "_safe_run", _fake)
+
+ def _binary_present(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Tell ``shutil.which('ollama')`` to return a path so the binary
+ check passes."""
+ from cortex import doctor
+
+ monkeypatch.setattr(doctor, "_ollama_binary_path", lambda: "/usr/bin/ollama")
+
+ def test_binary_missing_returns_info(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ monkeypatch.setattr(doctor, "_ollama_binary_path", lambda: None)
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.INFO
+ assert "not on PATH" in result.message
+
+ def test_ollama_ps_failed_returns_info(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ self._binary_present(monkeypatch)
+ monkeypatch.setattr(doctor, "_safe_run", lambda args, timeout=3.0: None)
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.INFO
+ assert "did not respond" in result.message
+
+ def test_no_models_returns_pass(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(monkeypatch, "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n")
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.PASS
+ assert "no models" in result.message
+
+ def test_all_gpu_with_no_override_returns_pass(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Canonical happy path: supervisor enabled, model runs on GPU.
+ from cortex import doctor
+
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(
+ monkeypatch,
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ # Line lengths follow real ``ollama ps`` output verbatim; the
+ # trailing UNTIL cell is multi-word on recent builds.
+ "qwen3-embedding:0.6b ac6da0dfba84 2.2 GB 100% GPU 4096 4 minutes\n",
+ )
+ # No static config and live probe returns no override.
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ (),
+ )
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.PASS
+ joined = " ".join(result.details)
+ assert "100% GPU" in joined
+
+ def test_signature_zero_gpu_with_no_override_returns_warn(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # The exact user-spec scenario: zero models with GPU layers but
+ # the supervisor says enabled. The classic "silent GPU fallback".
+ from cortex import doctor
+
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(
+ monkeypatch,
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "qwen3-embedding:0.6b ac6da0dfba84 2.2 GB 100% CPU 4096 4 minutes\n",
+ )
+ monkeypatch.setattr(
+ doctor,
+ "OLLAMA_DAEMON_PATHS",
+ (),
+ )
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.WARN
+ assert "zero GPU compute" in result.message
+ joined = " ".join(result.details)
+ assert "100% CPU" in joined
+ assert "supervisor: GPU enabled" in joined
+ # Hint should name plausible cause.
+ assert result.hint is not None
+ assert "Metal" in result.hint or "CUDA" in result.hint
+
+ def test_cpu_with_supervisor_override_returns_pass(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Consistent: user disabled GPU, model is on CPU. Quiet pass.
+ from cortex import doctor
+
+ plist = tmp_path / "override.plist"
+ _write_plist(plist, {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(
+ monkeypatch,
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "qwen3-embedding:0.6b ac6da0dfba84 2.2 GB 100% CPU 4096 4 minutes\n",
+ )
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.PASS
+ assert "consistent with OLLAMA_NUM_GPU=0" in result.message
+
+ def test_gpu_with_supervisor_override_returns_warn_contradiction(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Contradiction: supervisor disabled GPU, but ollama reports GPU
+ # compute. Could be Apple Silicon misreporting (we observed this
+ # in practice on M5); the doctor surfaces the visible gap rather
+ # than guessing which side is wrong.
+ from cortex import doctor
+
+ plist = tmp_path / "override.plist"
+ _write_plist(plist, {"OLLAMA_NUM_GPU": "0"})
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ((plist, "plist"),))
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(
+ monkeypatch,
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "qwen3:4b 123 8.0 GB 100% GPU 8192 4 minutes\n",
+ )
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.WARN
+ assert "supervisor" in result.message.lower() and "GPU" in result.message
+ joined = " ".join(result.details)
+ assert "100% GPU" in joined
+ assert "supervisor: GPU disabled" in joined
+ assert result.hint is not None
+ assert "ollama ps" in result.hint or "latency" in result.hint
+
+ def test_partial_offload_returns_warn(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Mixed GPU/CPU with no override -> warn so user knows they have
+ # a partial-offload situation.
+ from cortex import doctor
+
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(
+ monkeypatch,
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "llama2:13b abc 13.0 GB 32% GPU / 68% CPU 4096 4 minutes from now\n",
+ )
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ())
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.WARN
+ assert "partial" in result.message.lower()
+ joined = " ".join(result.details)
+ assert "32% GPU / 68% CPU" in joined
+
+ def test_unrecognised_processor_value_returns_pass(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Old Ollama builds emit literal "GPU" or "CPU"; classifier
+ # should still detect those without WARN noise.
+ from cortex import doctor
+
+ self._binary_present(monkeypatch)
+ self._fake_ollama_ps(
+ monkeypatch,
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "qwen3-embedding:0.6b ac6da0dfba84 2.2 GB GPU 4096 4 min\n",
+ )
+ monkeypatch.setattr(doctor, "OLLAMA_DAEMON_PATHS", ())
+ result = doctor.check_inference_path(settings)
+ assert result.status is Status.PASS
+
+ def test_ollama_ps_parser_handles_short_rows(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Parse robustness: rows shorter than the header shouldn't crash.
+ from cortex import doctor
+
+ body = (
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "qwen3-embedding:0.6b 100% GPU\n" # short row: only name + proc
+ )
+ result = doctor._parse_ollama_ps_rows(body)
+ assert len(result) == 1
+ # The PROCESSOR fragment should still be parsed even from a short row.
+ assert result[0]["PROCESSOR"] == "100% GPU"
+
+ def test_ollama_ps_parser_handles_full_output(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Realistic case: single row with full table columns including
+ # the multi-word UNTIL phrase that breaks naive token-position
+ # cropping.
+ from cortex import doctor
+
+ body = (
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "qwen3-embedding:0.6b ac6da0dfba84 2.2 GB 100% GPU 4096 4 minutes\n"
+ )
+ result = doctor._parse_ollama_ps_rows(body)
+ assert len(result) == 1
+ assert result[0]["NAME"] == "qwen3-embedding:0.6b"
+ assert result[0]["PROCESSOR"] == "100% GPU"
+
+ def test_ollama_ps_parser_handles_partial_offload(
+ self, settings: Settings, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from cortex import doctor
+
+ body = (
+ "NAME ID SIZE PROCESSOR CONTEXT UNTIL\n"
+ "llama2:13b abc 13.0 GB 32% GPU / 68% CPU 4096 4 minutes\n"
+ )
+ result = doctor._parse_ollama_ps_rows(body)
+ assert len(result) == 1
+ assert result[0]["PROCESSOR"] == "32% GPU / 68% CPU"
+
+
+class TestRunnerOrchestration:
+ def test_never_raises(self, settings: Settings) -> None:
+ # A vault with no model still gets a structured report back.
+ (settings.vault_path / "A.md").write_text("# A\n", encoding="utf-8")
+ result = run_doctor(settings)
+ assert isinstance(result, DoctorReport)
+ # Pin the exact count: a future "I removed a check by accident"
+ # refactor should surface here rather than leaving a silently empty
+ # diagnostic.
+ from cortex.doctor import _CHECKS
+
+ assert len(result.checks) == len(_CHECKS)
+
+ def test_contains_expected_check_names(self, settings: Settings) -> None:
+ # Exact set of names; the doctor module owns each one. If a check is
+ # renamed, this test surfaces it so callers reading the output don't
+ # silently lose a familiar label.
+ expected = {
+ "Config",
+ "Vault",
+ "Ollama",
+ "GPU path",
+ "Inference path",
+ "Vector store",
+ "Privacy",
+ "MCP",
+ "Rerank extra",
+ "Documents extra",
+ "Daemon",
+ "Memory folder",
+ }
+ result = run_doctor(settings)
+ assert {c.name for c in result.checks} == expected
+
+ def test_crash_in_one_check_does_not_abort(self, settings: Settings, monkeypatch) -> None: # type: ignore[no-untyped-def]
+ # Simulate one check blowing up. The runner must convert that crash
+ # into a recorded FAIL, not propagate.
+ from cortex import doctor
+
+ def _boom(_s: Settings) -> Check:
+ raise RuntimeError("synthetic crash")
+
+ monkeypatch.setattr(doctor, "_CHECKS", (doctor.check_config, _boom, doctor.check_vault))
+ report = doctor.run_doctor(settings)
+ statuses = [c.status for c in report.checks]
+ # The middle check should be a FAIL carrying the crash message.
+ assert Status.FAIL in statuses
+ crash_check = next(c for c in report.checks if c.status is Status.FAIL)
+ assert "synthetic crash" in (crash_check.details or [""])[0]
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..7e1fbaa
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,293 @@
+"""Smoke tests for the local HTTP API.
+
+Covers each surface at the seam we care about: a real ASGI scope going through
+FastAPI, with the runtime replaced by an in-memory one. Streaming SSE is
+parsed loosely (read chunks, dump ``data:`` JSON payloads, assert on the
+sequence) rather than against a brittle handcrafted parser.
+
+The point of these tests is to fail loudly on:
+
+ * a route that 404s when the chrome of FastAPI would accept it
+ * a SSE event our frontend depends on that we accidentally stopped emitting
+ * a status response whose schema broke
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncIterator
+from pathlib import Path
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from cortex.config import Settings
+from cortex.runtime import build_runtime
+from cortex.server import create_app
+from cortex.server.dependencies import set_runtime
+
+
+@pytest.fixture
+def in_mem_runtime(tmp_path: Path):
+ """An in-memory Runtime so the test never touches disk."""
+ settings = Settings(
+ vault_path=tmp_path / "vault",
+ data_dir=tmp_path / "data",
+ local_only=True,
+ )
+ settings.vault_path.mkdir(parents=True, exist_ok=True)
+ rt = build_runtime(settings=settings, offline=True, in_memory=True)
+ set_runtime(rt)
+ yield rt
+ set_runtime(None)
+ rt.close()
+
+
+@pytest.fixture
+async def client(in_mem_runtime) -> AsyncIterator[AsyncClient]:
+ app = create_app()
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
+ yield c
+
+
+def _parse_sse(text: str) -> list[dict[str, object]]:
+ """Return a list of ``{type, data}`` events from a raw SSE response body.
+
+ Loose by intent -- the protocol guarantees ``event:`` then ``data: `` lines
+ separated by a blank line. We don't care about heartbeats, comments, or
+ anything else.
+ """
+ events: list[dict[str, object]] = []
+ cur_type: str | None = None
+ cur_data: list[str] = []
+ for line in text.split("\n"):
+ if line.startswith("event:"):
+ cur_type = line.split(":", 1)[1].strip()
+ elif line.startswith("data:"):
+ cur_data.append(line.split(":", 1)[1].strip())
+ elif line == "" and cur_type:
+ payload = "\n".join(cur_data)
+ try:
+ decoded = json.loads(payload) if payload else {}
+ except json.JSONDecodeError:
+ decoded = {"raw": payload}
+ events.append({"type": cur_type, "data": decoded})
+ cur_type = None
+ cur_data = []
+ return events
+
+
+class TestWhoami:
+ async def test_returns_vault_metadata(self, client: AsyncClient) -> None:
+ r = await client.get("/api/whoami")
+ assert r.status_code == 200
+ body = r.json()
+ assert "vault_name" in body
+ assert "vault_path" in body
+ # Provider matrix should be present even if every provider is unconfigured.
+ assert isinstance(body.get("providers"), list)
+
+
+class TestHealth:
+ async def test_health_endpoint_ok(self, client: AsyncClient) -> None:
+ r = await client.get("/api/health")
+ assert r.status_code == 200
+ assert r.json()["ok"] is True
+
+
+class TestStatus:
+ async def test_status_shape(self, client: AsyncClient) -> None:
+ r = await client.get("/api/status")
+ assert r.status_code == 200
+ body = r.json()
+ assert "notes_indexed" in body
+ assert "chunks" in body
+ assert "providers" in body
+ assert "thermal" in body
+ assert "memory_count" in body
+
+
+class TestSearch:
+ async def test_empty_vault_returns_no_results(self, client: AsyncClient) -> None:
+ r = await client.post(
+ "/api/search",
+ json={"query": "anything", "top_k": 4},
+ )
+ assert r.status_code == 200
+ body = r.json()
+ assert body["query"] == "anything"
+ assert body["results"] == []
+ assert "elapsed_ms" in body
+
+ async def test_validates_payload(self, client: AsyncClient) -> None:
+ r = await client.post("/api/search", json={"top_k": 4})
+ # FastAPI returns 422 on a missing required field.
+ assert r.status_code == 422
+
+
+class TestChatSSE:
+ async def test_empty_query_emits_error_event(self, client: AsyncClient) -> None:
+ r = await client.post(
+ "/api/chat",
+ json={"messages": [{"role": "user", "content": ""}]},
+ )
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("text/event-stream")
+ events = _parse_sse(r.text)
+ assert any(e["type"] == "error" for e in events)
+
+ async def test_emits_expected_event_sequence_for_empty_vault(self, client: AsyncClient) -> None:
+ r = await client.post(
+ "/api/chat",
+ json={"messages": [{"role": "user", "content": "what is foo?"}]},
+ )
+ events = _parse_sse(r.text)
+ types = [e["type"] for e in events]
+ # Provider evaluation always goes first.
+ assert types[0] == "provider"
+ # Then a retrieval event summarising what the index produced.
+ assert "retrieval" in types
+ # With no chunks in the index, the engine short-circuits and emits text+done.
+ assert "text" in types
+ assert "done" in types
+
+ async def test_emits_correct_done_event(self, client: AsyncClient) -> None:
+ r = await client.post(
+ "/api/chat",
+ json={"messages": [{"role": "user", "content": "hello?"}]},
+ )
+ events = _parse_sse(r.text)
+ done_events = [e for e in events if e["type"] == "done"]
+ assert len(done_events) == 1
+ assert "answer" in done_events[0]["data"]
+
+
+class TestAsk:
+ async def test_returns_json_for_empty_vault(self, client: AsyncClient) -> None:
+ r = await client.post(
+ "/api/ask",
+ json={"messages": [{"role": "user", "content": "anything?"}]},
+ )
+ assert r.status_code == 200
+ body = r.json()
+ # Nothing in the index, so the engine returns the standard "no matches" text.
+ assert "answer" in body or "error" in body
+
+
+class TestMemory:
+ async def test_disabled_by_default_returns_409(self, client: AsyncClient) -> None:
+ # The fixture sets local_only=True and the memory is enabled by default.
+ # So this should succeed when memory is enabled. Just smoke the path.
+ r = await client.post(
+ "/api/memory",
+ json={
+ "question": "What is foo?",
+ "answer": "Foo is a placeholder I am testing with.",
+ "sources": ["notes/foo.md"],
+ },
+ )
+ assert r.status_code == 200
+ body = r.json()
+ assert body["ok"] is True
+ assert body.get("saved", "").endswith(".md")
+
+ async def test_recent_returns_the_written_note(self, client: AsyncClient) -> None:
+ await client.post(
+ "/api/memory",
+ json={"question": "R?", "answer": "Yes.", "sources": []},
+ )
+ r = await client.get("/api/memory/recent")
+ assert r.status_code == 200
+ body = r.json()
+ assert isinstance(body, list)
+ assert any(n["question"] == "R?" for n in body)
+
+
+class TestReindex:
+ async def test_reindex_runs_on_empty_vault(self, client: AsyncClient) -> None:
+ r = await client.post("/api/reindex", json={"full": False})
+ assert r.status_code == 200
+ body = r.json()
+ assert "summary" in body
+ assert "scanned" in body
+
+
+class TestCORS:
+ async def test_cors_headers_on_whoami(self, client: AsyncClient) -> None:
+ r = await client.get(
+ "/api/whoami",
+ headers={"Origin": "http://localhost:7331"},
+ )
+ assert r.status_code == 200
+ # The exact header FastAPI emits -- if the CORS middleware were missing,
+ # this would not be present at all.
+ assert r.headers.get("access-control-allow-origin") in {
+ "http://localhost:7331",
+ "*",
+ }
+
+
+class TestGraph:
+ async def test_empty_vault_returns_no_nodes(self, client: AsyncClient) -> None:
+ r = await client.get("/api/graph")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["nodes"] == []
+ assert body["edges"] == []
+ assert body["center"] is None
+
+ async def test_subgraph_around_center_includes_neighbours(self, in_mem_runtime) -> None:
+ # Build a real on-disk vault so the pipeline finishes with indexed
+ # chunks and a non-trivial graph.
+ from cortex.server.dependencies import set_runtime
+
+ vault_path = in_mem_runtime.settings.vault_path
+ (vault_path / "Hub.md").write_text(
+ "---\ntitle: Hub\n---\n# Hub\n\nSee also [[A]] and [[B]]. Also embeds ![[Embed]].\n",
+ encoding="utf-8",
+ )
+ (vault_path / "A.md").write_text("[[Hub]] is the central note.\n", encoding="utf-8")
+ (vault_path / "B.md").write_text("Branch of [[Hub]].\n", encoding="utf-8")
+ (vault_path / "Embed.md").write_text("# Embed\n\nShared block.\n", encoding="utf-8")
+ (vault_path / "Orphan.md").write_text("# Orphan\n\nNo links here.\n", encoding="utf-8")
+ set_runtime(in_mem_runtime)
+
+ app = create_app()
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ reindex = await c.post("/api/reindex", json={"full": False})
+ assert reindex.status_code == 200
+ # Hub should appear in the response and be marked is_hub when no
+ # ``center`` is provided -- top-degree node wins by default.
+ r = await c.get("/api/graph")
+ assert r.status_code == 200
+ body = r.json()
+ ids = {n["id"] for n in body["nodes"]}
+ assert "Hub.md" in ids
+ assert "A.md" in ids
+ assert "B.md" in ids
+ assert "Embed.md" in ids
+ # Orphans are excluded from the link graph (no incoming or
+ # outgoing edges), so they should not be present.
+ assert "Orphan.md" not in ids
+ # Hub should be flagged as the focal point.
+ hub_node = next(n for n in body["nodes"] if n["id"] == "Hub.md")
+ assert hub_node["is_hub"] is True
+ assert body["center"] == "Hub.md"
+ # Hub -> Embed should appear as an embed kind (transclusion, ![[...]]).
+ embed_edge = next(
+ (e for e in body["edges"] if e["source"] == "Hub.md" and e["target"] == "Embed.md"),
+ None,
+ )
+ assert embed_edge is not None
+ assert embed_edge["kind"] == "embed"
+
+ # ``center`` query param focuses the subgraph on a single note and
+ # its one-hop neighbours -- useful when the user clicked a search
+ # result and wants "what does this connect to".
+ r2 = await c.get("/api/graph", params={"center": "A.md"})
+ assert r2.status_code == 200
+ body2 = r2.json()
+ focused_ids = {n["id"] for n in body2["nodes"]}
+ assert focused_ids == {"A.md", "Hub.md"}
+ assert body2["center"] == "A.md"