diff --git a/manuals/content_publish.md b/manuals/content_publish.md
index 7578026..159aa2f 100644
--- a/manuals/content_publish.md
+++ b/manuals/content_publish.md
@@ -17,6 +17,7 @@
2. Инструмент **не копирует** файл — он только регистрирует метаданные в базе данных.
3. Пользователь видит файл по URL `/sessions/{session_id}/files/{filename}`.
4. Если вы отредактируете файл после публикации, пользователь увидит изменения **сразу** (перезагружать страницу не нужно).
+5. Опубликованные файлы остаются доступными в панели Artifacts, поэтому повторная публикация не нужна только для того, чтобы снова показать карточку ниже в диалоге.
## Формат вызова
@@ -61,7 +62,7 @@
filesystem write session_files/sess-abc/chart.svg ""
```
-Пользователь увидит изменения мгновенно — URL не меняется.
+Пользователь увидит изменения через ту же карточку и панель Artifacts — URL не меняется, повторно вызывать `content_publish` не нужно.
## Поддерживаемые типы
@@ -83,3 +84,6 @@
3. **Проверяйте коллизии** — если файл с таким именем уже есть, запись публикации будет обновлена, а файл останется тем же путём.
4. **Используйте понятные имена** — `chart.svg` лучше чем `file_1.svg`.
5. **Для редактирования используйте тот же путь** — не создавайте новые копии, правьте оригинал в директории сессии.
+6. **Не дублируйте опубликованный контент в текстовом ответе** — `content_publish` уже отображает файл пользователю в чате в виде интерактивной карточки и в панели Artifacts. После вызова `content_publish` не вставляйте SVG-код, HTML-разметку, base64-изображения, screenshots, iframe/markdown preview или вторую визуальную копию результата в текст сообщения. В текстовом ответе достаточно краткого подтверждения и не-визуальных пояснений.
+7. **Не публикуйте повторно ради видимости** — если карточка ушла выше по диалогу, пользователь всё равно найдёт файл в панели Artifacts. Для обновления содержимого правьте тот же файл в директории сессии.
+8. **Повторная публикация допустима осознанно** — используйте её для нового файла или если нужно обновить метаданные публикации, например `title` или `content_type`.
diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py
index 3451cf3..35539e6 100644
--- a/navi/api/routes/sessions.py
+++ b/navi/api/routes/sessions.py
@@ -12,6 +12,7 @@
from pydantic import BaseModel
from navi.api.deps import get_backend_registry, get_memory_store, get_profile_registry, get_session_store
+from navi.content_store import list_for_session
from navi.config import settings
from navi.core import BackendRegistry, ProfileRegistry, SessionStore
from navi.core.name_generator import generate_session_name
@@ -184,6 +185,18 @@
return {"session_id": session.id, "logs": session.planning_logs}
+@router.get("/{session_id}/content")
+async def get_session_content(
+ session_id: str,
+ store: Annotated[SessionStore, Depends(get_session_store)],
+) -> dict:
+ """Return published inline content records for this session."""
+ session = await store.get(session_id)
+ if session is None:
+ raise HTTPException(status_code=404, detail="Session not found")
+ return {"session_id": session.id, "content": await list_for_session(session_id)}
+
+
@router.post("/{session_id}/files", status_code=201)
async def upload_file(
session_id: str,
@@ -244,6 +257,7 @@
session_id: str,
filename: str,
store: Annotated[SessionStore, Depends(get_session_store)],
+ download: bool = False,
) -> FileResponse:
"""Download a file from the session's file directory."""
session = await store.get(session_id)
@@ -265,7 +279,7 @@
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)
+ inline = not download and any(content_type.startswith(t) for t in inline_types)
return FileResponse(
path=file_path,
diff --git a/navi/content_store.py b/navi/content_store.py
index 34871fa..c0ec08d 100644
--- a/navi/content_store.py
+++ b/navi/content_store.py
@@ -42,6 +42,21 @@
return _EXT_TO_TYPE.get(ext, "unknown")
+def _file_url(session_id: str, filename: str, *, download: bool = False) -> str:
+ base_url = settings.public_url.rstrip("/")
+ url = f"{base_url}/sessions/{session_id}/files/{filename}"
+ return f"{url}?download=1" if download else url
+
+
+def _file_updated_at(session_id: str, filename: str, fallback: datetime) -> datetime:
+ path = session_dir(session_id) / filename
+ try:
+ stat = path.stat()
+ except OSError:
+ return fallback
+ return datetime.fromtimestamp(stat.st_mtime, timezone.utc)
+
+
async def _get_db_pool():
"""Get asyncpg pool from deps (lazy import to avoid circular)."""
from navi.api.deps import get_memory_store
@@ -122,8 +137,8 @@
log.warning("content_store.db_upsert_failed", content_id=content_id, exc_info=True)
raise
- base_url = settings.public_url.rstrip("/")
- url = f"{base_url}/sessions/{session_id}/files/{filename}"
+ url = _file_url(session_id, filename)
+ updated_at = _file_updated_at(session_id, filename, datetime.now(timezone.utc))
log.info(
"content_store.published",
@@ -136,9 +151,11 @@
return {
"id": content_id,
"url": url,
+ "download_url": _file_url(session_id, filename, download=True),
"filename": filename,
"content_type": detected_type,
"title": title or filename,
+ "updated_at": updated_at.isoformat(),
}
@@ -154,16 +171,26 @@
"FROM session_content WHERE session_id=$1 ORDER BY created_at DESC",
session_id,
)
- return [
- {
+ items = []
+ for r in rows:
+ created_at = r["created_at"]
+ if not isinstance(created_at, datetime):
+ try:
+ created_at = datetime.fromisoformat(str(created_at))
+ except ValueError:
+ created_at = datetime.now(timezone.utc)
+ updated_at = _file_updated_at(session_id, r["filename"], created_at)
+ items.append({
"id": r["id"],
"filename": r["filename"],
"content_type": r["content_type"],
"title": r["title"],
+ "url": _file_url(session_id, r["filename"]),
+ "download_url": _file_url(session_id, r["filename"], download=True),
"created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else str(r["created_at"]),
- }
- for r in rows
- ]
+ "updated_at": updated_at.isoformat(),
+ })
+ return items
async def delete_content(content_id: str) -> bool:
diff --git a/navi/core/agent.py b/navi/core/agent.py
index f0138d7..71a792d 100644
--- a/navi/core/agent.py
+++ b/navi/core/agent.py
@@ -241,7 +241,8 @@
response = await llm.complete(
self._ctx_builder.build(session.context, profile, mem,
iteration=iteration, max_iterations=profile.max_iterations,
- extra_system=ctx_injections),
+ extra_system=ctx_injections,
+ session_id=session_id),
tools=tool_schemas if tools else None,
temperature=profile.temperature,
model=profile.model,
@@ -664,7 +665,8 @@
built_ctx = self._ctx_builder.build(session.context, profile, mem,
iteration=iteration, max_iterations=profile.max_iterations,
- extra_system=ctx_injections)
+ extra_system=ctx_injections,
+ session_id=session_id)
if (
profile.goal_anchoring_enabled
@@ -1033,4 +1035,3 @@
f"output_reserve {output_reserve:,}). "
"Split the file into smaller parts or delegate to a subagent."
)
-
diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py
index 7460a77..5253000 100644
--- a/navi/core/context_builder.py
+++ b/navi/core/context_builder.py
@@ -126,6 +126,7 @@
iteration: int | None = None,
max_iterations: int | None = None,
extra_system: list[Message] | None = None,
+ session_id: str | None = None,
) -> list[Message]:
system_msg = Message(
role="system",
@@ -155,4 +156,16 @@
content=f"[Iteration {iteration + 1}/{max_iterations} — {remaining} remaining.{urgency}]",
))
+ # Inject session identity so the model knows its own session_id for file paths
+ if session_id:
+ result.insert(1, Message(
+ role="system",
+ content=(
+ f"[Session context]\n"
+ f"Session ID: {session_id}\n"
+ f"Session files directory: {_config.settings.session_files_dir}/{session_id}/\n"
+ f"When writing files the user should see, always use the session directory path above."
+ ),
+ ))
+
return result
diff --git a/navi/llm/ollama.py b/navi/llm/ollama.py
index 5e0146c..9268410 100644
--- a/navi/llm/ollama.py
+++ b/navi/llm/ollama.py
@@ -32,17 +32,12 @@
def _base_options(
temperature: float,
- think: bool | None = None,
max_tokens: int | None = None,
top_k: int | None = None,
top_p: float | None = None,
num_thread: int | None = None,
) -> dict:
opts: dict = {"temperature": temperature, "num_ctx": settings.ollama_num_ctx}
- # think=None → use global setting; think=False → force off even if global is True
- effective_think = settings.ollama_think if think is None else think
- if effective_think:
- opts["think"] = True
if max_tokens is not None:
opts["num_predict"] = max_tokens
if top_k is not None:
@@ -54,6 +49,11 @@
return opts
+def _resolve_think(think: bool | None) -> bool | None:
+ # think=None → use global setting; think=False → force off even if global is True
+ return settings.ollama_think if think is None else think
+
+
def _resolve_model(model: "list[str] | str | None", default: str) -> str:
"""Normalize model param: list → first element, None → default."""
if isinstance(model, list):
@@ -111,8 +111,9 @@
kwargs: dict = {
"model": resolved,
"messages": _to_ollama_messages(messages),
- "options": _base_options(temperature, think=think, max_tokens=max_tokens, top_k=top_k, top_p=top_p, num_thread=num_thread),
+ "options": _base_options(temperature, max_tokens=max_tokens, top_k=top_k, top_p=top_p, num_thread=num_thread),
"stream": False,
+ "think": _resolve_think(think),
}
if tools:
kwargs["tools"] = _to_ollama_tools(tools)
@@ -193,8 +194,9 @@
kwargs: dict = {
"model": resolved,
"messages": _to_ollama_messages(messages),
- "options": _base_options(temperature, think=think, top_k=top_k, top_p=top_p, num_thread=num_thread),
+ "options": _base_options(temperature, top_k=top_k, top_p=top_p, num_thread=num_thread),
"stream": True,
+ "think": _resolve_think(think),
}
if tools:
kwargs["tools"] = _to_ollama_tools(tools)
diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json
index e966949..2a2e431 100644
--- a/navi/profiles/developer/config.json
+++ b/navi/profiles/developer/config.json
@@ -10,12 +10,12 @@
},
"llm_backend": "ollama",
"model": [
+ "gemma4:26b-a4b-it-q4_K_M",
"gemma4:31b-cloud",
"kimi-k2.6:cloud",
- "qwen3.6:27b",
- "gemma4:26b-a4b-it-q4_K_M"
+ "qwen3.6:27b"
],
- "temperature": 0.45,
+ "temperature": 0.35,
"max_iterations": 45,
"planning_enabled": true,
"subagent_planning_enabled": true,
diff --git a/navi/profiles/discuss/config.json b/navi/profiles/discuss/config.json
index 3242ec6..c0e816b 100644
--- a/navi/profiles/discuss/config.json
+++ b/navi/profiles/discuss/config.json
@@ -4,12 +4,12 @@
"description": "Creative partner for Q&A, brainstorming, and idea exploration. High creativity, free-form thinking.",
"short_description": "Creative Q&A and idea discussion — best for open questions, brainstorming, and exploring concepts.",
"model": [
+ "gemma4:26b-a4b-it-q4_K_M",
"gemma4:31b-cloud",
"kimi-k2.6:cloud",
- "qwen3.6:27b",
- "gemma4:26b-a4b-it-q4_K_M"
+ "qwen3.6:27b"
],
- "temperature": 0.85,
+ "temperature": 0.65,
"max_iterations": 12,
"enabled_tools": [
"web_search",
diff --git a/navi/profiles/secretary/config.json b/navi/profiles/secretary/config.json
index bef9688..d8af62f 100644
--- a/navi/profiles/secretary/config.json
+++ b/navi/profiles/secretary/config.json
@@ -10,12 +10,12 @@
},
"llm_backend": "ollama",
"model": [
+ "gemma4:26b-a4b-it-q4_K_M",
"gemma4:31b-cloud",
"kimi-k2.6:cloud",
- "qwen3.6:27b",
- "gemma4:26b-a4b-it-q4_K_M"
+ "qwen3.6:27b"
],
- "temperature": 0.65,
+ "temperature": 0.45,
"max_iterations": 35,
"planning_enabled": true,
"subagent_planning_enabled": true,
@@ -65,6 +65,6 @@
"planning_phase2_enabled": true,
"planning_phase3_enabled": true,
"top_k": 50,
- "top_p": 0.90,
+ "top_p": 0.9,
"num_thread": 11
}
diff --git a/navi/profiles/server_admin/config.json b/navi/profiles/server_admin/config.json
index 60805ff..ac264e6 100644
--- a/navi/profiles/server_admin/config.json
+++ b/navi/profiles/server_admin/config.json
@@ -10,12 +10,12 @@
},
"llm_backend": "ollama",
"model": [
+ "gemma4:26b-a4b-it-q4_K_M",
"gemma4:31b-cloud",
"kimi-k2.6:cloud",
- "qwen3.6:27b",
- "gemma4:26b-a4b-it-q4_K_M"
+ "qwen3.6:27b"
],
- "temperature": 0.30,
+ "temperature": 0.25,
"max_iterations": 40,
"planning_enabled": true,
"subagent_planning_enabled": true,
@@ -67,6 +67,6 @@
"planning_phase2_enabled": true,
"planning_phase3_enabled": true,
"top_k": 30,
- "top_p": 0.80,
+ "top_p": 0.8,
"num_thread": 11
}
diff --git a/navi/profiles/tool_developer/config.json b/navi/profiles/tool_developer/config.json
index 7edbc7d..51b1e4f 100644
--- a/navi/profiles/tool_developer/config.json
+++ b/navi/profiles/tool_developer/config.json
@@ -10,12 +10,12 @@
},
"llm_backend": "ollama",
"model": [
+ "gemma4:26b-a4b-it-q4_K_M",
"gemma4:31b-cloud",
"kimi-k2.6:cloud",
- "qwen3.6:27b",
- "gemma4:26b-a4b-it-q4_K_M"
+ "qwen3.6:27b"
],
- "temperature": 0.35,
+ "temperature": 0.25,
"max_iterations": 35,
"planning_enabled": true,
"subagent_planning_enabled": true,
diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py
index 1d20e0c..eff1a92 100644
--- a/navi/tools/filesystem.py
+++ b/navi/tools/filesystem.py
@@ -207,7 +207,9 @@
"Examples: 'what arguments does function X take?', 'on which line is class Y defined?', "
"'does this config contain key Z?', 'list all TODO comments'. "
"Pass the question in 'question'.\n"
- " • smart_edit — use INSTEAD of read+write for any semantic change to a file. "
+ " • edit — use for deterministic exact text replacement when you know the old text. "
+ "Pass 'old' and 'new'. The old text must match exactly and occur once.\n"
+ " • smart_edit — use for semantic changes when you know the instruction but not the exact replacement. "
"Examples: 'rename function foo to bar', 'add a docstring to method X', "
"'remove all commented-out code', 'change timeout from 30 to 60'. "
"Pass the instruction in 'instruction'. Returns a diff of what changed.\n"
@@ -230,7 +232,7 @@
"action": {
"type": "string",
"enum": [
- "read", "write", "append", "list", "find", "find_up",
+ "read", "write", "append", "edit", "list", "find", "find_up",
"info", "move", "delete", "exists", "mkdir",
"query", "smart_edit",
],
@@ -244,6 +246,14 @@
"type": "string",
"description": "Text to write or append (required for write/append).",
},
+ "old": {
+ "type": "string",
+ "description": "Exact text to replace (required for edit). Must occur exactly once.",
+ },
+ "new": {
+ "type": "string",
+ "description": "Replacement text for edit.",
+ },
"destination": {
"type": "string",
"description": "Target path for move action.",
@@ -314,6 +324,7 @@
case "read": return await asyncio.to_thread(self._read, path, params)
case "write": return await asyncio.to_thread(self._write, path, params)
case "append": return await asyncio.to_thread(self._append, path, params)
+ case "edit": return await asyncio.to_thread(self._edit, path, params)
case "list": return await asyncio.to_thread(self._list, path, params)
case "find": return await asyncio.to_thread(self._find, path, params)
case "find_up": return await asyncio.to_thread(self._find_up, path, params)
@@ -393,6 +404,52 @@
f.write(content)
return ToolResult(success=True, output=f"Appended {_fmt_size(len(content.encode()))} to {path} (file now {_fmt_size(path.stat().st_size)})")
+ def _edit(self, path: Path, params: dict) -> ToolResult:
+ old = params.get("old")
+ new = params.get("new")
+ if old is None:
+ return ToolResult(success=False, output="'old' is required for edit", error="missing_old")
+ if new is None:
+ return ToolResult(success=False, output="'new' is required for edit", error="missing_new")
+ if old == "":
+ return ToolResult(success=False, output="'old' must not be empty", error="empty_old")
+ if not path.exists():
+ return ToolResult(success=False, output=f"File not found: {path}", error="not_found")
+ if path.is_dir():
+ return ToolResult(success=False, output="edit works on files, not directories.", error="is_directory")
+
+ text = path.read_text(encoding="utf-8", errors="replace")
+ count = text.count(old)
+ if count == 0:
+ return ToolResult(
+ success=False,
+ output=(
+ "Exact text not found. Read the relevant file section and call edit again "
+ "with text copied exactly from the file."
+ ),
+ error="old_not_found",
+ )
+ if count > 1:
+ return ToolResult(
+ success=False,
+ output=(
+ f"Exact text occurs {count} times. Use a larger unique old text block "
+ "or smart_edit for a semantic change."
+ ),
+ error="old_not_unique",
+ )
+
+ new_text = text.replace(old, new, 1)
+ path.write_text(new_text, encoding="utf-8")
+ delta = len(new_text.encode()) - len(text.encode())
+ return ToolResult(
+ success=True,
+ output=(
+ f"Edited {path}: replaced {len(old.encode())} B with "
+ f"{len(new.encode())} B (delta {delta:+d} B)."
+ ),
+ )
+
def _list(self, path: Path, params: dict) -> ToolResult:
if not path.exists():
return ToolResult(success=False, output=f"Path not found: {path}", error="not_found")
diff --git a/persona.txt b/persona.txt
index 667c9a6..275f66d 100644
--- a/persona.txt
+++ b/persona.txt
@@ -37,6 +37,9 @@
- If the file is private, reusable later, or part of your own work process — use `workspace/`.
- 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/`.
+- The current session ID and exact session directory path are injected into every turn context — use that path when writing files the user should see. Do not write session-visible files to the project root or `workspace/` unless they are private drafts.
+- After calling `content_publish`, do NOT duplicate or re-render the published content in your text response. Do not paste SVG/HTML/base64, do not embed the same preview again, and do not create a second visual representation in prose or markdown. The user already sees the file through the inline viewer card and Artifacts panel; your text should only provide a brief confirmation and any non-visual notes.
+- Do not call `content_publish` again just to move a preview card lower in the chat. Published files remain available in the Artifacts panel. When updating a published artifact, edit the same session file path; call `content_publish` again only when registering a new file or intentionally changing publication metadata such as title or content type.
- 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.
@@ -119,7 +122,7 @@
When the user's message starts with one or more lines prefixed with "> " (markdown blockquote), this means they are replying to a specific fragment of your previous response. Treat the quoted text as the exact context they are addressing. Respond to their follow-up in relation to that specific fragment — do not ask which part they mean, it is already shown.
CODE GENERATION — SVG/HTML/XML:
-When generating SVG, HTML, XML, or any markup: always use single angle brackets for tags: `