diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index c164051..3303d09 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -4,8 +4,11 @@ from datetime import datetime, timedelta, timezone from typing import Annotated +import mimetypes + import structlog from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import FileResponse from pydantic import BaseModel from navi.api.deps import get_memory_store, get_profile_registry, get_session_store @@ -13,7 +16,7 @@ from navi.core import ProfileRegistry, SessionStore from navi.exceptions import ProfileNotFound from navi.memory import MemoryStore -from navi.session_files import delete_session_dir, ensure_session_dir, is_forbidden, safe_filename +from navi.session_files import delete_session_dir, ensure_session_dir, is_forbidden, safe_filename, session_dir log = structlog.get_logger() @@ -209,6 +212,42 @@ } +@router.get("/{session_id}/files/{filename}") +async def download_file( + session_id: str, + filename: str, + store: Annotated[SessionStore, Depends(get_session_store)], +) -> FileResponse: + """Download a file from the session's file directory.""" + session = await store.get(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Resolve and verify the file is within the session directory (no path traversal) + base = session_dir(session_id).resolve() + file_path = (base / filename).resolve() + try: + file_path.relative_to(base) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # Detect content type; use inline disposition for browser-renderable types + content_type, _ = mimetypes.guess_type(file_path.name) + content_type = content_type or "application/octet-stream" + inline_types = {"image/", "text/html", "text/plain", "application/pdf"} + inline = any(content_type.startswith(t) for t in inline_types) + + return FileResponse( + path=file_path, + filename=filename, + media_type=content_type, + content_disposition_type="inline" if inline else "attachment", + ) + + @router.delete("/{session_id}", status_code=204) async def delete_session( session_id: str, diff --git a/navi/config.py b/navi/config.py index d874ed1..5b06595 100644 --- a/navi/config.py +++ b/navi/config.py @@ -42,7 +42,10 @@ # Session file uploads session_files_dir: str = "session_files" session_files_max_size_mb: int = 200 - session_files_ttl_hours: int = 24 + + # Public base URL used by share_file tool to build download links. + # Change if the server is behind a reverse proxy or runs on a different port. + public_url: str = "http://localhost:8000" # LLM call timeouts # complete() is non-streaming (planning, compression) — blocked until full response diff --git a/navi/core/registry.py b/navi/core/registry.py index 57980ec..e9df3fd 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -27,6 +27,7 @@ from navi.tools.reload_tools import ReloadToolsTool from navi.tools.tool_manual import ToolManualTool from navi.tools.write_tool import WriteToolTool +from navi.tools.share_file import ShareFileTool from navi.tools.loader import LoadResult, load_tools_from_dir @@ -112,6 +113,7 @@ memory_forget = MemoryForgetTool(memory_store) if memory_store else None builtins = [WebSearchTool(), FilesystemTool(), HttpRequestTool(), WebViewTool(), CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), + ShareFileTool(), TodoTool(), ScratchpadTool(), reload_tool, write_tool, list_tool, manual_tool] if memory_search: builtins.extend([memory_search, memory_forget]) diff --git a/navi/profiles/secretary.py b/navi/profiles/secretary.py index bc3bff2..16af71f 100644 --- a/navi/profiles/secretary.py +++ b/navi/profiles/secretary.py @@ -33,6 +33,7 @@ "memory_search", "memory_forget", "list_tools", "tool_manual", "spawn_agent", + "share_file", ], model="gemma4:26b-a4b-it-q4_K_M", temperature=0.7, diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py index 2e51f92..c788941 100644 --- a/navi/profiles/server_admin.py +++ b/navi/profiles/server_admin.py @@ -43,6 +43,7 @@ "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent", + "share_file", ], model="gemma4:26b-a4b-it-q4_K_M", # model="gemma4:e4b-it-q8_0", diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py index 75ddedd..5edcd93 100644 --- a/navi/profiles/smart_home.py +++ b/navi/profiles/smart_home.py @@ -37,6 +37,7 @@ "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent", + "share_file", ], model="gemma4:26b-a4b-it-q4_K_M", temperature=0.3, diff --git a/navi/session_files.py b/navi/session_files.py index 0740ff8..b478069 100644 --- a/navi/session_files.py +++ b/navi/session_files.py @@ -1,8 +1,12 @@ -"""Session-scoped file storage with TTL-based cleanup.""" +"""Session-scoped file storage. + +Session directories live as long as the session itself exists in the database. +They are removed only when the session is explicitly deleted (DELETE /sessions/{id}) +or when an orphaned directory is found (session no longer in DB). +""" import asyncio import shutil -from datetime import datetime, timedelta, timezone from pathlib import Path import structlog @@ -49,14 +53,12 @@ log.info("session_files.deleted", session_id=session_id) -async def cleanup_expired(store) -> None: - """Remove session file dirs older than TTL; append assistant notice to those sessions.""" +async def cleanup_orphans(store) -> None: + """Remove session file dirs whose session no longer exists in the database.""" root = Path(settings.session_files_dir) if not root.exists(): return - cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.session_files_ttl_hours) - for d in root.iterdir(): if not d.is_dir(): continue @@ -68,37 +70,14 @@ if session is None: shutil.rmtree(d) - continue - - if session.last_active >= cutoff: - continue # still fresh - - # Expired — delete directory and notify - shutil.rmtree(d) - log.info("session_files.ttl_expired", session_id=session_id) - - from navi.llm.base import Message - - notice = Message( - role="assistant", - content=( - "Uploaded files for this session have been automatically deleted " - f"after {settings.session_files_ttl_hours} hours of inactivity. " - "Any files you previously uploaded are no longer available on disk." - ), - ) - session.messages.append(notice) - try: - await store.save(session) - except Exception: - log.warning("session_files.notice_save_failed", session_id=session_id, exc_info=True) + log.info("session_files.orphan_deleted", session_id=session_id) async def cleanup_loop(store) -> None: - """Background task: scan for expired session file dirs every hour.""" + """Background task: remove orphaned session file dirs every hour.""" while True: try: - await cleanup_expired(store) + await cleanup_orphans(store) except Exception: log.exception("session_files.cleanup_error") await asyncio.sleep(3600) diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py new file mode 100644 index 0000000..1f62756 --- /dev/null +++ b/navi/tools/share_file.py @@ -0,0 +1,87 @@ +"""share_file tool — expose a local file to the user as a download link.""" + +import shutil +from pathlib import Path + +from navi.config import settings +from navi.session_files import ensure_session_dir + +from .base import Tool, ToolResult, current_session_id + + +def _fmt_size(n: int) -> str: + if n < 1024: + return f"{n} B" + if n < 1024 ** 2: + return f"{n / 1024:.1f} KB" + return f"{n / 1024 ** 2:.1f} MB" + + +class ShareFileTool(Tool): + name = "share_file" + description = ( + "Make a file available for the user to download via a direct HTTP link. " + "Use after generating or producing a file (report, export, archive, converted document, etc.) " + "to give the user a clickable download URL. " + "The file is copied into the session's download folder and served until the session is deleted. " + "Always call this when you produce a file the user will want to keep." + ) + parameters = { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the file to share", + }, + "filename": { + "type": "string", + "description": ( + "Download filename shown to the user. " + "Defaults to the original filename. " + "Use this to give the file a clean, descriptive name." + ), + }, + }, + "required": ["path"], + } + + async def execute(self, params: dict) -> ToolResult: + session_id = current_session_id.get() + if not session_id: + return ToolResult(success=False, output="No active session context.", error="no_session") + + src = Path(params["path"]).expanduser().resolve() + if not src.exists(): + return ToolResult(success=False, output=f"File not found: {src}", error="not_found") + if not src.is_file(): + return ToolResult(success=False, output=f"Path is not a file: {src}", error="not_a_file") + + # Determine the download filename + download_name = Path(params.get("filename") or src.name).name or src.name + + dest_dir = ensure_session_dir(session_id) + dest = dest_dir / download_name + + # If it's already the same file in the session dir, skip copy + if dest.resolve() == src.resolve(): + pass + else: + # Avoid clobbering an existing different file with the same name + if dest.exists(): + stem = Path(download_name).stem + suffix = Path(download_name).suffix + i = 1 + while dest.exists(): + dest = dest_dir / f"{stem}_{i}{suffix}" + i += 1 + shutil.copy2(str(src), str(dest)) + + size = dest.stat().st_size + base_url = settings.public_url.rstrip("/") + url = f"{base_url}/sessions/{session_id}/files/{dest.name}" + + return ToolResult( + success=True, + output=f"Download ready: {dest.name} ({_fmt_size(size)})\nURL: {url}", + metadata={"url": url, "filename": dest.name, "size": size}, + )