---
@@ -36,7 +36,7 @@ docker compose up -d --build
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
-Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
+Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](website/setup.md).
## Features
@@ -51,7 +51,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo
-A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
+A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
## Contributing
@@ -64,7 +64,7 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
-Deployment details are in the [setup guide](docs/setup.md#security-notes).
+Deployment details are in the [setup guide](website/setup.md#security-notes).
## Star History
diff --git a/docs/odysseus-browser.jpg b/assets/branding/odysseus-browser.jpg
similarity index 100%
rename from docs/odysseus-browser.jpg
rename to assets/branding/odysseus-browser.jpg
diff --git a/docs/odysseus-wordmark.png b/assets/branding/odysseus-wordmark.png
similarity index 100%
rename from docs/odysseus-wordmark.png
rename to assets/branding/odysseus-wordmark.png
diff --git a/docs/odysseus.jpg b/assets/branding/odysseus.jpg
similarity index 100%
rename from docs/odysseus.jpg
rename to assets/branding/odysseus.jpg
diff --git a/build-macos-app.sh b/build-macos-app.sh
index c76075cac..7ea2c4b7f 100755
--- a/build-macos-app.sh
+++ b/build-macos-app.sh
@@ -27,13 +27,13 @@ echo " port: $PORT"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
-# ── Icon (best effort) — center-crop docs/odysseus.jpg to a square .icns ──
-if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
+# ── Icon (best effort) — center-crop the branding image to a square .icns ──
+if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
# let sips emit the .icns directly — more robust across macOS versions than
# building an .iconset by hand.
- sips -c 720 720 "$REPO_DIR/docs/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/docs/odysseus.jpg" "$TMPIMG/sq.png"
+ sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns"
@@ -42,7 +42,7 @@ if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
fi
rm -rf "$TMPIMG"
else
- echo " icon: (skipped — no docs/odysseus.jpg)"
+ echo " icon: (skipped — no assets/branding/odysseus.jpg)"
fi
# ── Info.plist ──
diff --git a/scripts/encode_previews.sh b/scripts/encode_previews.sh
index 1d8a51466..47cb47b75 100755
--- a/scripts/encode_previews.sh
+++ b/scripts/encode_previews.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Encode a source screen-recording (.mkv) into web-optimized preview clips for
-# the landing page: docs/.webm (VP9) + docs/.mp4 (H.264).
+# the landing page: website/.webm (VP9) + website/.mp4 (H.264).
#
# ./encode_previews.sh [max_secs]
#
@@ -13,7 +13,7 @@ set -euo pipefail
IN="${1:?input file}"
NAME="${2:?output basename}"
MAX="${3:-30}"
-OUT_DIR="$(cd "$(dirname "$0")/../docs" && pwd)"
+OUT_DIR="$(cd "$(dirname "$0")/../website" && pwd)"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$IN" | cut -d. -f1)
dur=${dur:-0}
diff --git a/tests/test_docs_no_orphan_images.py b/tests/test_docs_no_orphan_images.py
index a8f8a4331..f6ed43560 100644
--- a/tests/test_docs_no_orphan_images.py
+++ b/tests/test_docs_no_orphan_images.py
@@ -1,30 +1,39 @@
-"""Regression guard for issue #1335 — PR review screenshots were committed into
-docs/ (docs/a11y/*.png from #738, docs/gallery-314-*.png from #644) where they
-served no purpose: nothing in the repo referenced them, so they just showed up
-as "random images" in the doc folder.
+"""Repository asset ownership guards for issues #1335 and #6175.
-This test fails if any image under docs/ is orphaned — present in the tree but
-referenced by no tracked text file. The intended doc assets (the README hero
-image and the feature preview clips) are referenced, so they pass; a stray
-screenshot dropped in by a future PR would not.
+Public Markdown and landing-page media belong in website/, while shared
+README/packaging imagery belongs in assets/branding/. Images in either managed
+root must be referenced by tracked text, and every tracked website video must
+be referenced by the site's entry point.
"""
+import re
import subprocess
from pathlib import Path
+from urllib.parse import urlsplit
import pytest
REPO = Path(__file__).resolve().parent.parent
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
+VIDEO_EXTS = {".webm", ".mp4", ".mov", ".m4v"}
+PUBLIC_GUIDES = {
+ "agent-migration.md",
+ "attachments.md",
+ "backup-restore.md",
+ "email-outlook.md",
+ "pr-blocker-audit.md",
+ "security-ci.md",
+ "setup.md",
+}
# Files a referenced image name could legitimately appear in.
TEXT_EXTS = {".md", ".html", ".htm", ".js", ".ts", ".css", ".py", ".sh",
".json", ".yml", ".yaml", ".txt"}
-def _tracked(paths_under):
- """Git-tracked files under a path, or None if git isn't available."""
+def _tracked(*paths_under):
+ """Git-tracked files under paths, or None if git isn't available."""
try:
out = subprocess.run(
- ["git", "ls-files", paths_under],
+ ["git", "ls-files", "--", *paths_under],
cwd=REPO, capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.SubprocessError):
@@ -34,12 +43,14 @@ def _tracked(paths_under):
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
-def test_no_orphan_images_in_docs():
- docs_images = _tracked("docs")
- if docs_images is None:
+def test_no_orphan_documentation_or_branding_images():
+ managed_files = _tracked("website", "assets/branding")
+ if managed_files is None:
pytest.skip("not a git checkout")
- docs_images = [p for p in docs_images if p.suffix.lower() in IMAGE_EXTS]
- assert docs_images, "expected docs/ to still contain referenced doc assets"
+ managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
+ assert any("assets/branding" in p.as_posix() for p in managed_images), (
+ "expected assets/branding/ to contain the shared project imagery"
+ )
# All tracked text we might reference an image from.
all_tracked = _tracked(".") or []
@@ -55,10 +66,117 @@ def test_no_orphan_images_in_docs():
orphans = [
str(img.relative_to(REPO))
- for img in docs_images
+ for img in managed_images
if img.name not in blob
]
assert not orphans, (
- "unreferenced image(s) committed under docs/ — likely PR screenshots "
- f"added by accident (see #1335): {orphans}"
+ "unreferenced image(s) committed under website/ or assets/branding/ "
+ f"(see #1335 and #6175): {orphans}"
)
+
+
+def test_pages_site_owns_its_entrypoint_and_media():
+ docs_files = _tracked("docs")
+ website_files = _tracked("website")
+ if docs_files is None or website_files is None:
+ pytest.skip("not a git checkout")
+
+ assert REPO / "website/index.html" in website_files
+ assert REPO / "docs/index.html" not in docs_files
+ assert not [p for p in docs_files if p.suffix.lower() in VIDEO_EXTS | {".md"}]
+
+ website_paths = {p.relative_to(REPO / "website").as_posix() for p in website_files}
+ assert PUBLIC_GUIDES <= website_paths
+ for guide in PUBLIC_GUIDES:
+ text = (REPO / "website" / guide).read_text(encoding="utf-8")
+ assert text.startswith("---\nlayout: default\n---\n"), guide
+
+ website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
+ assert website_videos, "expected website/ to contain the landing-page videos"
+
+ entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
+ unreferenced = [
+ str(video.relative_to(REPO))
+ for video in website_videos
+ if video.name not in entrypoint
+ ]
+ assert not unreferenced, f"unreferenced website video(s): {unreferenced}"
+
+ workflow = (REPO / ".github/workflows/deploy-pages.yml").read_text(encoding="utf-8")
+ assert "actions/jekyll-build-pages@" in workflow
+ assert "source: website" in workflow
+ assert "destination: _site" in workflow
+ assert "path: _site" in workflow
+ assert "cancel-in-progress: false" in workflow
+
+
+def test_pages_guides_keep_relative_links_inside_site():
+ site_root = (REPO / "website").resolve()
+
+ for guide in sorted(PUBLIC_GUIDES):
+ guide_path = REPO / "website" / guide
+ text = guide_path.read_text(encoding="utf-8")
+ for target in re.findall(r"\]\(([^)]+)\)", text):
+ parsed = urlsplit(target)
+ if parsed.scheme or parsed.netloc or not parsed.path:
+ continue
+
+ resolved = (guide_path.parent / parsed.path).resolve()
+ assert resolved.is_relative_to(site_root), (
+ f"{guide} links outside the Pages source: {target}"
+ )
+ assert resolved.exists(), f"{guide} has a missing local link: {target}"
+
+
+def test_setup_preserves_docker_go_template_literal():
+ setup = (REPO / "website/setup.md").read_text(encoding="utf-8")
+ guarded_command = """
+```bash
+docker info --format '{{.DockerRootDir}}'
+```
+"""
+
+ assert guarded_command in setup
+
+
+def test_preview_encoder_targets_pages_source():
+ encoder = (REPO / "scripts/encode_previews.sh").read_text(encoding="utf-8")
+
+ assert "landing page: website/.webm" in encoder
+ assert 'OUT_DIR="$(cd "$(dirname "$0")/../website" && pwd)"' in encoder
+
+
+def test_ci_runs_asset_ownership_guards_for_managed_roots():
+ workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")
+ match = re.search(r"grep -Ev '([^']+)'", workflow)
+ assert match, "expected the docs-only path classifier in CI"
+ docs_only = re.compile(match.group(1))
+
+ assert docs_only.match("README.md")
+ assert docs_only.match("docs/example.md")
+ assert not docs_only.match("website/setup.md")
+ assert not docs_only.match("website/new-preview.webm")
+ assert not docs_only.match("assets/branding/new-logo.png")
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "website/favicon.png",
+ "website/media/social-card.jpg",
+ "website/guides/reference.pdf",
+ "assets/branding/new-logo.gif",
+ "assets/branding/print/logo.tiff",
+ ],
+)
+def test_managed_site_media_is_not_ignored(path):
+ result = subprocess.run(
+ ["git", "check-ignore", "--no-index", "--quiet", path],
+ cwd=REPO,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode == 128:
+ pytest.skip("not a git checkout")
+ assert result.returncode == 1, f"{path} is unexpectedly ignored"
diff --git a/tests/test_kokoro_optional_requirements.py b/tests/test_kokoro_optional_requirements.py
index 53e670a03..8e9c70ce3 100644
--- a/tests/test_kokoro_optional_requirements.py
+++ b/tests/test_kokoro_optional_requirements.py
@@ -30,7 +30,7 @@ def test_kokoro_feature_markers_match_supported_python_range(python_version, sel
def test_setup_documents_container_constraint_and_install_command():
- setup = (ROOT / "docs" / "setup.md").read_text(encoding="utf-8")
+ setup = (ROOT / "website" / "setup.md").read_text(encoding="utf-8")
assert "pip install -r requirements-optional.txt" in setup
assert "default Docker image currently uses Python 3.14" in setup
diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py
index be8d3b8a3..1b467c6b4 100644
--- a/tests/test_security_regressions.py
+++ b/tests/test_security_regressions.py
@@ -123,10 +123,10 @@ def test_docker_compose_binds_web_ui_to_loopback_by_default():
def test_readme_native_quickstart_uses_loopback():
- # The README refresh (#4306) moved the native quickstart into docs/setup.md,
+ # The Pages source split (#6175) moved the native quickstart into website/setup.md,
# so accept the loopback guidance from either the README or the setup guide.
docs = Path("README.md").read_text(encoding="utf-8")
- docs += "\n" + Path("docs/setup.md").read_text(encoding="utf-8")
+ docs += "\n" + Path("website/setup.md").read_text(encoding="utf-8")
assert "python -m uvicorn app:app --host 127.0.0.1 --port 7000" in docs
assert "0.0.0.0` only when you intentionally want" in docs
diff --git a/tests/test_setup_admin_user.py b/tests/test_setup_admin_user.py
index b0fde4d75..7e6959577 100644
--- a/tests/test_setup_admin_user.py
+++ b/tests/test_setup_admin_user.py
@@ -29,7 +29,7 @@ def test_create_default_admin_normalizes_env_username(tmp_path, monkeypatch):
def test_main_loads_admin_password_from_env_file(tmp_path, monkeypatch):
"""Regression: setup.py must honor an admin password pre-seeded in .env on
native installs, even when the var is not exported into the shell
- (docs/setup.md documents this). Previously setup.py never called
+ (website/setup.md documents this). Previously setup.py never called
load_dotenv(), so os.getenv() saw nothing and a random password was
generated instead."""
import bcrypt
diff --git a/website/_config.yml b/website/_config.yml
new file mode 100644
index 000000000..f383681ec
--- /dev/null
+++ b/website/_config.yml
@@ -0,0 +1,10 @@
+title: Odysseus
+description: A self-hosted AI workspace for chat, agents, tools, and local models.
+theme: jekyll-theme-primer
+
+plugins:
+ - jekyll-relative-links
+
+relative_links:
+ enabled: true
+ collections: true
diff --git a/docs/agent-migration.md b/website/agent-migration.md
similarity index 99%
rename from docs/agent-migration.md
rename to website/agent-migration.md
index ff082159e..7abf776ab 100644
--- a/docs/agent-migration.md
+++ b/website/agent-migration.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# Agent migration manifests
Odysseus should be able to learn from another agent without blindly trusting
diff --git a/docs/attachments.md b/website/attachments.md
similarity index 99%
rename from docs/attachments.md
rename to website/attachments.md
index 93f9e0ffe..d622f9050 100644
--- a/docs/attachments.md
+++ b/website/attachments.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# Attachment References and Upload Storage
Odysseus stores uploaded bytes once under the configured upload directory and
diff --git a/docs/backup-restore.md b/website/backup-restore.md
similarity index 99%
rename from docs/backup-restore.md
rename to website/backup-restore.md
index 902c9e683..424b8bd59 100644
--- a/docs/backup-restore.md
+++ b/website/backup-restore.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# Backup & Restore
Odysseus keeps all of your state in the `data/` directory — the SQLite database
diff --git a/docs/bg.webm b/website/bg.webm
similarity index 100%
rename from docs/bg.webm
rename to website/bg.webm
diff --git a/docs/chat.webm b/website/chat.webm
similarity index 100%
rename from docs/chat.webm
rename to website/chat.webm
diff --git a/docs/compare.webm b/website/compare.webm
similarity index 100%
rename from docs/compare.webm
rename to website/compare.webm
diff --git a/docs/document.webm b/website/document.webm
similarity index 100%
rename from docs/document.webm
rename to website/document.webm
diff --git a/docs/email-outlook.md b/website/email-outlook.md
similarity index 97%
rename from docs/email-outlook.md
rename to website/email-outlook.md
index 1f8b97d5d..0646d39ba 100644
--- a/docs/email-outlook.md
+++ b/website/email-outlook.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# Outlook / Office 365 email accounts
Odysseus email accounts currently use IMAP and SMTP with username/password
diff --git a/docs/gallery.webm b/website/gallery.webm
similarity index 100%
rename from docs/gallery.webm
rename to website/gallery.webm
diff --git a/docs/index.html b/website/index.html
similarity index 100%
rename from docs/index.html
rename to website/index.html
diff --git a/docs/notes.webm b/website/notes.webm
similarity index 100%
rename from docs/notes.webm
rename to website/notes.webm
diff --git a/docs/pr-blocker-audit.md b/website/pr-blocker-audit.md
similarity index 99%
rename from docs/pr-blocker-audit.md
rename to website/pr-blocker-audit.md
index b56f28cb3..0a9258f15 100644
--- a/docs/pr-blocker-audit.md
+++ b/website/pr-blocker-audit.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# PR Blocker Audit
`scripts/pr_blocker_audit.py` is a small, read-only triage helper for maintainers who need to inspect open pull request overlap before reviewing or starting related work.
diff --git a/docs/research.webm b/website/research.webm
similarity index 100%
rename from docs/research.webm
rename to website/research.webm
diff --git a/docs/security-ci.md b/website/security-ci.md
similarity index 99%
rename from docs/security-ci.md
rename to website/security-ci.md
index 8cceea258..1e68e7db7 100644
--- a/docs/security-ci.md
+++ b/website/security-ci.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# Security CI guide
This project runs a set of automated security checks on pull requests and
diff --git a/docs/setup.md b/website/setup.md
similarity index 99%
rename from docs/setup.md
rename to website/setup.md
index 523dd41d7..aeaf6baf6 100644
--- a/docs/setup.md
+++ b/website/setup.md
@@ -1,3 +1,7 @@
+---
+layout: default
+---
+
# Odysseus Setup Guide
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
@@ -15,8 +19,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**.
-Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
-pull request guidelines.
+Contributing? See [CONTRIBUTING.md](https://github.com/odysseus-dev/odysseus/blob/dev/CONTRIBUTING.md) for setup, testing, and pull request guidelines.
### Docker (recommended)
```bash
@@ -205,9 +208,11 @@ failed to fulfil mount request: open /usr/lib/wsl/lib/libdxcore.so: no such file
Check with `snap list docker` or:
+
```bash
docker info --format '{{.DockerRootDir}}'
```
+
A Docker root under `/var/snap/docker/` means snap confinement can prevent
Docker from seeing WSL2's `/usr/lib/wsl/lib` GPU libraries even when the files
@@ -476,7 +481,7 @@ uv pip sync requirements.lock # reproduce it exactly la
### Outlook / Office 365 email
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
-passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
+passwords will fail. See [the Outlook email guide](email-outlook.md) for the
current limitation and the planned integration direction.
## Security Notes
@@ -733,7 +738,7 @@ src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end)
-docs/ landing page (index.html) + preview clips
+website/ landing page (index.html) + preview clips
```
## Data
diff --git a/docs/theme.webm b/website/theme.webm
similarity index 100%
rename from docs/theme.webm
rename to website/theme.webm