diff --git a/NAVI.md b/NAVI.md index a19396b..980f670 100644 --- a/NAVI.md +++ b/NAVI.md @@ -63,4 +63,4 @@ - Before asking the user for project facts, proactively check nearest `NAVI.md`, then `docs/`, `manuals/`, memory, files, tool schemas, or web sources. - Treat `NAVI.md` as the active operational notebook for its directory, not just static project documentation. Update it with stable commands, conventions, local quirks, and current project decisions. - For Navi internals, use `docs/index.md` as the documentation map before scanning broad source trees. -- File-location rule: `workspace/` is persistent/private working storage; `session_files/{session_id}/` is the per-session area for uploads and files that `content_publish` can show in chat. To publish an artifact, put the final file in the session directory first; `content_publish` only registers it and does not copy from `workspace/`. +- File-location rule: `workspace/` is persistent/private working storage; `session_files/{session_id}/` is the per-session area for uploads, downloads, and inline artifacts. `share_file` copies an existing local file into the session directory and returns a download link. `content_publish` registers an existing session file for inline viewing and does not copy from `workspace/`. diff --git a/docs/config.md b/docs/config.md index 3f0bc64..95a51ba 100644 --- a/docs/config.md +++ b/docs/config.md @@ -90,6 +90,7 @@ |---|---|---|---| | `SESSION_FILES_DIR` | str | `session_files` | Directory for uploaded session files | | `SESSION_FILES_MAX_SIZE_MB` | int | `200` | Max upload size per file in megabytes | +| `SHARE_FILE_MAX_SIZE_MB` | int | `1024` | Max file size `share_file` may copy into session files, in megabytes | ## Public URL @@ -154,6 +155,7 @@ # Misc LOG_LEVEL=INFO TOOLS_DIR=tools +SHARE_FILE_MAX_SIZE_MB=1024 # Context compression CONTEXT_COMPRESSION_ENABLED=true diff --git a/docs/sessions.md b/docs/sessions.md index 9447383..96e4cbd 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -120,7 +120,11 @@ This lets the agent use `filesystem` or `code_exec` to access the files. -`workspace/` is separate from session files. Use `workspace/` for persistent private working files and `session_files/{session_id}/` for files that belong to the current chat. Published content uses the same session directory: `content_publish` registers a file that already exists there and exposes it through `/sessions/{id}/files/{filename}`. +`workspace/` is separate from session files. Use `workspace/` for persistent private working files and `session_files/{session_id}/` for files that belong to the current chat. + +Two tools expose session files to the user: +- `share_file` copies an existing local file into the session directory and returns a download link. It requires an absolute source path and has its own size limit (`SHARE_FILE_MAX_SIZE_MB`, default 1024 MB). +- `content_publish` registers a file that already exists in the session directory and exposes it as an inline viewer/card through `/sessions/{id}/files/{filename}`. It does not copy files. --- diff --git a/docs/tools.md b/docs/tools.md index d993fc9..21b8426 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -30,8 +30,8 @@ | `SpawnAgentTool` | `spawn_agent` | Spawn an isolated subagent (blocking, synchronous from caller's view) | | `SwitchProfileTool` | `switch_profile` | Switch the active profile for a session | | `ListProfilesTool` | `list_profiles` | List all available profiles | -| `ContentPublishTool` | `content_publish` | Publish a session file for inline viewing in chat | -| `ShareFileTool` | `share_file` | Share a file with the user | +| `ShareFileTool` | `share_file` | Copy an existing local file into session files and return a download link | +| `ContentPublishTool` | `content_publish` | Register an existing session file for inline viewing in chat | | `DeleteToolTool` | `delete_tool` | Delete a user tool file | | `TestToolTool` | `test_tool` | Run a user tool and verify its output | | `ReflectTool` | `reflect` | Self-reflection and analysis | diff --git a/manuals/share_file.md b/manuals/share_file.md index 449d8a3..714ccaf 100644 --- a/manuals/share_file.md +++ b/manuals/share_file.md @@ -1,6 +1,13 @@ # share_file -Make a file available to the user as a direct download link. +Copy an existing local file into the current session directory and return a direct download link. + +`share_file` and `content_publish` are different tools: + +- `share_file` = copy a local file into `session_files/{session_id}/` + return a download link. +- `content_publish` = register a file that is already in `session_files/{session_id}/` + show it as an inline viewer/card. + +Use `share_file` when the user should keep/download the file. Use `content_publish` when the user should inspect it directly in chat. ## When to call @@ -10,6 +17,18 @@ - Built binaries or packages - Any output file created by terminal, code_exec, or filesystem +Do not use this just to create an inline preview. For SVG/HTML/PDF/image/video/STL preview cards, put the file in the session directory and call `content_publish`. + +## How it works + +1. Takes an **absolute** source path. +2. Copies that file into the current session directory: `session_files/{session_id}/` by default. +3. If the target filename already exists, creates a numbered filename instead of overwriting. +4. Returns a URL: `/sessions/{session_id}/files/{filename}`. +5. The original source file remains unchanged. + +Maximum file size: `SHARE_FILE_MAX_SIZE_MB`, default `1024` MB (1 GB). + ## Parameters | Parameter | Required | Description | @@ -19,7 +38,7 @@ ## Critical: path must be absolute -`path` must be an absolute filesystem path. A relative path like `game_project.zip` or `workspace/report.csv` will fail or resolve to the wrong location. +`path` must be an absolute filesystem path. A relative path like `game_project.zip` or `workspace/report.csv` is rejected. ### How to get the absolute path @@ -72,3 +91,4 @@ | `path: "game_project.zip"` | `path: "/home/gmikcon/Projects/navi-1/game_project.zip"` | | `path: "workspace/report.csv"` | `path: "/home/gmikcon/Projects/navi-1/workspace/report.csv"` | | Saying "the file is ready" without the link | Posting the URL as a markdown link | +| Calling `share_file` for an inline SVG/HTML preview | Put the file in session dir and call `content_publish` | diff --git a/navi/config.py b/navi/config.py index 29ead09..72f236e 100644 --- a/navi/config.py +++ b/navi/config.py @@ -60,6 +60,7 @@ # Session file uploads session_files_dir: str = "session_files" session_files_max_size_mb: int = 200 + share_file_max_size_mb: int = 1024 # 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. diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py index ae56b12..aefa258 100644 --- a/navi/tools/share_file.py +++ b/navi/tools/share_file.py @@ -3,6 +3,7 @@ import asyncio import shutil from pathlib import Path +from urllib.parse import quote from navi.config import settings from navi.session_files import ensure_session_dir @@ -21,9 +22,19 @@ 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 the user will want to keep (report, archive, export, etc.). " - "IMPORTANT — path must be an ABSOLUTE path (e.g. /home/user/file.zip). " + "Copy an existing local file into the current session directory and return a direct download link. " + "Use this when the user should receive a file to keep: archives, reports, exports, source bundles, " + "datasets, PDFs, CSV/JSON files, or other generated artifacts.\n\n" + "Mechanics: share_file takes an ABSOLUTE source path, copies that file into " + "SESSION_FILES_DIR/{session_id}/ under the optional clean filename, and returns a URL at " + "/sessions/{session_id}/files/{filename}. The source file remains where it was. " + "If a file with the same name already exists in the session directory, share_file creates " + "a numbered filename instead of overwriting it. Max file size is SHARE_FILE_MAX_SIZE_MB " + "(default 1024 MB / 1 GB).\n\n" + "Do not confuse this with content_publish: share_file is for download links and may copy " + "from elsewhere; content_publish is for inline viewer cards and only registers a file that " + "already exists in the session directory.\n\n" + "IMPORTANT — path must be an ABSOLUTE path (e.g. /home/user/file.zip). Relative paths are rejected. " "If you only know a relative path, resolve it first: use filesystem(action='info') or " "terminal('realpath ') to get the absolute path, then call share_file." ) @@ -51,12 +62,35 @@ if not session_id: return ToolResult(success=False, output="No active session context.", error="no_session") - src = Path(params["path"]).expanduser().resolve() + raw_path = Path(params["path"]).expanduser() + if not raw_path.is_absolute(): + return ToolResult( + success=False, + output=( + f"share_file requires an absolute path, got: {params['path']}\n" + "Resolve it first with filesystem info or terminal realpath, then call share_file again." + ), + error="path_not_absolute", + ) + + src = raw_path.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") + max_bytes = settings.share_file_max_size_mb * 1024 * 1024 + src_size = src.stat().st_size + if src_size > max_bytes: + return ToolResult( + success=False, + output=( + f"File is too large to share: {_fmt_size(src_size)}. " + f"Limit: {settings.share_file_max_size_mb} MB." + ), + error="file_too_large", + ) + # Determine the download filename download_name = Path(params.get("filename") or src.name).name or src.name @@ -79,7 +113,7 @@ size = dest.stat().st_size base_url = settings.public_url.rstrip("/") - url = f"{base_url}/sessions/{session_id}/files/{dest.name}" + url = f"{base_url}/sessions/{quote(session_id, safe='')}/files/{quote(dest.name, safe='')}" return ToolResult( success=True, diff --git a/persona.txt b/persona.txt index 6457f26..667c9a6 100644 --- a/persona.txt +++ b/persona.txt @@ -31,13 +31,14 @@ Use it for long-term private working files: scripts, notes, datasets, configs, research results, intermediate code, and artifacts that should survive beyond one chat session. Do NOT write working files to the project root. 2. Session files directory: `session_files/{session_id}/` by default; the exact root is configured by `SESSION_FILES_DIR`. -This is the per-session file area used for user uploads and files the user should view in the chat. `content_publish` can publish ONLY files that already exist directly inside the current session directory. It does not copy files from `workspace/`. +This is the per-session file area used for user uploads and files the user should view or download in the chat. Decision rule: - If the file is private, reusable later, or part of your own work process — use `workspace/`. -- If the user should open, preview, download, or see live updates to the file in this chat — create or move it into the current session directory, then call `content_publish`. -- If you create something in `workspace/` and later decide to publish it, copy or rewrite the final artifact into the session directory first. -- If the user only needs a download link for an existing local file, `share_file` can copy it into the session directory. If the user needs an inline viewer card, the file must be in the session directory and then `content_publish` registers it. +- If the user should keep or download an existing local file — call `share_file` with an absolute source path. It copies the file into the session directory and returns a download link. +- If the user should preview, inspect, or see live updates to a file in chat — put the file in the session directory, then call `content_publish`. It registers an existing session file for an inline viewer card and does not copy from `workspace/`. +- If you create something in `workspace/` and later decide to show it inline, copy or rewrite the final artifact into the session directory first, then call `content_publish`. +- For download, use `share_file`. For inline viewer, use `content_publish`. Do not substitute one for the other. - To discover the exact session directory, call `content_publish` after checking/creating the intended filename, or inspect the filesystem path from uploaded-file hints/tool output. When a publish fails, trust the error message's session directory path. EXECUTION MODES: diff --git a/tests/unit/tools/test_share_file.py b/tests/unit/tools/test_share_file.py new file mode 100644 index 0000000..7b4156d --- /dev/null +++ b/tests/unit/tools/test_share_file.py @@ -0,0 +1,66 @@ +"""Unit tests for share_file tool.""" + +from urllib.parse import unquote, urlparse + +import pytest + +import navi.tools.share_file as share_file_mod +from navi.tools.base import current_session_id +from navi.tools.share_file import ShareFileTool + + +class TestShareFileTool: + @pytest.fixture + def tool(self, monkeypatch, tmp_path): + async def _to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(share_file_mod.asyncio, "to_thread", _to_thread) + monkeypatch.setattr(share_file_mod.settings, "session_files_dir", str(tmp_path / "sessions")) + monkeypatch.setattr(share_file_mod.settings, "share_file_max_size_mb", 1024) + monkeypatch.setattr(share_file_mod.settings, "public_url", "http://localhost:8000") + token = current_session_id.set("sess 1") + try: + yield ShareFileTool() + finally: + current_session_id.reset(token) + + async def test_rejects_relative_path(self, tool): + result = await tool.execute({"path": "workspace/report.txt"}) + + assert not result.success + assert result.error == "path_not_absolute" + + async def test_copies_file_into_session_dir(self, tool, tmp_path): + src = tmp_path / "report.txt" + src.write_text("hello") + + result = await tool.execute({"path": str(src), "filename": "clean report.txt"}) + + assert result.success + dest = tmp_path / "sessions" / "sess 1" / "clean report.txt" + assert dest.read_text() == "hello" + assert result.metadata["filename"] == "clean report.txt" + + async def test_rejects_files_over_share_limit(self, tool, monkeypatch, tmp_path): + monkeypatch.setattr(share_file_mod.settings, "share_file_max_size_mb", 0) + src = tmp_path / "too_large.bin" + src.write_bytes(b"x") + + result = await tool.execute({"path": str(src)}) + + assert not result.success + assert result.error == "file_too_large" + + async def test_url_quotes_session_and_filename(self, tool, tmp_path): + src = tmp_path / "source.txt" + src.write_text("hello") + + result = await tool.execute({"path": str(src), "filename": "отчёт #1.txt"}) + + assert result.success + parsed = urlparse(result.metadata["url"]) + assert parsed.path.endswith( + "/sessions/sess%201/files/%D0%BE%D1%82%D1%87%D1%91%D1%82%20%231.txt" + ) + assert unquote(parsed.path).endswith("/sessions/sess 1/files/отчёт #1.txt")