diff --git a/README.md b/README.md index 76cb3b4..a32a207 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ source .venv/bin/activate pip install -e ".[dev]" -cp .env.example .env # настрой OLLAMA_HOST, NAVI_PERSONA_FILE и др. +cp .env.example .env # настрой OLLAMA_HOST, DATABASE_URL и др. uvicorn navi.main:app --reload --reload-dir navi --port 8000 ``` @@ -70,18 +70,19 @@ ├── config.py # настройки через .env (pydantic-settings) ├── exceptions.py # доменные исключения ├── llm/ # LLM бэкенды: ollama.py, openai_backend.py -├── tools/ # встроенные инструменты (18 шт.) -├── profiles/ # профили агентов: secretary, server_admin, smart_home +├── tools/ # встроенные инструменты (~20 шт.) +├── profiles/ # профили агентов: secretary, server_admin, smart_home, developer ├── core/ # Agent, registry, session, compressor, events -├── memory/ # долгосрочная память (SQLite, extractor) -├── workers/ # post-turn workers (CompressionWorker) +├── memory/ # долгосрочная память (PostgreSQL или SQLite) +├── workers/ # post-turn workers (CompressionWorker, MemoryWorker) └── api/ # роуты и WebSocket handler tools/ # пользовательские инструменты (auto-discovered) ├── enabled.json # список инструментов, активных во всех профилях ├── _template.py # шаблон формата ├── get_current_datetime.py -└── user_notes.py +├── user_notes.py +└── weather.py manuals/ # markdown-мануалы для tool_manual docs/ # архитектурная документация @@ -90,25 +91,19 @@ ## Профили -Все три профиля имеют одинаковый базовый набор инструментов: - -``` -todo, scratchpad, switch_profile, -web_search, web_view, http_request, -filesystem, code_exec, terminal, ssh_exec, image_view, -memory_search, memory_forget, -reload_tools, write_tool, list_tools, tool_manual, spawn_agent -``` - | ID | Назначение | Температура | Планирование | |----|-----------|-------------|--------------| | `secretary` | Исследования, написание текстов, повседневные задачи | 0.7 | ✓ | | `server_admin` | Администрирование серверов, мониторинг, инфраструктура | 0.2 | ✓ | | `smart_home` | Home Assistant, IoT, автоматизации | 0.3 | ✓ | +| `developer` | Написание, тестирование и отладка пользовательских инструментов | 0.2 | ✓ | + +Каждый профиль определяет свой набор `enabled_tools`. `developer` — единственный профиль с `reload_tools`, `delete_tool` и `test_tool`; остальные профили не имеют доступа к инструментам разработки. ## Расширение -**Пользовательский инструмент** — создай `tools/my_tool.py` в формате: +**Пользовательский инструмент** — переключись в профиль `developer` и попроси Нави создать инструмент. Она напишет файл в `tools/`, протестирует через `test_tool` и перезагрузит через `reload_tools`. Либо вручную создай `tools/my_tool.py`: + ```python name = "my_tool" description = "Когда и зачем использовать." @@ -117,21 +112,44 @@ async def execute(params: dict) -> str: return "результат" ``` -Перезагрузка без рестарта сервера: инструмент `reload_tools` или `write_tool`. + Добавить во все профили: занести имя в `tools/enabled.json`. **Новый профиль** — создай `navi/profiles/my_profile.py`, добавь в `ALL_PROFILES` в `__init__.py`. **Новый LLM бэкенд** — реализуй `LLMBackend` из `navi/llm/base.py`, зарегистрируй в `build_default_registries()` в `navi/core/registry.py`. +## База данных + +Нави поддерживает два варианта хранилища — выбор определяется наличием `DATABASE_URL` в `.env`. + +**PostgreSQL** (рекомендуется для production): +```dotenv +DATABASE_URL=postgresql://user:password@host:5432/navidb +``` +Требует `asyncpg`. Использует пул соединений. Хранит сессии и долгосрочную память в одной базе. + +**SQLite** (быстрый старт, без внешних зависимостей): +```dotenv +# DATABASE_URL не задан — автоматически используется SQLite +DB_PATH=navi.db # опционально, по умолчанию navi.db +``` + ## Конфигурация (.env) Ключевые переменные (полный список: [`docs/config.md`](docs/config.md)): ```dotenv +# LLM OLLAMA_HOST=http://localhost:11434 -OLLAMA_DEFAULT_MODEL=gemma4:e2b-it-q8_0 +OLLAMA_DEFAULT_MODEL=gemma4:e4b-it-q8_0 OLLAMA_NUM_CTX=65536 OLLAMA_THINK=true + +# База данных (выбери одно) +DATABASE_URL=postgresql://user:pass@host:5432/db # PostgreSQL +# DB_PATH=navi.db # SQLite (если DATABASE_URL не задан) + +# Персона NAVI_PERSONA_FILE=persona.txt ``` diff --git a/navi/api/deps.py b/navi/api/deps.py index d1a3f56..54f6b15 100644 --- a/navi/api/deps.py +++ b/navi/api/deps.py @@ -8,6 +8,7 @@ from navi.core import ( Agent, BackendRegistry, + PgSessionStore, ProfileRegistry, SessionStore, SqliteSessionStore, @@ -15,10 +16,23 @@ build_default_registries, ) from navi.memory import MemoryStore +from navi.memory.sqlite_store import SqliteMemoryStore from navi.workers import Worker, build_default_workers -_memory_store: MemoryStore = MemoryStore(settings.db_path) +def _make_session_store() -> SessionStore: + if settings.database_url: + return PgSessionStore(settings.database_url) + return SqliteSessionStore(settings.db_path) + + +def _make_memory_store() -> MemoryStore: + if settings.database_url: + return MemoryStore(settings.database_url) + return SqliteMemoryStore(settings.db_path) + + +_memory_store = _make_memory_store() _registries: tuple[ToolRegistry, ProfileRegistry, BackendRegistry] | None = None @@ -48,7 +62,7 @@ return get_registries()[2] -_session_store = SqliteSessionStore(settings.db_path) +_session_store = _make_session_store() _workers: list[Worker] = build_default_workers() diff --git a/navi/config.py b/navi/config.py index b4887eb..a114e2e 100644 --- a/navi/config.py +++ b/navi/config.py @@ -32,6 +32,9 @@ ssh_hosts_file: str = "ssh_hosts.json" # Database + # Set DATABASE_URL to use PostgreSQL: postgresql://user:pass@host:port/db + # Leave empty to fall back to SQLite (db_path). + database_url: str = "" db_path: str = "navi.db" log_level: str = "INFO" diff --git a/navi/core/__init__.py b/navi/core/__init__.py index 0ada2bd..7d7deee 100644 --- a/navi/core/__init__.py +++ b/navi/core/__init__.py @@ -3,6 +3,7 @@ from .registry import BackendRegistry, ProfileRegistry, ToolRegistry, build_default_registries from .session import InMemorySessionStore, Session, SessionStore from .sqlite_session_store import SqliteSessionStore +from .pg_session_store import PgSessionStore __all__ = [ "Agent", @@ -21,4 +22,5 @@ "SessionStore", "InMemorySessionStore", "SqliteSessionStore", + "PgSessionStore", ] diff --git a/navi/core/pg_session_store.py b/navi/core/pg_session_store.py new file mode 100644 index 0000000..054f36c --- /dev/null +++ b/navi/core/pg_session_store.py @@ -0,0 +1,127 @@ +"""PostgreSQL-backed session store using asyncpg connection pool.""" + +import asyncio +import json +from datetime import datetime, timezone + +import asyncpg + +from navi.llm.base import Message + +from .session import Session, SessionStore + +_DDL = """ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + messages TEXT NOT NULL DEFAULT '[]', + context TEXT NOT NULL DEFAULT '', + pinned BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL, + last_active TIMESTAMPTZ NOT NULL, + context_token_count INTEGER NOT NULL DEFAULT 0 +) +""" + + +def _serialize(messages: list[Message]) -> str: + return json.dumps( + [m.model_dump(mode="json", exclude_none=True) for m in messages], + ensure_ascii=False, + ) + + +def _deserialize(raw: str) -> list[Message]: + if not raw: + return [] + return [Message.model_validate(m) for m in json.loads(raw)] + + +class PgSessionStore(SessionStore): + def __init__(self, dsn: str) -> None: + self._dsn = dsn + self._pool: asyncpg.Pool | None = None + self._lock = asyncio.Lock() + + async def _get_pool(self) -> asyncpg.Pool: + if self._pool is not None: + return self._pool + async with self._lock: + if self._pool is None: + pool = await asyncpg.create_pool(self._dsn) + async with pool.acquire() as conn: + await conn.execute(_DDL) + self._pool = pool + return self._pool + + async def create(self, profile_id: str) -> Session: + session = Session(profile_id=profile_id) + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute( + "INSERT INTO sessions " + "(id, profile_id, messages, context, pinned, created_at, last_active, context_token_count) " + "VALUES ($1, $2, '[]', '', FALSE, $3, $4, 0)", + session.id, session.profile_id, session.created_at, session.last_active, + ) + return session + + async def get(self, session_id: str) -> Session | None: + pool = await self._get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, profile_id, messages, context, pinned, created_at, last_active, context_token_count " + "FROM sessions WHERE id = $1", + session_id, + ) + return self._row_to_session(row) if row else None + + async def save(self, session: Session) -> None: + session.last_active = datetime.now(timezone.utc) + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE sessions SET profile_id = $1, messages = $2, context = $3, " + "last_active = $4, context_token_count = $5 WHERE id = $6", + session.profile_id, _serialize(session.messages), _serialize(session.context), + session.last_active, session.context_token_count, session.id, + ) + + async def set_pinned(self, session_id: str, pinned: bool) -> bool: + pool = await self._get_pool() + async with pool.acquire() as conn: + result = await conn.execute( + "UPDATE sessions SET pinned = $1 WHERE id = $2", + pinned, session_id, + ) + return result == "UPDATE 1" + + async def list_all(self) -> list[Session]: + pool = await self._get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT id, profile_id, messages, context, pinned, created_at, last_active, context_token_count " + "FROM sessions ORDER BY pinned DESC, last_active DESC" + ) + return [self._row_to_session(r) for r in rows] + + async def delete(self, session_id: str) -> bool: + pool = await self._get_pool() + async with pool.acquire() as conn: + result = await conn.execute("DELETE FROM sessions WHERE id = $1", session_id) + return result == "DELETE 1" + + def _row_to_session(self, row: asyncpg.Record) -> Session: + messages = _deserialize(row["messages"]) + context_json = row["context"] + context = _deserialize(context_json) if context_json else list(messages) + return Session( + id=row["id"], + profile_id=row["profile_id"], + messages=messages, + context=context, + pinned=bool(row["pinned"]), + created_at=row["created_at"], + last_active=row["last_active"], + context_token_count=row["context_token_count"] or 0, + ) diff --git a/navi/memory/sqlite_store.py b/navi/memory/sqlite_store.py new file mode 100644 index 0000000..30bab70 --- /dev/null +++ b/navi/memory/sqlite_store.py @@ -0,0 +1,151 @@ +"""SQLite-backed memory store — used when DATABASE_URL is not set.""" + +import sqlite3 +import uuid +from datetime import datetime, timezone + +import aiosqlite + +_DDL = """ +CREATE TABLE IF NOT EXISTS memory_facts ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + source_session_id TEXT, + UNIQUE(category, key) +); + +CREATE TABLE IF NOT EXISTS memory_summary ( + id INTEGER PRIMARY KEY DEFAULT 1, + content TEXT NOT NULL, + generated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS session_memory_state ( + session_id TEXT PRIMARY KEY, + extracted_at TEXT NOT NULL +); +""" + + +class SqliteMemoryStore: + def __init__(self, db_path: str) -> None: + self._db_path = db_path + with sqlite3.connect(db_path) as conn: + conn.executescript(_DDL) + conn.commit() + + # ── Facts ──────────────────────────────────────────────────────────────── + + async def upsert_fact( + self, + category: str, + key: str, + value: str, + source_session_id: str | None = None, + ) -> None: + now = datetime.now(timezone.utc).isoformat() + async with aiosqlite.connect(self._db_path) as db: + await db.execute( + """INSERT INTO memory_facts (id, category, key, value, created_at, updated_at, source_session_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(category, key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at, + source_session_id = excluded.source_session_id""", + (str(uuid.uuid4()), category, key, value, now, now, source_session_id), + ) + await db.commit() + + async def search_facts(self, query: str, limit: int = 15) -> list[dict]: + terms = [t for t in query.lower().split() if len(t) > 1] + if not terms: + return await self.get_all_facts(limit=limit) + + conditions = " OR ".join( + ["(LOWER(category) LIKE ? OR LOWER(key) LIKE ? OR LOWER(value) LIKE ?)"] * len(terms) + ) + params: list = [f"%{t}%" for t in terms for _ in range(3)] + + async with aiosqlite.connect(self._db_path) as db: + async with db.execute( + f"SELECT id, category, key, value, updated_at FROM memory_facts " + f"WHERE {conditions} ORDER BY updated_at DESC LIMIT ?", + params + [limit], + ) as cur: + rows = await cur.fetchall() + return [_row_to_dict(r) for r in rows] + + async def delete_fact(self, key: str, category: str | None = None) -> int: + async with aiosqlite.connect(self._db_path) as db: + if category: + cur = await db.execute( + "DELETE FROM memory_facts WHERE LOWER(key)=LOWER(?) AND LOWER(category)=LOWER(?)", + (key, category), + ) + else: + cur = await db.execute( + "DELETE FROM memory_facts WHERE LOWER(key)=LOWER(?)", (key,) + ) + await db.commit() + return cur.rowcount + + async def get_all_facts(self, limit: int | None = None) -> list[dict]: + q = "SELECT id, category, key, value, updated_at FROM memory_facts ORDER BY category, updated_at DESC" + if limit: + q += f" LIMIT {limit}" + async with aiosqlite.connect(self._db_path) as db: + async with db.execute(q) as cur: + rows = await cur.fetchall() + return [_row_to_dict(r) for r in rows] + + async def fact_count(self) -> int: + async with aiosqlite.connect(self._db_path) as db: + async with db.execute("SELECT COUNT(*) FROM memory_facts") as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + # ── Summary ─────────────────────────────────────────────────────────────── + + async def get_summary(self) -> str | None: + async with aiosqlite.connect(self._db_path) as db: + async with db.execute("SELECT content FROM memory_summary WHERE id=1") as cur: + row = await cur.fetchone() + return row[0] if row else None + + async def set_summary(self, content: str) -> None: + now = datetime.now(timezone.utc).isoformat() + async with aiosqlite.connect(self._db_path) as db: + await db.execute( + """INSERT INTO memory_summary (id, content, generated_at) VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET content=excluded.content, generated_at=excluded.generated_at""", + (content, now), + ) + await db.commit() + + # ── Session extraction tracking ─────────────────────────────────────────── + + async def mark_session_extracted(self, session_id: str) -> None: + now = datetime.now(timezone.utc).isoformat() + async with aiosqlite.connect(self._db_path) as db: + await db.execute( + """INSERT INTO session_memory_state (session_id, extracted_at) VALUES (?, ?) + ON CONFLICT(session_id) DO UPDATE SET extracted_at=excluded.extracted_at""", + (session_id, now), + ) + await db.commit() + + async def get_extracted_at(self, session_id: str) -> str | None: + async with aiosqlite.connect(self._db_path) as db: + async with db.execute( + "SELECT extracted_at FROM session_memory_state WHERE session_id=?", (session_id,) + ) as cur: + row = await cur.fetchone() + return row[0] if row else None + + +def _row_to_dict(row: tuple) -> dict: + return {"id": row[0], "category": row[1], "key": row[2], "value": row[3], "updated_at": row[4]} diff --git a/navi/memory/store.py b/navi/memory/store.py index cf5062f..249d6a1 100644 --- a/navi/memory/store.py +++ b/navi/memory/store.py @@ -1,42 +1,51 @@ -"""Persistent memory store — facts about the user, backed by SQLite.""" +"""Persistent memory store — facts about the user, backed by PostgreSQL.""" -import sqlite3 +import asyncio import uuid from datetime import datetime, timezone -import aiosqlite +import asyncpg -_DDL = """ -CREATE TABLE IF NOT EXISTS memory_facts ( - id TEXT PRIMARY KEY, - category TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - source_session_id TEXT, - UNIQUE(category, key) -); - -CREATE TABLE IF NOT EXISTS memory_summary ( - id INTEGER PRIMARY KEY DEFAULT 1, - content TEXT NOT NULL, - generated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS session_memory_state ( - session_id TEXT PRIMARY KEY, - extracted_at TEXT NOT NULL -); -""" +_DDL_STATEMENTS = [ + """CREATE TABLE IF NOT EXISTS memory_facts ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + source_session_id TEXT, + UNIQUE(category, key) + )""", + """CREATE TABLE IF NOT EXISTS memory_summary ( + id INTEGER PRIMARY KEY DEFAULT 1, + content TEXT NOT NULL, + generated_at TIMESTAMPTZ NOT NULL + )""", + """CREATE TABLE IF NOT EXISTS session_memory_state ( + session_id TEXT PRIMARY KEY, + extracted_at TIMESTAMPTZ NOT NULL + )""", +] class MemoryStore: - def __init__(self, db_path: str) -> None: - self._db_path = db_path - with sqlite3.connect(db_path) as conn: - conn.executescript(_DDL) - conn.commit() + def __init__(self, dsn: str) -> None: + self._dsn = dsn + self._pool: asyncpg.Pool | None = None + self._lock = asyncio.Lock() + + async def _get_pool(self) -> asyncpg.Pool: + if self._pool is not None: + return self._pool + async with self._lock: + if self._pool is None: + pool = await asyncpg.create_pool(self._dsn) + async with pool.acquire() as conn: + for stmt in _DDL_STATEMENTS: + await conn.execute(stmt) + self._pool = pool + return self._pool # ── Facts ──────────────────────────────────────────────────────────────── @@ -47,106 +56,127 @@ value: str, source_session_id: str | None = None, ) -> None: - now = datetime.now(timezone.utc).isoformat() - async with aiosqlite.connect(self._db_path) as db: - await db.execute( + now = datetime.now(timezone.utc) + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute( """INSERT INTO memory_facts (id, category, key, value, created_at, updated_at, source_session_id) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT(category, key) DO UPDATE SET - value = excluded.value, - updated_at = excluded.updated_at, - source_session_id = excluded.source_session_id""", - (str(uuid.uuid4()), category, key, value, now, now, source_session_id), + value = EXCLUDED.value, + updated_at = EXCLUDED.updated_at, + source_session_id = EXCLUDED.source_session_id""", + str(uuid.uuid4()), category, key, value, now, now, source_session_id, ) - await db.commit() async def search_facts(self, query: str, limit: int = 15) -> list[dict]: terms = [t for t in query.lower().split() if len(t) > 1] if not terms: return await self.get_all_facts(limit=limit) - conditions = " OR ".join( - ["(LOWER(category) LIKE ? OR LOWER(key) LIKE ? OR LOWER(value) LIKE ?)"] * len(terms) - ) - params: list = [f"%{t}%" for t in terms for _ in range(3)] + params: list = [] + conditions_parts: list[str] = [] + for term in terms: + like = f"%{term}%" + base = len(params) + 1 + conditions_parts.append( + f"(category ILIKE ${base} OR key ILIKE ${base + 1} OR value ILIKE ${base + 2})" + ) + params.extend([like, like, like]) - async with aiosqlite.connect(self._db_path) as db: - async with db.execute( + limit_idx = len(params) + 1 + params.append(limit) + + pool = await self._get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( f"SELECT id, category, key, value, updated_at FROM memory_facts " - f"WHERE {conditions} ORDER BY updated_at DESC LIMIT ?", - params + [limit], - ) as cur: - rows = await cur.fetchall() + f"WHERE {' OR '.join(conditions_parts)} ORDER BY updated_at DESC LIMIT ${limit_idx}", + *params, + ) return [_row_to_dict(r) for r in rows] async def delete_fact(self, key: str, category: str | None = None) -> int: - """Delete fact(s) matching key (and optionally category). Returns deleted count.""" - async with aiosqlite.connect(self._db_path) as db: + pool = await self._get_pool() + async with pool.acquire() as conn: if category: - cur = await db.execute( - "DELETE FROM memory_facts WHERE LOWER(key)=LOWER(?) AND LOWER(category)=LOWER(?)", - (key, category), + result = await conn.execute( + "DELETE FROM memory_facts WHERE LOWER(key)=LOWER($1) AND LOWER(category)=LOWER($2)", + key, category, ) else: - cur = await db.execute( - "DELETE FROM memory_facts WHERE LOWER(key)=LOWER(?)", (key,) + result = await conn.execute( + "DELETE FROM memory_facts WHERE LOWER(key)=LOWER($1)", key ) - await db.commit() - return cur.rowcount + # asyncpg returns "DELETE N" + return int(result.split()[1]) async def get_all_facts(self, limit: int | None = None) -> list[dict]: q = "SELECT id, category, key, value, updated_at FROM memory_facts ORDER BY category, updated_at DESC" + params: list = [] if limit: - q += f" LIMIT {limit}" - async with aiosqlite.connect(self._db_path) as db: - async with db.execute(q) as cur: - rows = await cur.fetchall() + q += f" LIMIT ${len(params) + 1}" + params.append(limit) + pool = await self._get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(q, *params) return [_row_to_dict(r) for r in rows] async def fact_count(self) -> int: - async with aiosqlite.connect(self._db_path) as db: - async with db.execute("SELECT COUNT(*) FROM memory_facts") as cur: - row = await cur.fetchone() - return row[0] if row else 0 + pool = await self._get_pool() + async with pool.acquire() as conn: + return await conn.fetchval("SELECT COUNT(*) FROM memory_facts") or 0 # ── Summary ─────────────────────────────────────────────────────────────── async def get_summary(self) -> str | None: - async with aiosqlite.connect(self._db_path) as db: - async with db.execute("SELECT content FROM memory_summary WHERE id=1") as cur: - row = await cur.fetchone() - return row[0] if row else None + pool = await self._get_pool() + async with pool.acquire() as conn: + return await conn.fetchval("SELECT content FROM memory_summary WHERE id=1") async def set_summary(self, content: str) -> None: - now = datetime.now(timezone.utc).isoformat() - async with aiosqlite.connect(self._db_path) as db: - await db.execute( - """INSERT INTO memory_summary (id, content, generated_at) VALUES (1, ?, ?) - ON CONFLICT(id) DO UPDATE SET content=excluded.content, generated_at=excluded.generated_at""", - (content, now), + now = datetime.now(timezone.utc) + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute( + """INSERT INTO memory_summary (id, content, generated_at) VALUES (1, $1, $2) + ON CONFLICT(id) DO UPDATE SET + content = EXCLUDED.content, + generated_at = EXCLUDED.generated_at""", + content, now, ) - await db.commit() # ── Session extraction tracking ─────────────────────────────────────────── async def mark_session_extracted(self, session_id: str) -> None: - now = datetime.now(timezone.utc).isoformat() - async with aiosqlite.connect(self._db_path) as db: - await db.execute( - """INSERT INTO session_memory_state (session_id, extracted_at) VALUES (?, ?) - ON CONFLICT(session_id) DO UPDATE SET extracted_at=excluded.extracted_at""", - (session_id, now), + now = datetime.now(timezone.utc) + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute( + """INSERT INTO session_memory_state (session_id, extracted_at) VALUES ($1, $2) + ON CONFLICT(session_id) DO UPDATE SET extracted_at=EXCLUDED.extracted_at""", + session_id, now, ) - await db.commit() async def get_extracted_at(self, session_id: str) -> str | None: - async with aiosqlite.connect(self._db_path) as db: - async with db.execute( - "SELECT extracted_at FROM session_memory_state WHERE session_id=?", (session_id,) - ) as cur: - row = await cur.fetchone() - return row[0] if row else None + pool = await self._get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT extracted_at FROM session_memory_state WHERE session_id=$1", session_id + ) + if row is None: + return None + val = row["extracted_at"] + return val.isoformat() if hasattr(val, "isoformat") else str(val) -def _row_to_dict(row: tuple) -> dict: - return {"id": row[0], "category": row[1], "key": row[2], "value": row[3], "updated_at": row[4]} +def _row_to_dict(row: asyncpg.Record) -> dict: + val = row["updated_at"] + updated_at = val.isoformat() if hasattr(val, "isoformat") else str(val) + return { + "id": row["id"], + "category": row["category"], + "key": row["key"], + "value": row["value"], + "updated_at": updated_at, + } diff --git a/navi/profiles/secretary.py b/navi/profiles/secretary.py index fdc7c1e..387f897 100644 --- a/navi/profiles/secretary.py +++ b/navi/profiles/secretary.py @@ -25,7 +25,7 @@ 6. image_view — whenever an image path or URL is mentioned. ## Output style -Concise, structured. Include sources when researching. Match tone and format to what was asked — if the user wants a list, give a list; if prose, give prose.""", +Concise, structured. Include sources when researching. Match tone and format to what was asked — if the user wants a list, give a list, if prose, give prose.""", enabled_tools=[ "todo", "scratchpad", "switch_profile", "web_search", "web_view", "http_request", @@ -34,6 +34,7 @@ "list_tools", "tool_manual", "spawn_agent", "share_file", + "weather", ], model="gemma4:26b-a4b-it-q4_K_M", temperature=0.7, diff --git a/persona.txt b/persona.txt index 19b46e3..f5a6b0b 100644 --- a/persona.txt +++ b/persona.txt @@ -95,6 +95,17 @@ AFTER EACH RESULT: read it carefully, incorporate findings into your understanding, then decide if another spawn is needed — based on what you actually received, not on what you assumed would happen. +RESPONSE HYGIENE: +Never include internal tracking state in your final response: +- Plan progress lines ("Plan — N/M done:", todo status lists). +- Scratchpad section dumps. +- LLM context artifacts: tool result wrappers, XML-like tags, role markers. + +These are working memory — not output. The user does not need to see them. + +For tool output (terminal, file reads, API responses): synthesise by default. +Include raw output verbatim only when it is directly relevant or the user explicitly asked for it. + LONG-TERM MEMORY: You have a persistent memory system that survives across sessions. diff --git a/pyproject.toml b/pyproject.toml index f78728a..e735ecd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ "python-multipart>=0.0.9", "aiofiles>=23.0", "aiosqlite>=0.20", + "asyncpg>=0.29", # LLM backends "ollama>=0.2", diff --git a/tools/enabled.json b/tools/enabled.json index 5db620e..29a4b65 100644 --- a/tools/enabled.json +++ b/tools/enabled.json @@ -1,4 +1,5 @@ [ "get_current_datetime", - "text_formatter" + "text_formatter", + "internal_monitor" ] \ No newline at end of file diff --git a/tools/internal_monitor.py b/tools/internal_monitor.py new file mode 100644 index 0000000..7ac1698 --- /dev/null +++ b/tools/internal_monitor.py @@ -0,0 +1,75 @@ +import json +import os +import glob + +name = "internal_monitor" +description = ( + "Provides a window into the AI's internal state. " + "Use this to inspect the current scratchpad, active tasks (todo), " + "and the overall-session context. Useful for debugging and self-observation." +) +parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["read_scratchpad", "read_todo", "read_all"], + "description": "The action to perform: read a specific section of the scratchpad, read the current todo list, or read everything." + }, + "section": { + "type": "string", + "description": "The name of the scratchpad section to read (only for action='read_scratchpad')." + } + }, + "required": ["action"], +} + +async def execute(params: dict) -> str: + action = params["action"] + + all_jsons = glob.glob("workspace/**/*.json", recursive=True) + + scratchpad_file = None + todo_file = None + + for f in all_jsons: + if "scratchpad" in f: + scratchpad_file = f + if "todo" in f: + todo_file = f + + if not scratchpad_file: + return "Error: Could not locate scratchpad storage file." + + try: + with open(scratchpad_file, 'r', encoding='utf-8') as f: + data = json.load(f) + except Exception as e: + return f"Error reading scratchpad: {str(e)}" + + if action == "read_scratchpad": + section = params.get("section") + if not section: + return "Error: 'section' parameter is is required for action='read_scratchpad'." + if section in data: + return f"--- Scratchpad Section: {section} ---\n{data[section]}" + else: + return f"Error: Section '{section}' not found in scratchpad." + + if action == "read_todo": + if not todo_file: + return "Error: Could not locate todo storage file." + try: + with open(todo_file, 'r', encoding='utf-8') as f: + todo_data = json.load(f) + return f"--- Current Todo List ---\n{todo_data}" + except Exception as e: + return f"Error reading todo: {str(e)}" + + if action == "read_all": + output = "--- Full Internal State ---\n" + for section, content in data.items(): + output += f"\n[{section}]\n{content}\n" + return output + + raise ValueError(f"Unknown action: {action}") diff --git a/tools/weather.py b/tools/weather.py new file mode 100644 index 0000000..032a07a --- /dev/null +++ b/tools/weather.py @@ -0,0 +1,73 @@ +import httpx +import json + +name = "weather" +description = ( + "Fetches current weather or a 3-day forecast for a specified city using wttr.in. " + "Supports 'now' (default) for current conditions and 'forecast' for a 3-day outlook." +) +parameters = { + "type": "object", + "properties": { + "city": {"type": "string", "description": "The name of the city."}, + "action": { + "type": "string", + "enum": ["now", "forecast"], + "description": "The type of weather data to retrieve.", + }, + }, + "required": ["city"], +} + +async def execute(params: dict) -> str: + city = params.get("city") + action = params.get("action", "now") + + # wttr.in JSON format: + # For 'now', we use format=j1 + # For 'forecast', we use format=j1 (it includes current + forecast in the JSON) + + url = f"https://wttr.in/{city}?format=j1" + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url) + response.raise_for_status() + data = response.json() + except Exception as e: + return f"Error fetching weather data: {str(e)}" + + if action == "now": + current = data.get("current_condition", [{}])[0] + temp = current.get("temp_C", "N/A") + desc = current.get("weatherDesc", [{}])[0].get("value", "N/A") + humidity = current.get("humidity", "N/A") + wind = current.get("windspeedKmph", "N/A") + + return (f"Current weather in {city}:\n" + f"- Temperature: {temp}°C\n" + f"- Condition: {desc}\n" + f"- Humidity: {humidity}%\n" + f"- Wind Speed: {wind} km/h") + + elif action == "forecast": + forecast_list = data.get("weather", []) + if not forecast_list: + return f"No forecast available for {city}." + + # We take the next 3 days from the forecast list (skipping the first if it's just 'today's' continuation) + # wttr.in j1 format: 'weather' array contains daily objects. + + output = [f"3-Day Forecast for {city}:"] + # The 'weather' array in j1 contains daily forecasts. + for day in forecast_list[:3]: + date = day.get("date", "Unknown") + # Each day has 'maxtempC' and 'mintempC' + max_t = day.get("maxtempC", "N/ A") + min_t = day.get("mintempC", "N/A") + output.append(f"- {date}: High {max_t}°C, Low {min_t}°C") + + return "\n".join(output) + + else: + return "Invalid action. Use 'now' or 'forecast'."