diff --git a/README.md b/README.md index 5bd5f04..7e694aa 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ Полная спецификация: [`docs/websocket.md`](docs/websocket.md) и [`docs/api.md`](docs/api.md) +## Navi Code — терминальный режим + +Локальный вариант Navi без авторизации, управляемый из терминала: + +```bash +# 1. Скопировать конфиг для терминального режима +cp .env.navi_code.example .env + +# 2. Запустить сервер (см. раздел "Запуск" выше) + +# 3. В другом терминале +navi-code +# или one-shot +navi-code "перепиши функцию на async/await" +``` + +- Профиль по умолчанию: `navi_code`. +- Без авторизации: `NAVI_AUTH_ENABLED=false`. +- CLI сохраняет `session_id` в `~/.navi_code/state.json`. + +Подробнее: [`docs/navi_code.md`](docs/navi_code.md) и [`docs/navi_code_cli.md`](docs/navi_code_cli.md). + ## Структура ``` @@ -80,7 +102,7 @@ ├── exceptions.py # доменные исключения ├── llm/ # LLM бэкенды: ollama.py, openai_backend.py ├── tools/ # встроенные инструменты (~20 шт.) -├── profiles/ # профили агентов: secretary, server_admin, developer, tool_developer, discuss, modeler_3d +├── profiles/ # профили агентов: secretary, server_admin, developer, tool_developer, discuss, modeler_3d, navi_code ├── core/ # Agent, registry, session, compressor, events ├── memory/ # долгосрочная память (PostgreSQL + pgvector) ├── workers/ # post-turn workers (CompressionWorker, MemoryWorker) @@ -93,9 +115,13 @@ ├── gmail.py └── weather.py +clients/ # клиенты +└── terminal/ # CLI-клиент Navi Code (navi-code) + manuals/ # markdown-мануалы для tool_manual docs/ # архитектурная документация persona.txt # глобальная личность и инструкции агента +persona_navi_code.txt # персона для терминального режима ``` ## Профили @@ -108,6 +134,7 @@ | `tool_developer` | Написание, тестирование и отладка пользовательских инструментов | 0.35 | ✓ | | `discuss` | Свободное обсуждение, мозговой штурм, лёгкие беседы | 0.85 | — | | `modeler_3d` | 3D-моделирование для 3D-печати (OpenSCAD → STL) | 0.35 | ✓ | +| `navi_code` | Локальный терминальный кодинг-ассистент | 0.35 | ✓ | `tool_developer` — единственный профиль с `reload_tools`, `delete_tool` и `test_tool`. diff --git a/clients/terminal/__init__.py b/clients/terminal/__init__.py new file mode 100644 index 0000000..052ed77 --- /dev/null +++ b/clients/terminal/__init__.py @@ -0,0 +1,3 @@ +"""Terminal client for Navi Code.""" + +__version__ = "0.1.0" diff --git a/clients/terminal/__main__.py b/clients/terminal/__main__.py new file mode 100644 index 0000000..c9c8f98 --- /dev/null +++ b/clients/terminal/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for `python -m clients.terminal`.""" + +from clients.terminal.cli import main + +if __name__ == "__main__": + main() diff --git a/clients/terminal/api.py b/clients/terminal/api.py new file mode 100644 index 0000000..400214b --- /dev/null +++ b/clients/terminal/api.py @@ -0,0 +1,49 @@ +"""REST API helpers for the terminal client.""" + +from __future__ import annotations + +import httpx + +from clients.terminal.config import settings + + +def _client() -> httpx.Client: + return httpx.Client(base_url=settings.base_url, timeout=30.0) + + +def get_profiles() -> list[dict]: + with _client() as client: + resp = client.get("/agents/profiles") + resp.raise_for_status() + return resp.json() + + +def list_sessions() -> list[dict]: + with _client() as client: + resp = client.get("/sessions") + resp.raise_for_status() + return resp.json() + + +def create_session(profile_id: str | None = None) -> dict: + body: dict = {} + if profile_id: + body["profile_id"] = profile_id + with _client() as client: + resp = client.post("/sessions", json=body) + resp.raise_for_status() + return resp.json() + + +def get_session(session_id: str) -> dict: + with _client() as client: + resp = client.get(f"/sessions/{session_id}") + resp.raise_for_status() + return resp.json() + + +def stop_session(session_id: str) -> dict: + with _client() as client: + resp = client.post(f"/sessions/{session_id}/stop") + resp.raise_for_status() + return resp.json() diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py new file mode 100644 index 0000000..54dd80f --- /dev/null +++ b/clients/terminal/cli.py @@ -0,0 +1,212 @@ +"""Click CLI for the Navi Code terminal client.""" + +from __future__ import annotations + +import asyncio + +import click + +from clients.terminal import api +from clients.terminal.config import settings +from clients.terminal.render import Renderer +from clients.terminal.state import StateManager +from clients.terminal.ws_client import NaviWebSocketClient + + +@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True}) +@click.argument("prompt", required=False) +@click.option("--base-url", default=None, help="Navi API base URL.") +@click.option("--ws-url", default=None, help="Navi WebSocket URL (defaults to base-url).") +@click.option("--profile-id", default=None, help="Profile to use for new sessions.") +@click.option("--new-session", is_flag=True, help="Create a new session even if state exists.") +@click.option("--show-thinking", is_flag=True, help="Show model reasoning blocks.") +@click.option("--show-events/--no-events", default=True, help="Show tool call events.") +@click.version_option(version="0.1.0", prog_name="navi-code") +def main( + prompt: str | None, + base_url: str | None, + ws_url: str | None, + profile_id: str | None, + new_session: bool, + show_thinking: bool, + show_events: bool, +) -> None: + """Navi Code — terminal client for Navi. + + Without PROMPT, runs interactive chat mode. With PROMPT, sends one message + and exits after the response completes. + """ + if base_url: + settings.base_url = base_url + if ws_url: + settings.ws_url = ws_url + if show_thinking: + settings.show_thinking = True + settings.show_events = show_events + + state = StateManager() + + session_id = _resolve_session_id(state, new_session, profile_id) + if not session_id: + raise click.ClickException("Failed to create or resume a session.") + + renderer = Renderer(show_thinking=settings.show_thinking, show_events=settings.show_events) + client = NaviWebSocketClient(session_id, renderer=renderer) + + if prompt: + asyncio.run(_run_one_shot(client, prompt)) + return + + asyncio.run(_run_interactive(client, state)) + + +def _resolve_session_id(state: StateManager, force_new: bool, profile_id: str | None) -> str | None: + if not force_new: + saved = state.get_session_id() + if saved: + try: + session = api.get_session(saved) + click.secho( + f"Resumed session {session['id'][:8]} (profile {session['profile_id']})", + fg="bright_black", + ) + return session["id"] + except Exception: + state.clear_session_id() + + profile = profile_id or settings.default_profile_id + try: + session = api.create_session(profile) + except Exception as exc: + click.secho(f"Failed to create session: {exc}", fg="red", err=True) + return None + + state.set_session_id(session["id"]) + click.secho( + f"Created session {session['id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return session["id"] + + +async def _run_one_shot(client: NaviWebSocketClient, prompt: str) -> None: + await client.run_one_shot(prompt) + + +async def _run_interactive(client: NaviWebSocketClient, state: StateManager) -> None: + click.secho("Navi Code interactive mode. Type /quit to exit, /help for commands.", fg="cyan") + + await client.connect() + receive_task = asyncio.create_task(client.receive_loop()) + + try: + while True: + try: + user_input = await asyncio.get_event_loop().run_in_executor( + None, lambda: input(click.style("You: ", fg="blue", bold=True)), + ) + except EOFError: + break + + user_input = user_input.strip() + if not user_input: + continue + + if user_input.startswith("/"): + handled = await _handle_command(user_input, state, client) + if handled: + continue + + client.enqueue(user_input) + finally: + client.stop_input() + receive_task.cancel() + try: + await receive_task + except asyncio.CancelledError: + pass + await client.close() + + +async def _handle_command(cmd: str, state: StateManager, client: NaviWebSocketClient) -> bool: + parts = cmd.split() + head = parts[0].lower() + + if head == "/quit": + raise click.exceptions.Exit(0) + + if head == "/help": + click.echo("Commands:") + click.echo(" /new create a new session") + click.echo(" /sessions list server sessions") + click.echo(" /switch switch to another session") + click.echo(" /profile show current session profile") + click.echo(" /clear clear local session state") + click.echo(" /quit exit") + return True + + if head == "/new": + try: + session = api.create_session(settings.default_profile_id) + except Exception as exc: + click.secho(f"Failed to create session: {exc}", fg="red", err=True) + return True + state.set_session_id(session["id"]) + click.secho( + f"Switched to new session {session['id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return True + + if head == "/sessions": + try: + sessions = api.list_sessions() + except Exception as exc: + click.secho(f"Failed to list sessions: {exc}", fg="red", err=True) + return True + for s in sessions: + click.echo(f" {s['id'][:8]} {s.get('profile_id', 'unknown')} {s.get('title', '')}") + return True + + if head == "/switch" and len(parts) == 2: + target = parts[1] + try: + session = api.get_session(target) + except Exception as exc: + # Try to find by prefix + try: + sessions = api.list_sessions() + matches = [s for s in sessions if s["id"].startswith(target)] + if len(matches) == 1: + session = matches[0] + else: + raise exc + except Exception: + click.secho(f"Session not found: {target}", fg="red", err=True) + return True + state.set_session_id(session["id"]) + click.secho( + f"Switched to session {session['id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return True + + if head == "/profile": + try: + current = api.get_session(state.get_session_id() or "") + click.echo(f"Profile: {current.get('profile_id', 'unknown')}") + click.echo(f"Session: {current['id']}") + except Exception as exc: + click.secho(f"Failed to get session: {exc}", fg="red", err=True) + return True + + if head == "/clear": + state.clear_session_id() + click.secho("Local session state cleared.", fg="green") + return True + + return False + + +if __name__ == "__main__": + main() diff --git a/clients/terminal/config.py b/clients/terminal/config.py new file mode 100644 index 0000000..6fa5ff4 --- /dev/null +++ b/clients/terminal/config.py @@ -0,0 +1,37 @@ +"""Pydantic settings for the terminal client.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuration loaded from environment / .env file.""" + + model_config = SettingsConfigDict( + env_prefix="NAVI_CODE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + base_url: str = "http://localhost:8000" + ws_url: str | None = None + state_dir: Path = Path.home() / ".navi_code" + default_profile_id: str = "navi_code" + show_thinking: bool = False + show_events: bool = True + + def websocket_url(self, session_id: str) -> str: + """Build WebSocket URL for a session.""" + base = (self.ws_url or self.base_url).rstrip("/") + if base.startswith("http://"): + base = base.replace("http://", "ws://", 1) + elif base.startswith("https://"): + base = base.replace("https://", "wss://", 1) + return f"{base}/ws/sessions/{session_id}" + + +settings = Settings() diff --git a/clients/terminal/render.py b/clients/terminal/render.py new file mode 100644 index 0000000..ecfffcb --- /dev/null +++ b/clients/terminal/render.py @@ -0,0 +1,89 @@ +"""Terminal rendering helpers for Navi WebSocket events.""" + +from __future__ import annotations + +import click + + +class Renderer: + """Render stream events to the terminal.""" + + def __init__(self, show_thinking: bool = False, show_events: bool = True) -> None: + self.show_thinking = show_thinking + self.show_events = show_events + self._thinking_buffer: list[str] = [] + self._in_thinking = False + + def _print(self, text: str = "", *, color: str | None = None, nl: bool = True) -> None: + click.secho(text, fg=color, nl=nl) + + def render(self, msg: dict) -> None: + msg_type = msg.get("type") + + if msg_type == "heartbeat": + return + + if msg_type == "session_sync": + if self.show_events: + profile_id = msg.get("profile_id") or "unknown" + self._print(f"[session {msg.get('session_id')[:8]} | profile {profile_id}]", color="bright_black") + return + + if msg_type == "stream_start": + if self.show_events: + self._print("[Navi] ", color="cyan", nl=False) + return + + if msg_type == "thinking_delta": + if self.show_thinking: + self._thinking_buffer.append(msg.get("delta", "")) + return + + if msg_type == "thinking_end": + if self.show_thinking and self._thinking_buffer: + self._print("\n[thinking]", color="bright_black") + self._print("".join(self._thinking_buffer), color="bright_black") + self._print("[/thinking]", color="bright_black") + self._thinking_buffer.clear() + return + + if msg_type == "stream_delta": + self._print(msg.get("delta", ""), nl=False) + return + + if msg_type == "tool_started": + if self.show_events: + tool = msg.get("tool", "?") + args = msg.get("args") or {} + self._print(f"\n[tool: {tool}]", color="yellow") + if args: + self._print(str(args), color="bright_black") + return + + if msg_type == "tool_call": + if self.show_events: + tool = msg.get("tool", "?") + success = msg.get("success", True) + color = "green" if success else "red" + self._print(f"[tool result: {tool} success={success}]", color=color) + result = msg.get("result") + if result: + preview = str(result).replace("\n", " ")[:400] + self._print(preview, color="bright_black") + return + + if msg_type == "stream_end": + self._print() # newline after response + return + + if msg_type == "error": + self._print(f"\n[error] {msg.get('message', 'unknown error')}", color="red") + return + + if msg_type == "context_compressed": + if self.show_events: + self._print("[context compressed]", color="bright_black") + return + + if self.show_events: + self._print(f"[event: {msg_type}] {msg}", color="bright_black") diff --git a/clients/terminal/state.py b/clients/terminal/state.py new file mode 100644 index 0000000..6a03213 --- /dev/null +++ b/clients/terminal/state.py @@ -0,0 +1,47 @@ +"""Persistent session state for the terminal client.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from clients.terminal.config import settings + + +class StateManager: + """Read/write ~/.navi_code/state.json.""" + + def __init__(self, state_dir: Path | None = None) -> None: + self.state_dir = state_dir or settings.state_dir + self.state_file = self.state_dir / "state.json" + + def _ensure_dir(self) -> None: + self.state_dir.mkdir(parents=True, exist_ok=True) + + def load(self) -> dict[str, Any]: + if not self.state_file.exists(): + return {} + try: + with self.state_file.open("r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + def save(self, data: dict[str, Any]) -> None: + self._ensure_dir() + with self.state_file.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def get_session_id(self) -> str | None: + return self.load().get("session_id") + + def set_session_id(self, session_id: str) -> None: + state = self.load() + state["session_id"] = session_id + self.save(state) + + def clear_session_id(self) -> None: + state = self.load() + state.pop("session_id", None) + self.save(state) diff --git a/clients/terminal/ws_client.py b/clients/terminal/ws_client.py new file mode 100644 index 0000000..6630e3c --- /dev/null +++ b/clients/terminal/ws_client.py @@ -0,0 +1,106 @@ +"""WebSocket client for streaming Navi responses.""" + +from __future__ import annotations + +import asyncio +import json + +import websockets + +from clients.terminal.config import settings +from clients.terminal.render import Renderer + + +class NaviWebSocketClient: + """Connect to /ws/sessions/ and render events.""" + + def __init__( + self, + session_id: str, + renderer: Renderer | None = None, + ) -> None: + self.session_id = session_id + self.renderer = renderer or Renderer( + show_thinking=settings.show_thinking, + show_events=settings.show_events, + ) + self.url = settings.websocket_url(session_id) + self._ws: websockets.ClientConnection | None = None + self._stop_event = asyncio.Event() + self._input_queue: asyncio.Queue[str | None] = asyncio.Queue() + + async def connect(self) -> None: + self._ws = await websockets.connect(self.url) + + async def close(self) -> None: + if self._ws: + await self._ws.close() + self._ws = None + + async def send(self, content: str) -> None: + if not self._ws: + raise RuntimeError("WebSocket is not connected") + await self._ws.send(json.dumps({"type": "message", "content": content})) + + async def receive_loop(self) -> None: + if not self._ws: + raise RuntimeError("WebSocket is not connected") + try: + async for raw in self._ws: + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + self.renderer.render(msg) + if msg.get("type") in ("stream_end", "error"): + self._stop_event.set() + except websockets.exceptions.ConnectionClosed: + pass + + async def input_loop(self) -> None: + while True: + content = await self._input_queue.get() + if content is None: + break + await self.send(content) + + def enqueue(self, content: str) -> None: + self._input_queue.put_nowait(content) + + def stop_input(self) -> None: + self._input_queue.put_nowait(None) + + async def wait_for_stream_end(self, timeout: float = 600.0) -> None: + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=timeout) + except asyncio.TimeoutError: + pass + + def reset_stop(self) -> None: + self._stop_event.clear() + + async def run_one_shot(self, content: str) -> None: + await self.connect() + try: + receive_task = asyncio.create_task(self.receive_loop()) + await self.send(content) + await self.wait_for_stream_end() + self.stop_input() + receive_task.cancel() + try: + await receive_task + except asyncio.CancelledError: + pass + finally: + await self.close() + + async def run_interactive(self) -> None: + await self.connect() + try: + receive_task = asyncio.create_task(self.receive_loop()) + input_task = asyncio.create_task(self.input_loop()) + await asyncio.gather(receive_task, input_task) + except websockets.exceptions.ConnectionClosed: + pass + finally: + await self.close() diff --git a/docs/config.md b/docs/config.md index f8071d4..cb1ad43 100644 --- a/docs/config.md +++ b/docs/config.md @@ -162,6 +162,7 @@ |---|---|---|---| | `NAVI_PERSONA` | str | `""` | Global personality prompt prepended to every profile's system prompt | | `NAVI_PERSONA_FILE` | str | `""` | Path to a `.txt` file containing the persona (preferred over inline `NAVI_PERSONA`) | +| `NAVI_DEFAULT_PROFILE_ID` | str | `""` | Profile used when `POST /sessions` is called without `profile_id`. Empty means the client must supply a profile. | **Recommended:** use `NAVI_PERSONA_FILE=persona.txt` rather than inlining the persona in `.env`, because multi-line values don't parse reliably in `.env` files. diff --git a/docs/navi_code.md b/docs/navi_code.md new file mode 100644 index 0000000..5a71a6d --- /dev/null +++ b/docs/navi_code.md @@ -0,0 +1,81 @@ +# Navi Code — локальный терминальный режим + +Navi Code — это вариант Navi для локального запуска через терминал. Без авторизации, с выделенным профилем `navi_code`, ориентированным на работу с кодом, shell и файловой системой. + +## Что входит + +- Профиль `navi_code` (терминальный кодинг-ассистент). +- Механизм дефолтного профиля через `NAVI_DEFAULT_PROFILE_ID`. +- Персона `persona_navi_code.txt`. +- Готовый `.env.navi_code.example`. +- CLI-клиент `navi-code`. + +## Что НЕ входит + +- Docker-упаковка (отложено). +- Web UI для изображений / публикации контента (`content_publish`, `share_file` отключены в профиле). +- Авторизация (`NAVI_AUTH_ENABLED=false`). + +## Быстрый старт + +1. Убедитесь, что запущены PostgreSQL с `pgvector` и Ollama с нужной моделью. +2. Скопируйте пример конфигурации: + ```bash + cp .env.navi_code.example .env + ``` +3. Установите / обновите зависимости: + ```bash + pip install -e ".[dev]" + ``` +4. Запустите сервер: + ```bash + .venv/bin/uvicorn navi.main:app --reload --reload-dir navi --port 8000 + ``` +5. В другом терминале запустите клиент: + ```bash + navi-code + # или + python -m clients.terminal + ``` + +## Конфигурация `.env` + +Ключевые переменные для Navi Code: + +```dotenv +NAVI_AUTH_ENABLED=false +NAVI_DEFAULT_PROFILE_ID=navi_code +NAVI_PERSONA_FILE=persona_navi_code.txt + +OLLAMA_HOST=http://localhost:11434 +OLLAMA_DEFAULT_MODEL=gemma4:26b-a4b-it-q4_K_M +OLLAMA_NUM_CTX=8192 +OLLAMA_THINK=true + +DATABASE_URL=postgresql://navi:navipass@localhost:5432/navidb + +FS_ALLOWED_PATHS=* +TERMINAL_ALLOWED_COMMANDS=* +``` + +> **Важно:** `FS_ALLOWED_PATHS=*` и `TERMINAL_ALLOWED_COMMANDS=*` дают Нави полный доступ к файловой системе и shell. Используйте только на доверенной локальной машине. + +## Профиль `navi_code` + +- Расположение: `navi/profiles/navi_code/`. +- База: `developer`, адаптирован под терминал. +- Включённые инструменты: `terminal`, `filesystem`, `code_exec`, `spawn_agent`, `todo`, `scratchpad`, `reflect`, `list_tools`, `tool_manual`, `write_tool`, `reload_tools`. +- Отключённые инструменты: `share_file`, `content_publish`, `image_view`, `http_request`, `web_search`, `ssh_exec`, `gmail`. +- `planning_phase2_enabled: false` — уменьшает latency. + +## Безопасность + +- Нави всегда должна спрашивать подтверждение перед разрушительными операциями (`rm`, перезапись файлов, форматирование). +- Не выставляйте сервер Navi Code в интернет без авторизации. +- `TERMINAL_ALLOWED_COMMANDS=*` — это полный shell-доступ; убедитесь, что сервер запущен от пользователя с ограниченными правами, если экспериментируете. + +## См. также + +- [`docs/navi_code_cli.md`](navi_code_cli.md) — справка по CLI-клиенту. +- [`docs/profiles.md`](profiles.md) — устройство профилей. +- [`docs/config.md`](config.md) — все переменные окружения. diff --git a/docs/navi_code_cli.md b/docs/navi_code_cli.md new file mode 100644 index 0000000..7a37627 --- /dev/null +++ b/docs/navi_code_cli.md @@ -0,0 +1,98 @@ +# Navi Code CLI + +Терминальный клиент для общения с Navi через WebSocket. + +## Установка + +CLI устанавливается вместе с пакетом `navi`: + +```bash +pip install -e . +``` + +После установки доступна команда `navi-code`. Внутри репозитория можно запускать без установки: + +```bash +python -m clients.terminal +``` + +## Использование + +### Интерактивный режим + +```bash +navi-code +``` + +Клиент подключается к `http://localhost:8000`, создаёт или восстанавливает сессию и запускает чат. + +### One-shot режим + +```bash +navi-code "объясни, что делает этот файл" +navi-code --new-session "напиши pytest-тест для функции foo" +``` + +### Параметры + +| Флаг | Описание | +|---|---| +| `--base-url URL` | Базовый URL сервера Navi. | +| `--ws-url URL` | URL WebSocket (по умолчанию производный от `--base-url`). | +| `--profile-id ID` | Профиль для новой сессии. | +| `--new-session` | Создать новую сессию, даже если сохранена старая. | +| `--show-thinking` | Показывать блоки рассуждений модели. | +| `--no-events` | Скрывать события `tool_started` / `tool_call`. | +| `--version` | Версия клиента. | + +## Команды в интерактивном режиме + +| Команда | Описание | +|---|---| +| `/help` | Список команд. | +| `/new` | Создать новую сессию. | +| `/sessions` | Список сессий на сервере. | +| `/switch ` | Переключиться на другую сессию (можно по префиксу id). | +| `/profile` | Показать текущий профиль и id сессии. | +| `/clear` | Очистить локально сохранённый `session_id`. | +| `/quit` | Выйти. | + +## Состояние + +Клиент сохраняет `session_id` в `~/.navi_code/state.json`, чтобы восстановить диалог при следующем запуске. Удалите файл или используйте `/clear`, чтобы начать с чистого листа. + +## Рендеринг событий + +- `stream_delta` — печатается inline, как в обычном чате. +- `tool_started` / `tool_call` — показываются имена инструментов и краткий результат (если включено `--show-events`). +- `thinking_delta` / `thinking_end` — показываются только с `--show-thinking`. +- `error` — красным цветом. + +## Пример сессии + +```bash +$ navi-code +Created session a1b2c3d4 (profile navi_code) +Navi Code interactive mode. Type /quit to exit, /help for commands. +You: напиши скрипт, который считает строки в текущей директории +[tool: terminal] +{'cmd': 'find . -type f | wc -l'} +[tool result: terminal success=True] +42 + +Я насчитал 42 файла в текущей директории и её поддиректориях. +You: /quit +``` + +## Разработка + +Код CLI находится в `clients/terminal/`: + +- `cli.py` — точка входа и интерактивный цикл. +- `ws_client.py` — WebSocket-клиент. +- `api.py` — REST-запросы к серверу. +- `render.py` — рендеринг событий в терминал. +- `state.py` — сохранение `session_id` в `~/.navi_code/state.json`. +- `config.py` — настройки из переменных окружения `NAVI_CODE_*`. + +Тесты: `tests/clients/test_terminal_client.py` и `tests/clients/test_terminal_ws.py`. diff --git a/docs/profiles.md b/docs/profiles.md index 59eb327..049bd19 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -100,9 +100,21 @@ | `tool_developer` | Tool Developer | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.35 | Yes | | `discuss` | Discussion | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.85 | No | | `modeler_3d` | 3D Modeler | gemma4:26b-a4b-it-q4_K_M → gemma4:31b-cloud | 0.35 | Yes | +| `navi_code` | Navi Code | gemma4:26b-a4b-it-q4_K_M → gemma4:31b-cloud | 0.35 | Yes | All profiles share a base tool set. User tools from `tools/enabled.json` are merged in at runtime. +### `navi_code` + +Terminal-first local coding assistant. Designed for the Navi Code CLI and single-user local deployments: + +- **Tools:** `terminal`, `filesystem`, `code_exec`, `spawn_agent`, `todo`, `scratchpad`, `reflect`, `list_tools`, `tool_manual`, `write_tool`, `reload_tools`. +- **Excluded:** `share_file`, `content_publish`, `image_view`, `http_request`, `web_search`, `ssh_exec`, `gmail`. +- **Planning:** Phase 1 and Phase 3 enabled, Phase 2 disabled to reduce latency. +- **Safety:** the system prompt asks Navi to confirm destructive operations (`rm`, overwrites) before executing them. + +Use it with `NAVI_DEFAULT_PROFILE_ID=navi_code` so `POST /sessions` without a `profile_id` creates a `navi_code` session automatically. See [`docs/navi_code.md`](navi_code.md) for the full local-terminal setup. + --- ## System prompt construction diff --git a/pyproject.toml b/pyproject.toml index a425df0..6c009b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,15 @@ # Utilities "tenacity>=8.3", "structlog>=24.1", + + # Terminal client + "click>=8.0", + "websockets>=12.0", ] +[project.scripts] +navi-code = "clients.terminal.cli:main" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/tests/clients/test_terminal_client.py b/tests/clients/test_terminal_client.py new file mode 100644 index 0000000..b1074a1 --- /dev/null +++ b/tests/clients/test_terminal_client.py @@ -0,0 +1,88 @@ +"""Tests for the Navi Code terminal client.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from clients.terminal.cli import main +from clients.terminal.config import Settings +from clients.terminal.render import Renderer +from clients.terminal.state import StateManager + + +class TestStateManager: + def test_load_missing_returns_empty(self, tmp_path: Path) -> None: + mgr = StateManager(tmp_path) + assert mgr.load() == {} + assert mgr.get_session_id() is None + + def test_roundtrip_session_id(self, tmp_path: Path) -> None: + mgr = StateManager(tmp_path) + mgr.set_session_id("sess-123") + assert mgr.get_session_id() == "sess-123" + assert (tmp_path / "state.json").exists() + data = json.loads((tmp_path / "state.json").read_text()) + assert data == {"session_id": "sess-123"} + + def test_clear_session_id(self, tmp_path: Path) -> None: + mgr = StateManager(tmp_path) + mgr.set_session_id("sess-123") + mgr.clear_session_id() + assert mgr.get_session_id() is None + + +class TestRenderer: + def test_stream_delta_prints_inline(self, capsys) -> None: + renderer = Renderer() + renderer.render({"type": "stream_delta", "delta": "hello"}) + captured = capsys.readouterr() + assert "hello" in captured.out + + def test_error_prints_red(self, capsys) -> None: + renderer = Renderer() + renderer.render({"type": "error", "message": "boom"}) + captured = capsys.readouterr() + assert "boom" in captured.out + + def test_tool_started_shown_when_events_enabled(self, capsys) -> None: + renderer = Renderer(show_events=True) + renderer.render({"type": "tool_started", "tool": "terminal", "args": {"cmd": "ls"}}) + captured = capsys.readouterr() + assert "terminal" in captured.out + + def test_tool_started_hidden_when_events_disabled(self, capsys) -> None: + renderer = Renderer(show_events=False) + renderer.render({"type": "tool_started", "tool": "terminal", "args": {"cmd": "ls"}}) + captured = capsys.readouterr() + assert captured.out == "" + + +class TestSettings: + def test_websocket_url_converts_http_to_ws(self) -> None: + s = Settings(base_url="http://localhost:8000") + assert s.websocket_url("abc") == "ws://localhost:8000/ws/sessions/abc" + + def test_websocket_url_converts_https_to_wss(self) -> None: + s = Settings(base_url="https://navi.example.com") + assert s.websocket_url("abc") == "wss://navi.example.com/ws/sessions/abc" + + def test_websocket_url_uses_explicit_ws_url(self) -> None: + s = Settings(ws_url="ws://custom:9000") + assert s.websocket_url("abc") == "ws://custom:9000/ws/sessions/abc" + + +class TestCliRunner: + def test_help_shows_usage(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "Navi Code" in result.output + + def test_version_shows_version(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output diff --git a/tests/clients/test_terminal_ws.py b/tests/clients/test_terminal_ws.py new file mode 100644 index 0000000..911c1b4 --- /dev/null +++ b/tests/clients/test_terminal_ws.py @@ -0,0 +1,49 @@ +"""WebSocket integration tests for the terminal client.""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +import websockets + +from clients.terminal.config import Settings +from clients.terminal.render import Renderer +from clients.terminal.ws_client import NaviWebSocketClient + + +async def fake_navi_server(websocket: websockets.ServerConnection) -> None: + """Fake Navi server: echo one message back as stream events.""" + raw = await websocket.recv() + msg = json.loads(raw) + assert msg["type"] == "message" + + events = [ + {"type": "session_sync", "session_id": "test-session", "profile_id": "navi_code"}, + {"type": "stream_start"}, + {"type": "stream_delta", "delta": "Echo: "}, + {"type": "stream_delta", "delta": msg["content"]}, + {"type": "stream_end", "content": f"Echo: {msg['content']}"}, + ] + for ev in events: + await websocket.send(json.dumps(ev)) + + +@pytest.mark.anyio +async def test_run_one_shot_receives_and_renders_events() -> None: + stop_event = asyncio.Event() + + async def serve() -> None: + async with websockets.serve(fake_navi_server, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + settings = Settings(base_url=f"http://127.0.0.1:{port}") + renderer = Renderer() + client = NaviWebSocketClient("test-session", renderer=renderer) + client.url = settings.websocket_url("test-session") + + await client.run_one_shot("hello") + stop_event.set() + + await asyncio.wait_for(serve(), timeout=10.0) + assert stop_event.is_set() diff --git a/tests/integration/test_auth_disabled.py b/tests/integration/test_auth_disabled.py index d07056c..8ab45ea 100644 --- a/tests/integration/test_auth_disabled.py +++ b/tests/integration/test_auth_disabled.py @@ -3,7 +3,6 @@ import pytest from fastapi.testclient import TestClient -from navi.auth import User from navi.core.registry import BackendRegistry from navi.core.session import InMemorySessionStore from navi.main import app