diff --git a/android-client/app/src/main/java/com/navi/client/MainActivity.kt b/android-client/app/src/main/java/com/navi/client/MainActivity.kt index fa12c55..3f584cc 100644 --- a/android-client/app/src/main/java/com/navi/client/MainActivity.kt +++ b/android-client/app/src/main/java/com/navi/client/MainActivity.kt @@ -71,6 +71,7 @@ } setContentView(R.layout.activity_main) + WebView.setWebContentsDebuggingEnabled(true) webView = findViewById(R.id.webview) setupWebView(prefs) webView.loadUrl(serverUrl) diff --git a/android-client/app/src/main/res/menu/main_menu.xml b/android-client/app/src/main/res/menu/main_menu.xml deleted file mode 100644 index 9ed1811..0000000 --- a/android-client/app/src/main/res/menu/main_menu.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/docs/config.md b/docs/config.md index d452d6a..d9da79f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -19,6 +19,16 @@ so update the `model` field in `navi/profiles/*/config.json` to a cloud model when switching away from local Ollama models. +## LLM Timeouts + +| Variable | Type | Default | Description | +|---|---|---|---| +| `LLM_COMPLETE_TIMEOUT` | int | `120` | Seconds before a non-streaming `complete()` call times out | +| `LLM_STREAM_FIRST_CHUNK_TIMEOUT` | int | `180` | Seconds to wait for the first token of a streaming call (prefill phase) | +| `LLM_STREAM_CHUNK_TIMEOUT` | int | `60` | Max seconds between consecutive tokens in a streaming call | + +Large contexts can take 60–90 s to prefill; `LLM_STREAM_FIRST_CHUNK_TIMEOUT=180` is a safe upper bound for local Ollama. Reduce if using a fast cloud endpoint. + ## Security / Sandboxing | Variable | Type | Default | Description | @@ -34,6 +44,7 @@ | Variable | Type | Default | Description | |---|---|---|---| | `DB_PATH` | str | `navi.db` | SQLite database file path | +| `DATABASE_URL` | str | `""` | PostgreSQL URL (`postgresql://user:pass@host:port/db`). Overrides `DB_PATH` when set. | ## Logging @@ -53,16 +64,29 @@ |---|---|---|---| | `SESSION_FILES_DIR` | str | `session_files` | Directory for uploaded session files | | `SESSION_FILES_MAX_SIZE_MB` | int | `200` | Max upload size per file in megabytes | -| `SESSION_FILES_TTL_HOURS` | int | `24` | Hours before session file directories are cleaned up | + +## Public URL + +| Variable | Type | Default | Description | +|---|---|---|---| +| `PUBLIC_URL` | str | `http://localhost:8000` | Base URL used by `share_file` to build download links. Set this when behind a reverse proxy. | ## Context compression | Variable | Type | Default | Description | |---|---|---|---| | `CONTEXT_COMPRESSION_ENABLED` | bool | `true` | Enable/disable automatic context compression | -| `CONTEXT_COMPRESSION_THRESHOLD` | float | `0.80` | Trigger compression at this fraction of `OLLAMA_NUM_CTX` | -| `CONTEXT_KEEP_RECENT` | int | `10` | Number of recent conversation turns to keep verbatim | +| `CONTEXT_COMPRESSION_THRESHOLD` | float | `0.70` | Trigger compression at this fraction of `OLLAMA_NUM_CTX` | +| `CONTEXT_KEEP_RECENT` | int | `8` | Number of recent conversation turns to keep verbatim | | `CONTEXT_SUMMARY_TEMPERATURE` | float | `0.3` | Temperature for the summarization LLM call | +| `CONTEXT_SUMMARY_MAX_TOKENS` | int | `3000` | Max output tokens for the summary LLM call | + +## Gmail + +| Variable | Type | Default | Description | +|---|---|---|---| +| `GMAIL_ADDRESS` | str | `""` | Gmail address for the `email_manager` tool (IMAP/SMTP with App Password) | +| `GMAIL_APP_PASSWORD` | str | `""` | Gmail App Password (not the account password — generate at myaccount.google.com) | ## Persona @@ -92,8 +116,8 @@ TOOLS_DIR=tools CONTEXT_COMPRESSION_ENABLED=true -CONTEXT_COMPRESSION_THRESHOLD=0.80 -CONTEXT_KEEP_RECENT=10 +CONTEXT_COMPRESSION_THRESHOLD=0.70 +CONTEXT_KEEP_RECENT=8 NAVI_PERSONA_FILE=persona.txt ``` diff --git a/docs/profiles.md b/docs/profiles.md index 1a88459..a2eac6d 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -50,7 +50,8 @@ |---|---|---|---|---| | `secretary` | Personal Secretary | gemma4:31b-cloud | 0.7 | Yes | | `server_admin` | Server Administrator | gemma4:31b-cloud | 0.2 | Yes | -| `developer` | Tool Developer | gemma4:31b-cloud | 0.2 | Yes | +| `developer` | Developer | gemma4:31b-cloud | 0.2 | Yes | +| `tool_developer` | Tool Developer | gemma4:31b-cloud | 0.2 | Yes | All profiles share a base tool set. User tools from `tools/enabled.json` are merged in at runtime. diff --git a/install_essentials.sh b/install_essentials.sh deleted file mode 100644 index 078883e..0000000 --- a/install_essentials.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -# Script to prepare the server for Navi -# Target: Ubuntu 22.04 - -set -e - -echo "[1/5] Updating package lists..." -sudo apt-get update -y - -echo "[2/5] Installing essential system tools..." -sudo apt-get install -y build-essential curl wget git vim unzip software-properties-common \ - python3-pip python3-venv nmap iperf3 net-tools htop btop tree tree - -echo "[3/5] Installing Docker..." -if ! command -v docker &> /dev/string; then - curl -fsSL https://get.docker.com -o get-docker.sh - sudo sh get-docker.sh - sudo usermod -aG docker $USER - rm get-docker.sh - echo "Docker installed successfully." -else - echo "Docker is already installed." -fi - -echo "[4/5] Installing Docker Compose..." -sudo apt-get install -y docker-compose-plugin - -echo "[5/5] Finalizing Python environment..." -python3 -m venv ~/navi_env -source ~/navi_env/bin/activate -pip install --upgrade pip - -echo "--------------------------------------------------" -echo "Server preparation complete!" -echo "Installed: Docker, Docker Compose, Python venv, Nmap, Iperf3, etc." -echo "--------------------------------------------------" diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index 2002c2d..b210692 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -199,26 +199,26 @@ dest = dest.with_name(f"{stem}_{i}{suffix}") i += 1 + # Collect chunks asynchronously, then write in a thread + chunks: list[bytes] = [] size = 0 + while True: + chunk = await file.read(1024 * 1024) # 1 MB at a time + if not chunk: + break + size += len(chunk) + if size > max_bytes: + raise HTTPException( + status_code=413, + detail=f"File exceeds {settings.session_files_max_size_mb} MB limit", + ) + chunks.append(chunk) + try: - with dest.open("wb") as f: - while True: - chunk = await file.read(1024 * 1024) # 1 MB at a time - if not chunk: - break - size += len(chunk) - if size > max_bytes: - f.close() - dest.unlink(missing_ok=True) - raise HTTPException( - status_code=413, - detail=f"File exceeds {settings.session_files_max_size_mb} MB limit", - ) - f.write(chunk) - except HTTPException: - raise + data = b"".join(chunks) + await asyncio.to_thread(dest.write_bytes, data) except Exception as exc: - dest.unlink(missing_ok=True) + await asyncio.to_thread(dest.unlink, True) raise HTTPException(status_code=500, detail=f"Upload failed: {exc}") from exc return { @@ -304,4 +304,4 @@ if not deleted: raise HTTPException(status_code=404, detail="Session not found") # Remove session file directory if it exists - delete_session_dir(session_id) + await delete_session_dir(session_id) diff --git a/navi/session_files.py b/navi/session_files.py index b478069..aa46208 100644 --- a/navi/session_files.py +++ b/navi/session_files.py @@ -46,10 +46,10 @@ return name or "upload" -def delete_session_dir(session_id: str) -> None: +async def delete_session_dir(session_id: str) -> None: d = session_dir(session_id) if d.exists(): - shutil.rmtree(d) + await asyncio.to_thread(shutil.rmtree, d) log.info("session_files.deleted", session_id=session_id) @@ -59,7 +59,8 @@ if not root.exists(): return - for d in root.iterdir(): + entries = await asyncio.to_thread(lambda: list(root.iterdir())) + for d in entries: if not d.is_dir(): continue session_id = d.name @@ -69,7 +70,7 @@ continue if session is None: - shutil.rmtree(d) + await asyncio.to_thread(shutil.rmtree, d) log.info("session_files.orphan_deleted", session_id=session_id) diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py index 007c1b2..9c8470f 100644 --- a/navi/tools/filesystem.py +++ b/navi/tools/filesystem.py @@ -5,6 +5,7 @@ FS_ALLOWED_PATHS=/home/user,/var/www """ +import asyncio import difflib import shutil import stat @@ -310,15 +311,15 @@ try: match action: - case "read": return self._read(path, params) - case "write": return self._write(path, params) - case "append": return self._append(path, params) - case "list": return self._list(path, params) - case "find": return self._find(path, params) - case "find_up": return self._find_up(path, params) - case "info": return self._info(path) - case "move": return self._move(path, params) - case "delete": return self._delete(path) + case "read": return await asyncio.to_thread(self._read, path, params) + case "write": return await asyncio.to_thread(self._write, path, params) + case "append": return await asyncio.to_thread(self._append, path, params) + case "list": return await asyncio.to_thread(self._list, path, params) + case "find": return await asyncio.to_thread(self._find, path, params) + case "find_up": return await asyncio.to_thread(self._find_up, path, params) + case "info": return await asyncio.to_thread(self._info, path) + case "move": return await asyncio.to_thread(self._move, path, params) + case "delete": return await asyncio.to_thread(self._delete, path) case "exists": return ToolResult(success=True, output="true" if path.exists() else "false") case "query": return await self._query(path, params) case "smart_edit": return await self._smart_edit(path, params) @@ -545,7 +546,7 @@ if path.is_dir(): return ToolResult(success=False, output="query works on files, not directories.", error="is_directory") - text = path.read_text(encoding="utf-8", errors="replace") + text = await asyncio.to_thread(path.read_text, "utf-8", "replace") lines = text.splitlines() total = len(lines) chunks = _make_chunks(lines, _AI_CHUNK_CHARS, _AI_OVERLAP_LINES) @@ -596,7 +597,7 @@ if path.is_dir(): return ToolResult(success=False, output="smart_edit works on files, not directories.", error="is_directory") - text = path.read_text(encoding="utf-8", errors="replace") + text = await asyncio.to_thread(path.read_text, "utf-8", "replace") if len(text) > _AI_EDIT_MAX_CHARS: return ToolResult( success=False, @@ -644,12 +645,16 @@ import os new_text = "\n".join(new_lines) + ("\n" if text.endswith("\n") else "") tmp = path.with_suffix(path.suffix + ".tmp") - try: - tmp.write_text(new_text, encoding="utf-8") - os.replace(tmp, path) - finally: - if tmp.exists(): - tmp.unlink(missing_ok=True) + + def _atomic_write() -> None: + try: + tmp.write_text(new_text, encoding="utf-8") + os.replace(tmp, path) + finally: + if tmp.exists(): + tmp.unlink(missing_ok=True) + + await asyncio.to_thread(_atomic_write) summary = ( f"Applied {len(raw_ops)} operation(s) to {path.name} " diff --git a/navi/tools/image_view.py b/navi/tools/image_view.py index 5521777..c50eba7 100644 --- a/navi/tools/image_view.py +++ b/navi/tools/image_view.py @@ -4,6 +4,7 @@ can actually see it (not just read a text description of it). """ +import asyncio import base64 import mimetypes from pathlib import Path @@ -40,7 +41,7 @@ if source.startswith(("http://", "https://")): raw, mime = await self._fetch_url(source) else: - raw, mime = self._read_file(source) + raw, mime = await self._read_file(source) b64 = base64.b64encode(raw).decode() size_kb = len(raw) // 1024 @@ -59,11 +60,12 @@ mime = r.headers.get("content-type", "image/jpeg").split(";")[0].strip() return r.content, mime - def _read_file(self, path_str: str) -> tuple[bytes, str]: + async def _read_file(self, path_str: str) -> tuple[bytes, str]: path = Path(path_str).expanduser().resolve() if not path.exists(): raise FileNotFoundError(f"File not found: {path}") if path.suffix.lower() not in _SUPPORTED: raise ValueError(f"Unsupported image format: {path.suffix}") mime = mimetypes.guess_type(str(path))[0] or "image/jpeg" - return path.read_bytes(), mime + raw = await asyncio.to_thread(path.read_bytes) + return raw, mime diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py index 460ce15..ae56b12 100644 --- a/navi/tools/share_file.py +++ b/navi/tools/share_file.py @@ -1,5 +1,6 @@ """share_file tool — expose a local file to the user as a download link.""" +import asyncio import shutil from pathlib import Path @@ -74,7 +75,7 @@ while dest.exists(): dest = dest_dir / f"{stem}_{i}{suffix}" i += 1 - shutil.copy2(str(src), str(dest)) + await asyncio.to_thread(shutil.copy2, str(src), str(dest)) size = dest.stat().st_size base_url = settings.public_url.rstrip("/") diff --git a/navi/tools/write_tool.py b/navi/tools/write_tool.py index f2ff9e4..de4ad6b 100644 --- a/navi/tools/write_tool.py +++ b/navi/tools/write_tool.py @@ -3,6 +3,7 @@ This is the primary way for the agent to extend itself with new capabilities. """ +import asyncio import json from pathlib import Path @@ -81,7 +82,7 @@ file_path = tools_dir / f"{tool_name}.py" try: - file_path.write_text(code, encoding="utf-8") + await asyncio.to_thread(file_path.write_text, code, "utf-8") except OSError as e: return ToolResult(success=False, output=f"Failed to write file: {e}", error="write_error") @@ -107,7 +108,7 @@ loaded_names = [t.name for t in result.loaded] if tool_name in loaded_names: - _register_user_tool(tool_name, tools_dir) + await asyncio.to_thread(_register_user_tool, tool_name, tools_dir) return ToolResult(success=True, output=f"Tool '{tool_name}' created and enabled globally.\n" + "\n".join(lines)) return ToolResult(success=True, output="\n".join(lines) if lines else "Done.") diff --git a/ssh_hosts.json.example b/ssh_hosts.json.example deleted file mode 100644 index 36f3ce1..0000000 --- a/ssh_hosts.json.example +++ /dev/null @@ -1,16 +0,0 @@ -{ - "myvps": { - "host": "1.2.3.4", - "port": 22, - "username": "root", - "client_keys": ["~/.ssh/id_rsa"], - "known_hosts": "none" - }, - "ubuntu-pass": { - "host": "192.168.1.154", - "port": 22, - "username": "ubuntu", - "password": "yourpassword", - "known_hosts": "none" - } -} diff --git a/test_instagram_api.py b/test_instagram_api.py deleted file mode 100644 index 3ac637a..0000000 --- a/test_instagram_api.py +++ /dev/null @@ -1,71 +0,0 @@ - -import asyncio -import json -import sys -import os - -# Add current directory to sys.path to ensure tools can be imported -sys.path.append(os.getcwd()) - -from tools.instagram_engine import execute - -async def test_instagram_engine(): - print("Starting test for instagram_engine...") - - # Test case 1: Scrape a known public profile (e.g., 'natgeo') - params_success = { - "action": "scrape", - "username": "natgeo", - "limit": 1 - } - - print(f"Testing with params: {params_success}") - try: - result_str = await execute(params_success) - result = json.loads(result_str) - print("Result received successfully.") - print(json.dumps(result, indent=2)) - - if result.get("status") == "success": - print("Test Case 1 (Success Path): PASSED") - elif result.get("status") == "error": - print(f"Test Case 1 (Success Path): FAILED with error: {result.get('message')}") - else: - print(f"Test Case 1 (Success Path): FAILED (Unknown status)") - - except Exception as e: - print(f"Test Case 1 (Success Path): FAILED with exception: {e}") - - # Test case 2: Invalid action - params_invalid_action = { - "action": "delete", - "username": "natgeo" - } - print(f"\nTesting with invalid action: {params_invalid_action}") - try: - result_str = await execute(params_invalid_action) - result = json.loads(result_str) - if "error" in result: - print("Test Case 2 (Invalid Action): PASSED") - else: - print(f"Test Case 2 (Invalid Action): FAILED (Expected error, got: {result})") - except Exception as e: - print(f"Test Case 2 (Invalid/Invalid Action): FAILED with exception: {e}") - - # Test case 3: Missing username - params_missing_username = { - "action": "scrape" - } - print(f"\nTesting with missing username: {params_missing_username}") - try: - result_str = await execute(params_missing_username) - result = json.loads(result_str) - if "error" in result: - print("Test Case 3 (Missing Username): PASSED") - else: - print(f"Test Case 3 (Missing Username): FAILED (Expected error, got: {result})") - except Exception as e: - print(f"Test Case 3 (Missing Username): FAILED with exception: {e}") - -if __name__ == "__main__": - asyncio.run(test_instagram_engine())