Chat & RAG¶
Once the semantic index is built, Jarvis can answer natural-language questions over your vault and structured records — cited, with sensitive data withheld before anything reaches the cloud. Three surfaces sit on top of the same answer loop: the CLI, the MCP server, and the in-app chat tab.
How AgenticRagClient.ask works¶
Asking a question makes exactly two cloud calls, both propose_audited
hops, with one local retrieval round in between:
- Plan hop (
purpose="rag_plan") — the LLM is given the question and a tool schema, and proposes a short list of retrieval steps (e.g. asearch_vaultcall with a query, or aquery_recordscall against an allowlisted collection). Any step that fails validation is dropped before execution — the plan hop can only select what to retrieve, never see or return data itself. - Local execution — no cloud call. Each valid step runs against the
local store:
search_vault(semantic search over the vector index),query_records,person_timeline, orlist_inbox. Results from every step are merged by Reciprocal Rank Fusion into one ranked list, and each item's citation is resolved to a[[note]]wikilink where a matching vault note is found — vault-note chunks already carry one, andcap_items/entitiesresolve when their frontmatter matches; anything else (and any unmatched record) keeps itscollection:idcitation. The list is then split by sensitivity: sensitive items are held back, and the remaining items are capped to the top 8 (configurable) by fused rank. - Synthesize hop (
purpose="rag_answer") — only the retrieved, non-sensitive, top-N items are sent to the LLM, which returns an answer plus citations.
Invariant: the citations ask returns are always a subset of the items
that were actually retrieved and sent to the synthesize hop — a citation the
model invents that isn't in that set is silently dropped. Citations are
never fabricated, even if the model tries.
If any items were withheld for being sensitive, the answer text gets a
trailing note. It's emitted with Markdown italics (the surrounding _), so
it renders emphasized:
_(2 sensitive sources withheld from the cloud answer.)_
Both hops are logged to conv_llm_log (via propose_audited), the same
redacted, fail-closed, audited path every other cloud call in Jarvis uses.
CLI¶
Run from the repo root, with the agent runtime on PYTHONPATH:
PYTHONPATH="agent-runtime/src:modules/conversation/agent" \
python -m conversation_agent.agent ask "What did we agree with the landlord about the deposit?"
ask accepts --limit (default 8) to change the top-N sent to synthesis:
PYTHONPATH="agent-runtime/src:modules/conversation/agent" \
python -m conversation_agent.agent ask "..." --limit 4
Related subcommands of the same agent.py entrypoint, for direct/structured
access without going through the RAG loop:
| Command | Purpose |
|---|---|
ask "<question>" [--limit N] |
Cited natural-language answer via the agentic RAG loop above. |
query <collection> [--filter JSON] [--sort FIELD] [--limit N] |
Structured read over one allowlisted collection. |
timeline <entity_id> [--limit N] |
Cross-module contact timeline for one entity. |
inbox |
List pending platform inbox items. |
index |
(Re)build the semantic index. |
serve-mcp |
Run the MCP server over stdio. |
serve-chat |
Run the chat-tab worker described below. |
All of ask, query, timeline, and inbox need PocketBase reachable and
credentials set (POCKETBASE_URL, DEV_USER_EMAIL, DEV_USER_PASSWORD);
ask additionally needs DEEPSEEK_API_KEY (or LLM_PROVIDER=stub for an
offline canned response) and a built vector index.
The chat tab¶
The chat tab (Jarvis app, jarvis_conversation Flutter package) is a
multi-thread conversational surface backed entirely by PocketBase — the app
never talks to Python directly. All the work happens through two
collections defined by the 3_conv_chat.js migration:
conv_threads— one row per conversation:title,identity.conv_messages— one row per turn:thread(relation, cascade-delete with its thread),role(userorassistant),text,citations(JSON),state,error.
User turns carry a lifecycle: pending → thinking → answered or
error. Assistant turns are written already-terminal, carrying the
answer's text and its citations (wikilinks where resolvable, otherwise
collection:id).
The round trip¶
- The tab creates a
conv_messagesrow for the user's question withstate="pending". - The
serve-chatworker's poll loop claims the oldest pending user turn, flipping it tostate="thinking"(a single-writer claim — only one worker instance should run against a given store). - It mechanically composes the query context from the thread's prior
turns: newest-first, accumulating lines until a character cap is hit,
then replaying the kept turns chronologically ahead of the live question
as
Earlier in this conversation: ...\n\nNow: <question>. This step is pure string assembly — it never calls an LLM. A thread with no prior turns (or one entirely over the cap) just passes the question through unchanged. - It calls
AgenticRagClient.askwith the composed query (the two-hop loop described above). - On success, it creates a new
conv_messagesrow for the assistant's answer (role="assistant",text,citations) and marks the original user turnstate="answered". - On any failure — composing the context, the
askcall itself, or writing the terminal records — the user turn is markedstate="error"with the exception message (truncated) inerror. A single bad turn can't crash the worker's poll loop.
The tab watches both collections and renders turns as their state changes.
Run the worker¶
PYTHONPATH="agent-runtime/src:modules/conversation/agent" \
python -m conversation_agent.agent serve-chat
On startup the worker first requeues any turn orphaned in state="thinking"
back to pending — that happens when a previous worker process died
mid-answer — so a downed worker doesn't leave the tab showing "Thinking…"
forever. It then loops: claim and answer one pending turn if there is one,
otherwise sleep for the poll interval. A downed worker simply stops making
progress — turns pile up as pending — and the backlog drains automatically
once a worker is running again.
Env vars¶
| Var | Default | Purpose |
|---|---|---|
POCKETBASE_URL, DEV_USER_EMAIL, DEV_USER_PASSWORD |
— (required) | PocketBase connection and auth. |
DEEPSEEK_API_KEY |
— | Needed for real answers (LLM_PROVIDER=deepseek); otherwise falls back to the stub provider. |
JARVIS_VECTOR_DB / JARVIS_PB_DATA |
see Semantic index | Sidecar vector store the retrieval step reads. |
JARVIS_VAULT_DIR |
— (optional) | Vault root. When set, cap_items / entities citations resolve to [[note]] wikilinks via note frontmatter; unset, they degrade to collection:id (chunk citations are wikilinks regardless). |
JARVIS_CHAT_POLL_SECS |
2.0 |
Poll interval between checks for pending turns. |
JARVIS_CHAT_CONTEXT_CHARS |
4000 |
Character cap on the whole-thread context assembled per turn. |
The worker also requires the 3_conv_chat.js PocketBase migration applied
(it creates conv_threads and conv_messages). See
Configuration for where each of these vars
is set, and MCP server for the ask tool
exposed to MCP clients like Claude Code — the same agentic loop, a different
surface.