diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3980563 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# Navi — Project Context for Claude + +## What this is +Personal modular AI agent system. FastAPI backend + vanilla JS client. The agent is named Navi — female, loyal, uncensored personal assistant. Runs locally via Ollama. + +## Stack +- **Backend**: FastAPI, uvicorn, aiosqlite, structlog, pydantic-settings +- **LLM**: Ollama (primary), OpenAI-compatible backend also wired +- **Current model**: `gemma4:e2b-it-q8_0` (2B active params, Q8 — small but capable) +- **Thinking**: `ollama_think: bool = True` — model reasoning is enabled and streamed to client +- **Client**: Vanilla JS ES modules, marked.js + highlight.js via esm.sh CDN +- **DB**: SQLite via aiosqlite for persistent sessions +- **Run**: `.venv/bin/uvicorn navi.main:app --reload --port 8000` + +## Key architecture + +### Agent loop (`navi/core/agent.py`) +Tool-calling loop with `llm.complete()` for tool turns, `llm.stream()` for final response. +Events yielded: `ToolEvent`, `ThinkingDelta`, `ThinkingEnd`, `TextDelta`, `StreamEnd`. +Tool schemas built fresh on every `run_stream()` call from registry + `tools/enabled.json`. + +### Tool system +Two tiers: + +**Built-in tools** (`navi/tools/`): +- `web_search`, `filesystem`, `http_request`, `code_exec`, `terminal`, `ssh_exec`, `image_view` +- `write_tool` — Navi's primary self-extension mechanism (writes + reloads in one call) +- `reload_tools` — hot-reload all user tools without server restart +- `list_tools` — returns actual live tool list from registry (Navi calls this when asked what she can do) +- `tool_manual` — returns `manuals/.md` if exists, else auto-generates from schema + +**User tools** (`tools/*.py`): +- Written by Navi via `write_tool`, or manually +- Module-level format: `name`, `description`, `parameters`, `async def execute(params) -> str` +- No classes, no module-level print(), execute must return plain string or raise +- Auto-discovered at startup and on reload +- `tools/enabled.json` — list of user tool names to auto-include in all profiles +- `tools/_template.py` — canonical format reference (starts with `_`, not auto-loaded) + +Currently created by Navi: `get_current_datetime.py`, `user_notes.py` (working, correct format). + +### Profiles (`navi/profiles/`) +`secretary`, `server_admin`, `smart_home`. Each has `enabled_tools`, `system_prompt`, `model`, `temperature`, `max_iterations`. +All profiles have the same built-in tool set: `[..., reload_tools, write_tool, list_tools, tool_manual]`. +User tools from `enabled.json` are merged in by `Agent._tool_list()`. + +### Global persona (`navi/config.py` → `.env`) +`NAVI_PERSONA` env var — prepended to every profile's system prompt separated by `---`. +Contains: personality, self-extension instructions, `write_tool` usage rules, `tool_manual` usage. + +### Registry (`navi/core/registry.py`) +`ToolRegistry` tracks `_builtin_names` to distinguish builtins from user tools on reload. +`reload_user_tools()` drops all non-builtins and reloads from disk. +Built-in tools with registry injection: `ReloadToolsTool`, `WriteToolTool`, `ListToolsTool`, `ToolManualTool`. + +### Sessions (`navi/core/sqlite_session_store.py`) +Persistent SQLite sessions. `model_dump(mode='json')` required for datetime serialization. +Session ID in URL hash for bookmarking. + +### WebSocket protocol (`navi/api/websocket.py`) +``` +client → server: {type: "message", content: "...", images: [...]} +server → client: stream_start + thinking_delta {delta} ← reasoning chunks (collapsible in UI) + thinking_end + tool_call {tool, args, result, success} + stream_delta {delta} + stream_end {content} + error {message} +``` + +### Client (`client/`) +ES modules: `app.js` (state/routing), `chat.js` (DOM helpers), `ws.js` (WebSocket), `api.js` (REST), `sidebar.js`. +Thinking blocks: open during reasoning, auto-collapse on `thinking_end`, re-openable (like tool cards). +Tool cards: accordion, collapsed by default, click to expand. +Images: paste/attach, base64 via FileReader, rendered in bubbles. +No localStorage — session from URL hash or most recent server session. + +### Dynamic tool loading (`navi/tools/loader.py`) +Tries module-level format first (preferred for user tools), falls back to class-based. +Errors isolated per file — one broken file doesn't affect others. +Detailed error messages: lists exactly which required definitions are missing. + +## Config (`.env`) +``` +NAVI_PERSONA="..." # global personality + tool writing rules +OLLAMA_HOST=... +OLLAMA_DEFAULT_MODEL=gemma4:e2b-it-q8_0 +OLLAMA_NUM_CTX=8192 +OLLAMA_THINK=true +``` + +## Manuals (`manuals/`) +Markdown files, one per tool. `tool_manual` serves them on demand. +Currently: `manuals/write_tool.md` (full format reference + working example). +Auto-generation fallback from tool schema if no .md exists. + +## Important patterns +- `write_tool` validates code before writing (checks for 4 required definitions) +- `write_tool` adds tool to `tools/enabled.json` on success → available in all profiles +- New tool available from the **next** user message (tool schemas built at `run_stream()` entry) +- Navi should call `tool_manual("write_tool")` before writing a tool +- Navi should call `list_tools` when asked about her capabilities (not generate from memory) +- `no-store` cache middleware on `/static/` — safe to hard-refresh during development + +## What works well +- Hot-reload without server restart +- Thinking display in client +- Self-extension via `write_tool` (improving — model still sometimes struggles with format) +- Session persistence, URL-based navigation + +## Known friction +- Small model (e2b) sometimes writes tools in wrong format despite detailed instructions +- `tool_manual` + explicit format feedback in `write_tool` errors is the current mitigation +- Navi tends to hallucinate tool lists — `list_tools` fixes this if she uses it diff --git a/manuals/write_tool.md b/manuals/write_tool.md new file mode 100644 index 0000000..017fad2 --- /dev/null +++ b/manuals/write_tool.md @@ -0,0 +1,115 @@ +# write_tool — Manual + +## What it does +Writes Python source code to `tools/.py` and immediately reloads it into the system. +The new tool becomes a permanent part of your capabilities — available in every future session. + +## Parameters +- `name` (string, required) — tool filename without `.py`, e.g. `"task_manager"` +- `code` (string, required) — full Python source code (see format below) + +## Required code format +Every tool file must define exactly these four things at module level, in this order: + +```python +name = "tool_name" # must match the filename +description = "When and why to use this tool. Be specific — this is what you read to decide whether to call it." +parameters = { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "What this parameter is for"}, + "param2": {"type": "integer", "description": "..."}, + }, + "required": ["param1"], +} + +async def execute(params: dict) -> str: + value = params["param1"] + # your implementation here + return "result as a plain string" +``` + +Rules: +- NO classes +- NO print() at module level +- `execute` MUST be `async` +- `execute` MUST return a plain `str` — not dict, not None +- Raise an exception to signal failure (do not return error dicts) +- Put imports inside `execute()` or at the top of the file — both are fine + +## Data persistence +If the tool needs to store data between calls, use a JSON file inside the `tools/` directory: + +```python +import json, os + +DATA_FILE = os.path.join(os.path.dirname(__file__), "my_tool_data.json") + +def _load(): + if os.path.exists(DATA_FILE): + with open(DATA_FILE) as f: + return json.load(f) + return {} + +def _save(data): + with open(DATA_FILE, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=2) +``` + +## Full example — a simple note-taking tool + +```python +import json, os + +name = "notes" +description = ( + "Save and retrieve short text notes by key. " + "Use this to remember things across sessions: facts, preferences, reminders. " + "Actions: save (store a note), get (retrieve by key), list (show all keys)." +) +parameters = { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["save", "get", "list"]}, + "key": {"type": "string", "description": "Note identifier"}, + "value": {"type": "string", "description": "Note content (for save)"}, + }, + "required": ["action"], +} + +_FILE = os.path.join(os.path.dirname(__file__), "notes_data.json") + +async def execute(params: dict) -> str: + action = params["action"] + data = json.loads(open(_FILE).read()) if os.path.exists(_FILE) else {} + + if action == "save": + key, value = params["key"], params["value"] + data[key] = value + open(_FILE, "w").write(json.dumps(data, ensure_ascii=False, indent=2)) + return f"Saved: {key}" + + if action == "get": + key = params["key"] + if key not in data: + raise KeyError(f"Note '{key}' not found. Use action=list to see all keys.") + return data[key] + + if action == "list": + if not data: + return "No notes saved yet." + return "Saved notes: " + ", ".join(data.keys()) + + raise ValueError(f"Unknown action: {action}") +``` + +## What write_tool checks before writing +- `name`, `description`, `parameters`, `async def execute` must all be present in the code +- Name must not start with `_` + +If the check fails, you get a clear error with the list of what's missing. +If the file loads but has a Python error, you get the exact traceback. + +## After a successful call +The tool is registered and added to `tools/enabled.json`. +It will be available starting from the **next user message** — not the current one. diff --git a/tools/_template.py b/tools/_template.py new file mode 100644 index 0000000..f64129d --- /dev/null +++ b/tools/_template.py @@ -0,0 +1,40 @@ +# Tool template — copy this file, rename it (no leading _), fill in the fields. +# +# No imports needed. Just define these four things at module level: +# +# name — unique identifier (string), used to call the tool +# description — when and why to use this tool (the LLM reads this to decide) +# parameters — JSON Schema object describing the arguments +# execute — async function that receives params dict and returns a string +# +# After saving, call reload_tools. The tool is available from the NEXT message. +# To enable it in a profile, add the name to enabled_tools in navi/profiles/.py. + +name = "example" +description = ( + "One sentence: what this tool does and when to use it. " + "Be specific — the model reads this to decide whether to call the tool." +) +parameters = { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "What this parameter is for", + }, + # add more parameters here + }, + "required": ["input"], +} + + +async def execute(params: dict) -> str: + value = params["input"] + + # --- your implementation here --- + result = f"processed: {value}" + + return result + + # To signal failure, raise an exception: + # raise ValueError("something went wrong") diff --git a/tools/enabled.json b/tools/enabled.json new file mode 100644 index 0000000..0c8c2de --- /dev/null +++ b/tools/enabled.json @@ -0,0 +1,4 @@ +[ + "get_current_datetime", + "user_notes" +] \ No newline at end of file diff --git a/tools/get_current_datetime.py b/tools/get_current_datetime.py new file mode 100644 index 0000000..52d3b53 --- /dev/null +++ b/tools/get_current_datetime.py @@ -0,0 +1,15 @@ +name = "get_current_datetime" +description = "Получает текущую дату и время." +parameters = { + "type": "object", + "properties": {} +} + +import datetime + +async def execute(params: dict) -> str: + """ + Получает текущую дату и время. + """ + now = datetime.datetime.now() + return f"Текущая дата и время: {now.strftime('%Y-%m-%d %H:%M:%S')}" \ No newline at end of file diff --git a/tools/user_notes.py b/tools/user_notes.py new file mode 100644 index 0000000..2327f57 --- /dev/null +++ b/tools/user_notes.py @@ -0,0 +1,91 @@ +import json +import os + +name = "user_notes" +description = ( + "Save, retrieve, and list personal notes specifically about the user. " + "Use this to store facts, preferences, or important context about the user for future reference." +) +parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["save", "get", "list", "delete"], + "description": "The action to perform: 'save' a new note, 'get' a note by key, 'list' all saved note keys, or 'delete' a note by key." + }, + "key": { + "type": "string", + "description": "The unique identifier (key) for the note (required for get, delete)." + }, + "value": { + "type": "string", + "description": "The content of the note (required for 'save' action)." + }, + }, + "required": ["action"], +} + +# Define the file path for data persistence within the tool's directory +DATA_FILE = os.path.join(os.path.dirname(__file__), "user_notes_data.json") + +def _load_data(): + """Loads data from the JSON file.""" + if os.path.exists(DATA_FILE): + try: + with open(DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + # Handle case where file exists but is corrupted + return {} + return {} + +def _save_data(data): + """Saves data to the JSON file.""" + with open(DATA_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +async def execute(params: dict) -> str: + action = params.get("action") + data = _load_data() + + if action == "save": + key = params.get("key") + value = params.get("value") + if not key or not value: + raise ValueError("For 'save' action, both 'key' and 'value' must be provided.") + + data[key] = value + _save_data(data) + return f"Successfully saved note with key: {key}" + + elif action == "get": + key = params.get("key") + if not key: + raise ValueError("For 'get' action, the 'key' parameter is required.") + + if key not in data: + raise KeyError(f"Note with key '{key}' not found. Use 'list' to see available keys.") + + return data[key] + + elif action == "list": + if not data: + return "No user notes have been saved yet." + keys = list(data.keys()) + return f"User notes available. Keys: {', '.join(keys)}" + + elif action == "delete": + key = params.get("key") + if not key: + raise ValueError("For 'delete' action, the 'key' parameter is required.") + + if key in data: + del data[key] + _save_data(data) + return f"Successfully deleted note with key: {key}" + else: + raise KeyError(f"Cannot delete. Note with key '{key}' not found.") + + else: + raise ValueError(f"Invalid action specified: {action}. Must be one of: save, get, list, delete.") \ No newline at end of file