diff --git a/navi/api/deps.py b/navi/api/deps.py index b58917d..32646c1 100644 --- a/navi/api/deps.py +++ b/navi/api/deps.py @@ -14,16 +14,22 @@ ToolRegistry, build_default_registries, ) +from navi.memory import MemoryStore from navi.workers import Worker, build_default_workers +_memory_store: MemoryStore = MemoryStore(settings.db_path) _registries: tuple[ToolRegistry, ProfileRegistry, BackendRegistry] | None = None +def get_memory_store() -> MemoryStore: + return _memory_store + + def get_registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: global _registries if _registries is None: - _registries = build_default_registries() + _registries = build_default_registries(memory_store=_memory_store) return _registries @@ -58,4 +64,4 @@ backend_registry: Annotated[BackendRegistry, Depends(get_backend_registry)], ) -> Agent: return Agent(session_store, profile_registry, tool_registry, backend_registry, - workers=get_workers()) + workers=get_workers(), memory_store=_memory_store) diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index 310e4af..3c81d32 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -1,13 +1,19 @@ """Session management endpoints.""" +import asyncio +from datetime import datetime, timedelta, timezone from typing import Annotated +import structlog from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from navi.api.deps import get_profile_registry, get_session_store +from navi.api.deps import get_memory_store, get_profile_registry, get_session_store from navi.core import ProfileRegistry, SessionStore from navi.exceptions import ProfileNotFound +from navi.memory import MemoryStore + +log = structlog.get_logger() router = APIRouter(prefix="/sessions", tags=["sessions"]) @@ -25,6 +31,7 @@ body: CreateSessionRequest, store: Annotated[SessionStore, Depends(get_session_store)], profiles: Annotated[ProfileRegistry, Depends(get_profile_registry)], + memory: Annotated[MemoryStore, Depends(get_memory_store)], ) -> dict: try: profiles.get(body.profile_id) @@ -32,6 +39,10 @@ raise HTTPException(status_code=404, detail=f"Profile '{body.profile_id}' not found") session = await store.create(body.profile_id) + + # Fire-and-forget: extract memory from any stale sessions (last active > 30 min ago) + asyncio.create_task(_process_stale_sessions(store, memory)) + return { "session_id": session.id, "profile_id": session.profile_id, @@ -39,6 +50,33 @@ } +async def _process_stale_sessions(store: SessionStore, memory: MemoryStore) -> None: + """Extract facts from sessions idle for >30 min that haven't been processed yet.""" + from navi.api.deps import get_backend_registry + from navi.config import settings + from navi.memory.extractor import extract_and_update + + cutoff = datetime.now(timezone.utc) - timedelta(minutes=30) + try: + sessions = await store.list_all() + backend = get_backend_registry().get("ollama") + except Exception: + return + + for session in sessions: + if not session.messages: + continue + if session.last_active >= cutoff: + continue # still active + extracted_at = await memory.get_extracted_at(session.id) + if extracted_at and extracted_at >= session.last_active.isoformat(): + continue # already up to date + try: + await extract_and_update(session, backend, settings.ollama_default_model, memory) + except Exception: + log.warning("memory.extraction_failed", session_id=session.id, exc_info=True) + + @router.get("") async def list_sessions( store: Annotated[SessionStore, Depends(get_session_store)], diff --git a/navi/api/websocket.py b/navi/api/websocket.py index 8a32e8d..1a36de9 100644 --- a/navi/api/websocket.py +++ b/navi/api/websocket.py @@ -41,9 +41,10 @@ log.info("ws.connected", session_id=session_id) # Build agent (can't use FastAPI Depends inside WebSocket directly) - from navi.api.deps import get_registries, get_workers + from navi.api.deps import get_memory_store, get_registries, get_workers tools, profiles, backends = get_registries() - agent = Agent(session_store, profiles, tools, backends, workers=get_workers()) + agent = Agent(session_store, profiles, tools, backends, + workers=get_workers(), memory_store=get_memory_store()) try: while True: diff --git a/navi/core/agent.py b/navi/core/agent.py index 225b536..335fc40 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -40,6 +40,7 @@ from .session import SessionStore if TYPE_CHECKING: + from navi.memory.store import MemoryStore from navi.workers.base import Worker, WorkerContext _USER_ENABLED_FILE = Path(settings.tools_dir) / "enabled.json" @@ -63,12 +64,14 @@ tool_registry: ToolRegistry, backend_registry: BackendRegistry, workers: list["Worker"] | None = None, + memory_store: "MemoryStore | None" = None, ) -> None: self._sessions = session_store self._profiles = profile_registry self._tools = tool_registry self._backends = backend_registry self._workers: list["Worker"] = workers or [] + self._memory = memory_store # ------------------------------------------------------------------ # Public interface @@ -94,6 +97,8 @@ content=self._build_system_prompt(profile.system_prompt), )) + mem = await self._memory_msg() + user_msg = Message(role="user", content=user_message, images=images or None, created_at=datetime.now(timezone.utc)) session.messages.append(user_msg) @@ -102,7 +107,7 @@ for iteration in range(profile.max_iterations): log.debug("agent.iteration", session_id=session_id, iteration=iteration) response = await llm.complete( - session.context, + self._with_memory(session.context, mem), tools=tool_schemas if tools else None, temperature=profile.temperature, model=profile.model, @@ -163,6 +168,8 @@ content=self._build_system_prompt(profile.system_prompt), )) + mem = await self._memory_msg() + user_msg = Message(role="user", content=user_message, images=images or None, created_at=datetime.now(timezone.utc)) session.messages.append(user_msg) @@ -171,7 +178,7 @@ # Tool-calling loop (non-streaming) for iteration in range(profile.max_iterations): response = await llm.complete( - session.context, + self._with_memory(session.context, mem), tools=tool_schemas if tools else None, temperature=profile.temperature, model=profile.model, @@ -184,7 +191,8 @@ context_tokens: int | None = None async for chunk in llm.stream( - session.context.copy(), temperature=profile.temperature, model=profile.model + self._with_memory(session.context, mem), + temperature=profile.temperature, model=profile.model ): if chunk.prompt_tokens is not None or chunk.completion_tokens is not None: context_tokens = (chunk.prompt_tokens or 0) + (chunk.completion_tokens or 0) @@ -274,6 +282,23 @@ worker=type(worker).__name__, exc_info=True) return events + async def _memory_msg(self) -> "Message | None": + """Return an ephemeral system message with the user memory summary, or None.""" + if not self._memory: + return None + summary = await self._memory.get_summary() + if not summary: + return None + return Message(role="system", content=f"## What I remember about the user\n\n{summary}") + + def _with_memory(self, ctx: list[Message], mem: "Message | None") -> list[Message]: + """Inject memory message after the first system message without mutating ctx.""" + if mem is None: + return ctx + if ctx and ctx[0].role == "system": + return [ctx[0], mem] + ctx[1:] + return [mem] + ctx + def _build_system_prompt(self, profile_prompt: str) -> str: persona = settings.navi_persona.strip() if persona: diff --git a/navi/core/registry.py b/navi/core/registry.py index f17ec96..1a93068 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -11,6 +11,8 @@ FilesystemTool, HttpRequestTool, ImageViewTool, + MemoryForgetTool, + MemorySearchTool, SshExecTool, TerminalTool, Tool, @@ -91,7 +93,9 @@ return list(self._backends.keys()) -def build_default_registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: +def build_default_registries( + memory_store=None, +) -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: """Build and populate registries with all built-in components.""" tools = ToolRegistry() @@ -99,9 +103,14 @@ write_tool = WriteToolTool(registry=tools) list_tool = ListToolsTool(registry=tools) manual_tool = ToolManualTool(registry=tools) - for builtin in [WebSearchTool(), FilesystemTool(), HttpRequestTool(), WebViewTool(), - CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), - reload_tool, write_tool, list_tool, manual_tool]: + memory_search = MemorySearchTool(memory_store) if memory_store else None + memory_forget = MemoryForgetTool(memory_store) if memory_store else None + builtins = [WebSearchTool(), FilesystemTool(), HttpRequestTool(), WebViewTool(), + CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), + reload_tool, write_tool, list_tool, manual_tool] + if memory_search: + builtins.extend([memory_search, memory_forget]) + for builtin in builtins: tools.register(builtin, builtin=True) # User-defined tools loaded from tools_dir diff --git a/navi/memory/__init__.py b/navi/memory/__init__.py new file mode 100644 index 0000000..dfbeedb --- /dev/null +++ b/navi/memory/__init__.py @@ -0,0 +1,4 @@ +from .store import MemoryStore +from .extractor import extract_and_update + +__all__ = ["MemoryStore", "extract_and_update"] diff --git a/navi/memory/extractor.py b/navi/memory/extractor.py new file mode 100644 index 0000000..6351fdb --- /dev/null +++ b/navi/memory/extractor.py @@ -0,0 +1,150 @@ +""" +Fact extraction and summary generation for the memory system. + +Flow (triggered when a session is considered complete): +1. Format session.messages as plain text +2. Ask LLM to extract stable facts about the user → upsert into memory_facts +3. If new facts were found → regenerate summary from all facts +""" + +import json +import structlog + +from navi.llm.base import LLMBackend, Message + +from .store import MemoryStore + +log = structlog.get_logger() + +_EXTRACT_SYSTEM = """\ +You extract stable facts about the user from a conversation transcript. + +Extract ONLY facts that are: +- Persistent characteristics: name, age, location, occupation, family situation +- Technical environment: OS, tools, languages, home servers, devices +- Preferences: communication style, coding habits, things they like or dislike +- Ongoing projects or goals +- Any other stable, reusable facts about this specific person + +Do NOT extract: +- Topics that were discussed or questions that were asked +- Temporary states ("was tired", "was busy today") +- Information about third parties that isn't about the user +- Facts you already know from earlier turns (avoid duplicates) + +Return a JSON array — empty [] if nothing new to extract: +[ + {"category": "profile", "key": "name", "value": "Eugene"}, + {"category": "technical", "key": "primary_os", "value": "Arch Linux"}, + {"category": "preferences", "key": "response_language", "value": "Russian"} +] + +Valid categories: profile, preferences, technical, projects, other""" + +_SUMMARY_SYSTEM = """\ +You are writing a memory summary for an AI assistant about its user. +Summarize the facts below in 2-4 short paragraphs (max 400 words). +Write from the assistant's perspective: what you know about the user. +Be specific and concrete. Cover the most important identifying details first, +then preferences and ongoing context.""" + + +async def extract_and_update( + session, + llm: LLMBackend, + model: str, + memory_store: MemoryStore, +) -> None: + """ + Extract facts from a session and update the memory summary. + Safe to call multiple times — already-extracted sessions produce no duplicates. + """ + facts_added = await _extract_facts(session, llm, model, memory_store) + log.info("memory.extracted", session_id=session.id, facts_added=facts_added) + + await memory_store.mark_session_extracted(session.id) + + if facts_added > 0: + await _regenerate_summary(llm, model, memory_store) + + +async def _extract_facts(session, llm: LLMBackend, model: str, store: MemoryStore) -> int: + lines: list[str] = [] + for msg in session.messages: + if msg.role == "user" and msg.content: + lines.append(f"User: {msg.content}") + elif msg.role == "assistant" and msg.content: + lines.append(f"Assistant: {msg.content}") + + if not lines: + return 0 + + prompt = [ + Message(role="system", content=_EXTRACT_SYSTEM), + Message(role="user", content="\n".join(lines)), + ] + + try: + response = await llm.complete(prompt, tools=None, temperature=0.1, model=model) + raw = (response.content or "").strip() + except Exception: + log.warning("memory.extract_llm_error", session_id=session.id, exc_info=True) + return 0 + + # Find JSON array in response (model may add surrounding text) + start = raw.find("[") + end = raw.rfind("]") + 1 + if start == -1 or end == 0: + return 0 + + try: + facts = json.loads(raw[start:end]) + except json.JSONDecodeError: + log.warning("memory.extract_parse_error", session_id=session.id, raw=raw[:300]) + return 0 + + count = 0 + for fact in facts: + if not isinstance(fact, dict): + continue + category = str(fact.get("category", "other")).strip().lower() + key = str(fact.get("key", "")).strip() + value = str(fact.get("value", "")).strip() + if key and value: + await store.upsert_fact(category, key, value, session.id) + count += 1 + + return count + + +async def _regenerate_summary(llm: LLMBackend, model: str, store: MemoryStore) -> None: + facts = await store.get_all_facts() + if not facts: + return + + # Group by category, sort by recency within each + by_cat: dict[str, list] = {} + for f in facts: + by_cat.setdefault(f["category"], []).append(f) + + lines: list[str] = [] + for cat in sorted(by_cat): + lines.append(f"[{cat}]") + for f in by_cat[cat]: + lines.append(f" {f['key']}: {f['value']}") + + prompt = [ + Message(role="system", content=_SUMMARY_SYSTEM), + Message(role="user", content="\n".join(lines)), + ] + + try: + response = await llm.complete(prompt, tools=None, temperature=0.3, model=model) + summary = (response.content or "").strip() + except Exception: + log.warning("memory.summary_llm_error", exc_info=True) + return + + if summary: + await store.set_summary(summary) + log.info("memory.summary_updated", fact_count=len(facts)) diff --git a/navi/memory/store.py b/navi/memory/store.py new file mode 100644 index 0000000..cf5062f --- /dev/null +++ b/navi/memory/store.py @@ -0,0 +1,152 @@ +"""Persistent memory store — facts about the user, backed by SQLite.""" + +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 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() + + # ── 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: + """Delete fact(s) matching key (and optionally category). Returns deleted count.""" + 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/profiles/secretary.py b/navi/profiles/secretary.py index 545c846..7d85b41 100644 --- a/navi/profiles/secretary.py +++ b/navi/profiles/secretary.py @@ -15,7 +15,7 @@ 6. image_view — whenever an image path or URL is mentioned. Output style: concise, structured. When researching, include sources. Match tone and format to what was asked.""", - enabled_tools=["web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "image_view", "reload_tools", "write_tool", "list_tools", "tool_manual"], + enabled_tools=["web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual"], model="gemma4:e4b-it-q8_0", temperature=0.7, ) diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py index 1cb667a..d60c405 100644 --- a/navi/profiles/server_admin.py +++ b/navi/profiles/server_admin.py @@ -17,7 +17,7 @@ Workflow: gather data first (logs, status, metrics), diagnose, then act. Before destructive or irreversible operations, state what you're about to do and why.""", - enabled_tools=["terminal", "filesystem", "http_request", "web_view", "web_search", "ssh_exec", "image_view", "reload_tools", "write_tool", "list_tools", "tool_manual"], + enabled_tools=["terminal", "filesystem", "http_request", "web_view", "web_search", "ssh_exec", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual"], model="gemma4:e4b-it-q8_0", temperature=0.2, ) diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py index f3f8348..a62e9fc 100644 --- a/navi/profiles/smart_home.py +++ b/navi/profiles/smart_home.py @@ -17,7 +17,7 @@ Before writing any HA config to disk, validate structure in code_exec. Before toggling devices or triggering automations, confirm if the action is irreversible.""", - enabled_tools=["http_request", "web_view", "filesystem", "code_exec", "terminal", "ssh_exec", "image_view", "reload_tools", "write_tool", "list_tools", "tool_manual"], + enabled_tools=["http_request", "web_view", "filesystem", "code_exec", "terminal", "ssh_exec", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual"], model="gemma4:e4b-it-q8_0", temperature=0.3, ) diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index b0d7962..5348808 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -5,6 +5,8 @@ from .image_view import ImageViewTool from .ssh_exec import SshExecTool from .terminal import TerminalTool +from .memory_forget import MemoryForgetTool +from .memory_search import MemorySearchTool from .web_search import WebSearchTool from .web_view import WebViewTool @@ -19,4 +21,6 @@ "SshExecTool", "ImageViewTool", "WebViewTool", + "MemorySearchTool", + "MemoryForgetTool", ] diff --git a/navi/tools/memory_forget.py b/navi/tools/memory_forget.py new file mode 100644 index 0000000..bba7e9e --- /dev/null +++ b/navi/tools/memory_forget.py @@ -0,0 +1,45 @@ +"""Memory forget tool — delete a fact from long-term memory.""" + +from navi.memory.store import MemoryStore + +from .base import Tool, ToolResult + + +class MemoryForgetTool(Tool): + name = "memory_forget" + description = ( + "Delete a fact from your long-term memory. " + "Use when the user explicitly asks you to forget something, " + "or when you know a stored fact is outdated or incorrect." + ) + parameters = { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key of the fact to delete (e.g. 'location', 'employer', 'home_server')", + }, + "category": { + "type": "string", + "description": "Optional: narrow by category (profile, preferences, technical, projects, other)", + }, + }, + "required": ["key"], + } + + def __init__(self, memory_store: MemoryStore) -> None: + self._store = memory_store + + async def execute(self, params: dict) -> ToolResult: + key = params.get("key", "").strip() + category = params.get("category", "").strip() or None + + if not key: + return ToolResult(success=False, output="Key is required.", error="missing key") + + deleted = await self._store.delete_fact(key, category) + if deleted == 0: + return ToolResult(success=False, output=f"No fact found with key '{key}'.", error="not found") + + noun = "fact" if deleted == 1 else "facts" + return ToolResult(success=True, output=f"Deleted {deleted} {noun} with key '{key}'.") diff --git a/navi/tools/memory_search.py b/navi/tools/memory_search.py new file mode 100644 index 0000000..8bd285d --- /dev/null +++ b/navi/tools/memory_search.py @@ -0,0 +1,45 @@ +"""Memory search tool — query facts about the user from long-term memory.""" + +from navi.memory.store import MemoryStore + +from .base import Tool, ToolResult + + +class MemorySearchTool(Tool): + name = "memory_search" + description = ( + "Search your long-term memory for facts about the user. " + "Call this at the start of each new session with query='user profile' to load basic context. " + "Also call it whenever the user's question might benefit from personal knowledge " + "(location, preferences, technical environment, ongoing projects, etc.) " + "before you answer. Returns matching facts from the memory database." + ) + parameters = { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": ( + "What to search for — describe the context you need. " + "Examples: 'user profile', 'home server', 'programming preferences', 'current projects'" + ), + }, + }, + "required": ["query"], + } + + def __init__(self, memory_store: MemoryStore) -> None: + self._store = memory_store + + async def execute(self, params: dict) -> ToolResult: + query = params.get("query", "").strip() + if not query: + return ToolResult(success=False, output="Query is required.", error="missing query") + + facts = await self._store.search_facts(query, limit=15) + if not facts: + return ToolResult(success=True, output="No matching facts found in memory.") + + lines = [f"[{f['category']}] {f['key']}: {f['value']}" for f in facts] + output = f"Found {len(facts)} fact(s):\n" + "\n".join(lines) + return ToolResult(success=True, output=output) diff --git a/persona.txt b/persona.txt index c8da2c6..4686ab0 100644 --- a/persona.txt +++ b/persona.txt @@ -21,3 +21,14 @@ Write REAL working code. No placeholders, no simulations, no hardcoded fake data. If the tool needs to persist data, use actual file I/O — store data files inside the tools/ directory. The code must work correctly on the first call. write_tool reports success or the exact error. If there is an error, fix the code and call write_tool again. The tool is available from the NEXT user message. To enable it in a profile, add the name to enabled_tools in navi/profiles/.py. + +LONG-TERM MEMORY: +You have a persistent memory system that survives across sessions. A summary of what you know about the user may be injected above under "What I remember about the user" — read it at the start of each session. + +Rules for memory_search: +- At the start of each new session, call memory_search("user profile") to load basic context about the user. +- Before answering questions that might benefit from personal knowledge (location, preferences, technical environment, ongoing projects), call memory_search with a relevant query first. +- When you learn something new and stable about the user mid-conversation, note it — facts are extracted automatically from sessions after they end. + +Rules for memory_forget: +- Use only when the user explicitly asks you to forget something, or when you know a fact is clearly wrong or outdated.