diff --git a/backend/cli.py b/backend/cli.py index 1c771bd..cba97b3 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -71,6 +71,177 @@ from models import Message, ResearchPlan, ResearchStep, StepStatus from session_store import SessionStore +# ═══════════════════════════════════════════════════════════════════════════════ +# Local Document Discovery +# ═══════════════════════════════════════════════════════════════════════════════ + +# File extensions considered for local document scanning. +_SUPPORTED_DOC_EXTENSIONS = frozenset( + {".pdf", ".docx", ".odt", ".txt", ".md", ".csv", ".json"} +) + + +def _extract_text_snippet(file_path: Path, max_chars: int = 500) -> str: + """ + Extract a short text snippet from a file for relevance checking. + + For plain-text formats, reads the first *max_chars* directly. + For binary formats (PDF, DOCX, ODT), attempts extraction via optional + libraries — falls back to empty string if unavailable. + """ + suffix = file_path.suffix.lower() + try: + if suffix in (".txt", ".md", ".csv", ".json"): + return file_path.read_text(encoding="utf-8", errors="replace")[:max_chars] + elif suffix == ".pdf": + try: + import pypdf + except ImportError: + return "" + reader = pypdf.PdfReader(str(file_path)) + text = "" + for page in reader.pages: + text += page.extract_text() or "" + if len(text) >= max_chars: + break + return text[:max_chars] + elif suffix == ".docx": + try: + import docx + except ImportError: + return "" + doc = docx.Document(str(file_path)) + text = "" + for para in doc.paragraphs: + text += para.text + "\n" + if len(text) >= max_chars: + break + return text[:max_chars] + elif suffix == ".odt": + try: + from odf import text as odf_text, teletype + from odf.opendocument import load + except ImportError: + return "" + doc = load(str(file_path)) + paragraphs = [ + teletype.extractText(p) for p in doc.getElementsByType(odf_text.P) + ] + return "\n".join(paragraphs)[:max_chars] + except Exception: + return "" + return "" + + +def _read_full_document(file_path: Path, max_chars: int = 10000) -> str: + """ + Read the full text content of a document (up to *max_chars*). + + Used after user approval to inject document content into the research + pipeline. + """ + suffix = file_path.suffix.lower() + try: + if suffix in (".txt", ".md", ".csv", ".json"): + content = file_path.read_text(encoding="utf-8", errors="replace") + elif suffix == ".pdf": + try: + import pypdf + except ImportError: + return "" + reader = pypdf.PdfReader(str(file_path)) + content = "\n\n".join(p.extract_text() or "" for p in reader.pages) + elif suffix == ".docx": + try: + import docx + except ImportError: + return "" + doc = docx.Document(str(file_path)) + content = "\n\n".join( + para.text for para in doc.paragraphs if para.text.strip() + ) + elif suffix == ".odt": + try: + from odf import text as odf_text, teletype + from odf.opendocument import load + except ImportError: + return "" + doc = load(str(file_path)) + paragraphs = [ + teletype.extractText(p) for p in doc.getElementsByType(odf_text.P) + ] + content = "\n\n".join(p for p in paragraphs if p.strip()) + else: + return "" + except Exception: + return "" + + if len(content) > max_chars: + content = content[:max_chars] + "\n[truncated]" + return content + + +def scan_local_documents(directory: str, query: str, max_files: int = 50) -> List[dict]: + """ + Scan *directory* recursively for supported documents and return those + whose filename or content snippet is relevant to *query*. + + Returns a list of dicts: {"path": str, "name": str, "size_bytes": int, + "match_reason": str}. + """ + root = Path(directory).resolve() + if not root.is_dir(): + return [] + + # Tokenize query into lowercase keywords for matching + query_terms = [t.lower() for t in query.split() if len(t) >= 3] + if not query_terms: + # No meaningful terms — return all supported files + query_terms = [] + + results: List[dict] = [] + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + if file_path.suffix.lower() not in _SUPPORTED_DOC_EXTENSIONS: + continue + if len(results) >= max_files: + break + + name_lower = file_path.stem.lower() + match_reasons: List[str] = [] + + # Check filename match + for term in query_terms: + if term in name_lower: + match_reasons.append(f"filename contains '{term}'") + + # Check content snippet match (only if no filename match yet) + if not match_reasons and query_terms: + snippet = _extract_text_snippet(file_path) + snippet_lower = snippet.lower() + for term in query_terms: + if term in snippet_lower: + match_reasons.append(f"content contains '{term}'") + break # one content match is enough + + # If no query terms provided, include all files + if not query_terms: + match_reasons.append("supported document type") + + if match_reasons: + results.append( + { + "path": str(file_path), + "name": file_path.name, + "size_bytes": file_path.stat().st_size, + "match_reason": "; ".join(match_reasons), + } + ) + + return results + + # ═══════════════════════════════════════════════════════════════════════════════ # CSS # ═══════════════════════════════════════════════════════════════════════════════ @@ -312,6 +483,40 @@ margin-bottom: 1; } +/* ── Local Documents Approval ────────────────────────────────────── */ +#docs-panel { + height: auto; + max-height: 12; + border: round $warning; + padding: 1; + margin-bottom: 1; + display: none; +} + +#docs-panel-title { + text-style: bold; + color: $warning; + margin-bottom: 1; +} + +#docs-actions { + layout: horizontal; + height: 3; + margin-bottom: 1; + display: none; +} + +#btn-approve-docs { + width: 18; + background: $success; + margin-right: 1; +} + +#btn-deny-docs { + width: 18; + background: $error; +} + /* ── Sessions Screen ─────────────────────────────────────────────── */ #sessions-list { height: 1fr; @@ -558,6 +763,7 @@ [bold cyan]/research[/] [dim][/dim] — Deep multi-agent investigation [bold cyan]/chat[/] — Interactive Q&A with the agent [bold cyan]/optimize[/] — Analyse memories & evolve methods + [bold cyan]/docs[/] [dim][/dim] — Set local documents directory [bold cyan]/models[/] — View / change the model backend [bold cyan]/mcp[/] — View / manage MCP servers [bold cyan]/sessions[/] — Browse & resume past sessions @@ -615,7 +821,11 @@ def _status_line(self) -> str: } model = model_env_map.get(backend, "unknown") n_servers = len(self.app.mcp_registry.server_configs) - return f"Backend: {backend} | Model: {model} | MCP servers: {n_servers}" + docs_dir = self.app.docs_dir + status = f"Backend: {backend} | Model: {model} | MCP servers: {n_servers}" + if docs_dir: + status += f" | Docs: {Path(docs_dir).name}/" + return status def _show_feedback(self, text: str) -> None: self.query_one("#home-feedback", Label).update(text) @@ -629,6 +839,7 @@ def _refresh_status(self) -> None: ("/research", "Deep multi-agent investigation"), ("/chat", "Interactive Q&A with the agent"), ("/optimize", "Analyse memories & evolve methods"), + ("/docs", "Set local documents directory"), ("/models", "View / change the model backend"), ("/mcp", "View / manage MCP servers"), ("/sessions", "Browse & resume past sessions"), @@ -690,6 +901,7 @@ def handle_command(self, event: Input.Submitted) -> None: "/research": lambda: self._open_research(arg), "/chat": lambda: self._open_chat(), "/optimize": lambda: self._open_optimize(), + "/docs": lambda: self._set_docs_dir(arg), "/models": lambda: self._open_models(), "/mcp": lambda: self._open_mcp(), "/sessions": lambda: self._open_sessions(), @@ -706,6 +918,18 @@ def _show_help(self) -> None: self._show_feedback("") self.query_one("#home-commands", Static).update(_COMMAND_HELP) + def _set_docs_dir(self, path: str = "") -> None: + if not path: + current = self.app.docs_dir or "(not set)" + self._show_feedback(f"Current docs dir: {current}. Usage: /docs ") + return + resolved = Path(path).expanduser().resolve() + if not resolved.is_dir(): + self._show_feedback(f"Not a valid directory: {resolved}") + return + self.app.docs_dir = str(resolved) + self._show_feedback(f"✔ Docs directory set: {resolved}") + def _open_research(self, query: str = "") -> None: screen = ResearchScreen(self.app.orchestrator, self.app.config) self.app.push_screen(screen) @@ -803,6 +1027,10 @@ def __init__(self, orchestrator: Orchestrator, config: Config) -> None: self._report: str = "" self._step_widgets: dict[int, Label] = {} self._busy = False + # Local document discovery state + self._discovered_docs: List[dict] = [] + self._docs_approved: Optional[bool] = None + self._docs_event: Optional[asyncio.Event] = None # ── Layout ─────────────────────────────────────────────────────────────── @@ -824,6 +1052,14 @@ def compose(self) -> ComposeResult: ) yield Button("▶ Research", id="btn-run-research") + with ScrollableContainer(id="docs-panel"): + yield Label("📂 Local Documents Found", id="docs-panel-title") + yield Static("", id="docs-body") + + with Horizontal(id="docs-actions"): + yield Button("✔ Use Documents", id="btn-approve-docs") + yield Button("✘ Skip", id="btn-deny-docs") + with ScrollableContainer(id="plan-panel"): yield Label("Research Plan", id="plan-panel-title") yield Static( @@ -969,6 +1205,28 @@ def handle_deny(self) -> None: self._show_plan_actions(False) self._run_deny_plan() + @on(Button.Pressed, "#btn-approve-docs") + def handle_approve_docs(self) -> None: + self._docs_approved = True + self._show_docs_panel(False) + self._log( + "[bold green]✔ Local documents approved — will include in research.[/bold green]" + ) + if self._docs_event: + self._docs_event.set() + + @on(Button.Pressed, "#btn-deny-docs") + def handle_deny_docs(self) -> None: + self._docs_approved = False + self._show_docs_panel(False) + self._log("[dim]Local documents skipped.[/dim]") + if self._docs_event: + self._docs_event.set() + + def _show_docs_panel(self, visible: bool) -> None: + self.query_one("#docs-panel").display = visible + self.query_one("#docs-actions").display = visible + # ── Workers ─────────────────────────────────────────────────────────────── @work(exclusive=True, thread=False) @@ -976,8 +1234,57 @@ async def _run_plan(self, query: str) -> None: self._log(f"[bold blue]🔍 Research query:[/bold blue] {query}") self._set_progress(5) + # ── Local document discovery ────────────────────────────────────── + local_documents: Optional[List[dict]] = None + docs_dir = getattr(self.app, "docs_dir", None) + if docs_dir: + self._log(f"[dim]Scanning local documents in {docs_dir}…[/dim]") + max_files = self.config.max_local_docs + discovered = scan_local_documents(docs_dir, query, max_files=max_files) + + if discovered: + self._discovered_docs = discovered + # Show discovered documents to user + lines = [f"Found **{len(discovered)}** relevant document(s):\n"] + for doc in discovered[:20]: # Show at most 20 in the panel + size_kb = doc["size_bytes"] / 1024 + lines.append( + f" • {doc['name']} ({size_kb:.1f} KB) — {doc['match_reason']}" + ) + if len(discovered) > 20: + lines.append(f" … and {len(discovered) - 20} more") + lines.append("\nAllow the agent to read and use these during research?") + self.query_one("#docs-body", Static).update("\n".join(lines)) + self._show_docs_panel(True) + + # Wait for user response + self._docs_event = asyncio.Event() + self._docs_approved = None + await self._docs_event.wait() + self._docs_event = None + + if self._docs_approved: + # Read full contents of approved documents + max_chars = self.config.max_local_doc_chars + local_documents = [] + for doc_info in discovered: + content = _read_full_document( + Path(doc_info["path"]), max_chars=max_chars + ) + if content.strip(): + local_documents.append( + {"name": doc_info["name"], "content": content} + ) + self._log( + f"[green]Loaded {len(local_documents)} document(s) as context.[/green]" + ) + else: + self._log("[dim]No relevant local documents found.[/dim]") + try: - async for msg in self.orchestrator.plan(query): + async for msg in self.orchestrator.plan( + query, local_documents=local_documents + ): if msg.type == "status": self._log(f"[dim]{msg.message}[/dim]") @@ -2166,6 +2473,7 @@ def __init__( long_term_memory: Optional[AsyncLongTermMemory] = None, initial_mode: Optional[str] = None, initial_query: Optional[str] = None, + docs_dir: Optional[str] = None, ) -> None: super().__init__() self.orchestrator = orchestrator @@ -2174,6 +2482,7 @@ def __init__( self.long_term_memory = long_term_memory self._initial_mode = initial_mode self._initial_query = initial_query + self.docs_dir: Optional[str] = docs_dir self.session_store = SessionStore() def on_mount(self) -> None: @@ -2284,6 +2593,10 @@ def main() -> None: default="shallow", help="Research depth (default: shallow)", ) + parser.add_argument( + "--docs-dir", + help="Local directory to scan for relevant documents", + ) args = parser.parse_args() @@ -2312,6 +2625,21 @@ def main() -> None: initial_query = " ".join(args.query) if args.query else None + # Resolve docs directory: CLI arg takes precedence over config/env var + docs_dir = args.docs_dir + if docs_dir: + resolved = Path(docs_dir).expanduser().resolve() + if not resolved.is_dir(): + print( + f"[WARNING] --docs-dir path is not a valid directory: {resolved}", + file=sys.stderr, + ) + docs_dir = None + else: + docs_dir = str(resolved) + elif config.docs_dir: + docs_dir = config.docs_dir + app = ResearchApp( orchestrator=orchestrator, config=config, @@ -2319,6 +2647,7 @@ def main() -> None: long_term_memory=long_term_memory, initial_mode=args.mode, initial_query=initial_query, + docs_dir=docs_dir, ) app.run() diff --git a/backend/config.py b/backend/config.py index 4fc3714..25ea1f3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -215,6 +215,25 @@ class Config(BaseModel): default_factory=lambda: int(os.getenv("GRAPH_COMMUNITY_MIN_MUTATIONS", "3")) ) + # ───────── Local Document Discovery + + # Default directory to scan for local documents relevant to a research query. + # When set, the CLI will offer to include matching files as primary sources. + # Can be overridden at runtime with --docs-dir. + docs_dir: Optional[str] = Field( + default_factory=lambda: os.getenv("DOCS_DIR") or None + ) + + # Maximum number of local documents to consider during discovery. + max_local_docs: int = Field( + default_factory=lambda: int(os.getenv("MAX_LOCAL_DOCS", "50")) + ) + + # Maximum characters to read per local document for content injection. + max_local_doc_chars: int = Field( + default_factory=lambda: int(os.getenv("MAX_LOCAL_DOC_CHARS", "10000")) + ) + # ───────── MCP Servers # Each MCP server exposes tools over the Model Context Protocol. The URL # must point to the server's /mcp endpoint. Optional API keys are forwarded diff --git a/docs/CLI.md b/docs/CLI.md index fda5696..27611b0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -10,9 +10,10 @@ - [Options](#options) - [Research Depth](#research-depth) - [Backend & Model Overrides](#backend--model-overrides) -5. [Mode Picker](#mode-picker) +5. [Home Screen & Slash Commands](#home-screen--slash-commands) 6. [Research Mode](#research-mode) - [Layout](#research-layout) + - [Local Document Discovery](#local-document-discovery) - [Plan Review Workflow](#plan-review-workflow) - [Agent Log Colours](#agent-log-colours) - [Progress Bar](#progress-bar) @@ -86,7 +87,7 @@ pip install -r backend/requirements.txt ```bash # From the backend/ directory -# Open the mode picker +# Open the home screen (slash-command interface) python cli.py # Jump straight to a mode @@ -104,6 +105,10 @@ python cli.py research --depth deep "Detailed analysis of quantum computing" # Override the model backend at launch python cli.py research --backend ollama --model llama3.2 python cli.py chat --backend openai --model gpt-4o + +# Scan a local directory for relevant documents during research +python cli.py research --docs-dir ./my-papers "survey of transformer architectures" +python cli.py research --docs-dir ~/Documents/research-data ``` --- @@ -111,7 +116,7 @@ python cli.py chat --backend openai --model gpt-4o ## Invocation & Arguments ``` -python cli.py [mode] [query ...] [--backend BACKEND] [--model MODEL] [--depth DEPTH] +python cli.py [mode] [query ...] [--backend BACKEND] [--model MODEL] [--depth DEPTH] [--docs-dir PATH] ``` ### Positional Arguments @@ -128,6 +133,7 @@ python cli.py [mode] [query ...] [--backend BACKEND] [--model MODEL] [--depth DE | `--backend` | Override the model backend. Choices: `openai`, `ollama`, `bedrock`, `azure`, `huggingface`, `gcp`. | | `--model` | Override the specific model name within the chosen backend. | | `--depth` | Research depth. Choices: `shallow` (default), `moderate`, `deep`. Controls whether the QA loop runs and how many retries it gets. See [Research Depth](#research-depth) below. | +| `--docs-dir` | Path to a local directory to scan for relevant documents. When set, the CLI discovers matching files and asks for your permission before including them in the research pipeline. See [Local Document Discovery](#local-document-discovery). | | `-h`, `--help` | Print the help message and exit. | ### Research Depth @@ -157,11 +163,28 @@ The mapping of `--backend` to environment variable: --- -## Mode Picker +## Home Screen & Slash Commands + +The home screen is the default landing screen. It displays an ASCII banner, status line (showing active backend, model, MCP server count, and docs directory if set), and an input field for slash commands. -The mode picker is the default landing screen. It presents three buttons (also reachable via keyboard shortcuts `1`, `2`, `3`) and requires no model interaction — it is purely navigational. +**Available slash commands:** + +| Command | Description | +|---|---| +| `/research ` | Open Research mode, optionally with a pre-filled query | +| `/chat` | Open Chat mode | +| `/optimize` | Open Self-Optimize mode | +| `/docs ` | Set or change the local documents directory for research | +| `/models` | View / change the model backend | +| `/mcp` | View / manage MCP servers | +| `/sessions` | Browse & resume past sessions | +| `/new` | Start a fresh session (resets orchestrator state) | +| `/help` | Show the command list | +| `/exit` | Quit the application | -**Key bindings on the mode picker:** +The input field provides **autocomplete** — as you type a `/` prefix, a dropdown shows matching commands. + +**Key bindings on the home screen:** | Key | Action | |---|---| @@ -170,6 +193,20 @@ The mode picker is the default landing screen. It presents three buttons (also r | `3` | Open Self-Optimize mode | | `q` | Quit the application | +### The `/docs` Command + +Use `/docs ` to set a local directory that will be scanned for relevant documents whenever you start a research run. The path can be absolute or relative (supports `~` expansion). + +``` +/docs ~/Documents/research-papers +/docs ./data/references +/docs # shows the currently set directory +``` + +When a docs directory is active, the status line at the top of the home screen shows the directory name. The setting persists for the duration of the CLI session. + +You can also set this via the `--docs-dir` CLI argument or the `DOCS_DIR` environment variable (in `.env` or exported). + --- ## Research Mode @@ -177,7 +214,7 @@ The mode picker is the default landing screen. It presents three buttons (also r Research mode runs the full **Orchestrator** pipeline: ``` -Query → plan() → [Approve / Modify / Deny] → execute() → synthesise() → Report +Query → [Document Discovery → Permission] → plan() → [Approve / Modify / Deny] → execute() → synthesise() → Report ``` ### Research Layout @@ -186,22 +223,30 @@ Query → plan() → [Approve / Modify / Deny] → execute() → synthesise() ┌─────────────────────┬──────────────────────────────────────────┐ │ 📋 Research Plan │ [ query input field ] [▶ Research]│ │ │ │ -│ Goal: … │ ┌─ Research Plan ─────────────────────┐ │ -│ Steps: │ │ Goal: … │ │ -│ 1. … (pending) │ │ - Step 1: … │ │ -│ 2. … (active) │ │ - Step 2: … │ │ -│ 3. … (done ✓) │ └─────────────────────────────────────┘ │ +│ Goal: … │ ┌─ 📂 Local Documents Found ──────────┐ │ +│ Steps: │ │ Found 3 relevant document(s): │ │ +│ 1. … (pending) │ │ • paper.pdf (245.3 KB) — filename │ │ +│ 2. … (active) │ │ contains 'transformer' │ │ +│ 3. … (done ✓) │ │ • notes.md (12.1 KB) — content │ │ +│ │ │ contains 'attention' │ │ +│ │ └──────────────────────────────────────┘ │ +│ │ [✔ Use Documents] [✘ Skip] │ +│ │ │ +│ │ ┌─ Research Plan ─────────────────────┐ │ +│ │ │ Goal: … │ │ +│ │ │ - Step 1: … │ │ +│ │ │ - Step 2: … │ │ +│ │ └─────────────────────────────────────┘ │ │ │ │ │ │ [✔ Approve] [✏ Modify] [✘ Deny] │ │ │ │ │ │ ████████████░░░░ 65% │ │ │ │ │ │ ┌─ Agent Log ─────────────────────────┐ │ +│ │ │ [dim] Scanning local documents… │ │ +│ │ │ [green] Loaded 3 document(s)… │ │ │ │ │ [dim] Orchestrator: analysing… │ │ │ │ │ [cyan] [Search] Gathering data… │ │ -│ │ │ [blue] [Analyst] Extracting… │ │ -│ │ │ [yellow] [QA] Auditing… │ │ -│ │ │ [green] ✔ Step 1 complete │ │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ │ ┌─ 📄 Research Report ────────────────┐ │ @@ -212,12 +257,49 @@ Query → plan() → [Approve / Modify / Deny] → execute() → synthesise() ``` - **Left sidebar** — live plan step tracker; each step label updates its colour as the pipeline progresses. +- **Documents panel** — appears only when a docs directory is set and relevant files are found. Shows matched files with sizes and match reasons. Hidden again after the user responds. - **Plan panel** — shows the goal and step list as formatted text; updated in-place when the plan is modified. - **Action bar** — only visible after a plan is generated or after a modification. - **Progress bar** — hidden until research starts; updates at key milestones. - **Agent log** — scrollable, colour-coded stream of all agent activity. - **Report panel** — hidden until synthesis is complete; renders the final Markdown report inline. +### Local Document Discovery + +When a docs directory is configured (via `--docs-dir`, `/docs`, or the `DOCS_DIR` environment variable), the CLI automatically scans it before generating a research plan. The workflow is: + +1. **Scan** — Recursively walks the directory for supported file types (`.pdf`, `.docx`, `.odt`, `.txt`, `.md`, `.csv`, `.json`), up to a configurable cap (default: 50 files, controlled by `MAX_LOCAL_DOCS`). + +2. **Relevance filter** — Matches query keywords against filenames and the first ~500 characters of file content. Only files with at least one keyword match are shown. If the query has no meaningful terms (< 3 chars per word), all supported files are included. + +3. **Permission prompt** — Displays the discovered documents in a panel with: + - File name and size + - Match reason (e.g., "filename contains 'quantum'" or "content contains 'entanglement'") + - Two action buttons: **✔ Use Documents** and **✘ Skip** + +4. **Content injection** — If approved, full file contents are read (up to `MAX_LOCAL_DOC_CHARS` per file, default: 10,000) and passed to `Orchestrator.plan()`. The orchestrator injects them into the planning prompt as "Local reference documents" — treated as primary sources that the agent avoids re-researching. + +5. **Skip** — If denied, research proceeds normally without local document context. + +**Supported file types and extraction:** + +| Extension | Extraction method | +|---|---| +| `.txt`, `.md`, `.csv`, `.json` | Direct UTF-8 read | +| `.pdf` | `pypdf` page-by-page text extraction | +| `.docx` | `python-docx` paragraph extraction | +| `.odt` | `odfpy` paragraph extraction | + +> **Note:** PDF/DOCX/ODT extraction requires the corresponding Python libraries (`pypdf`, `python-docx`, `odfpy`). If a library is not installed, files of that type are silently skipped during scanning. These are already in `requirements.txt`. + +**Configuration options:** + +| Variable | Default | Description | +|---|---|---| +| `DOCS_DIR` | (unset) | Default directory to scan. Overridden by `--docs-dir` or `/docs`. | +| `MAX_LOCAL_DOCS` | 50 | Maximum number of files to discover per scan. | +| `MAX_LOCAL_DOC_CHARS` | 10000 | Maximum characters read per document for injection. | + ### Plan Review Workflow After the Orchestrator returns a plan, three action buttons appear: @@ -405,7 +487,7 @@ Both sections are rendered as Markdown. If the agent has no stored memories yet When `cli.py` is invoked, the `main()` function runs the following setup *before* the TUI launches: ``` -main() (--depth passed as research_depth) +main() (--depth passed as research_depth, --docs-dir resolved) └── asyncio.run(_bootstrap(research_depth)) ├── Config() # reads .env + env vars ├── create_mcp_registry(config) # connects to MCP servers (best-effort) @@ -418,9 +500,13 @@ main() (--depth passed as research_depth) agent_pool=agent_pool, long_term_memory=ltm, research_depth=research_depth) + └── Resolve docs_dir (--docs-dir > Config.docs_dir > None) + └── ResearchApp(orchestrator, config, mcp_registry, # TUI application + long_term_memory, initial_mode, + initial_query, docs_dir) ``` -All four objects (`orchestrator`, `config`, `mcp_registry`, `long_term_memory`) are passed into `ResearchApp` and shared across screens via `self.app.*`. `SelfOptimizeScreen` receives `long_term_memory` directly so it can pass it to its `SelfOptimizingAgent` instance. +All four objects (`orchestrator`, `config`, `mcp_registry`, `long_term_memory`) are passed into `ResearchApp` and shared across screens via `self.app.*`. The `docs_dir` is stored on `self.app.docs_dir` and can be changed at runtime via the `/docs` slash command. `SelfOptimizeScreen` receives `long_term_memory` directly so it can pass it to its `SelfOptimizingAgent` instance. MCP server connections are attempted at startup but failures are non-fatal — the CLI will start even if MCP servers are offline, though features that depend on them (web search, file handling, memory) will not work until the servers are available.