mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-29 03:22:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Agent migration manifests
|
||||
|
||||
Odysseus should be able to learn from another agent without blindly trusting
|
||||
that agent's whole state. The safe migration path is:
|
||||
|
||||
```text
|
||||
source agent export -> source adapter -> agent-migration.v1 manifest -> preview -> apply
|
||||
```
|
||||
|
||||
The manifest is intentionally source-neutral. OpenClaw, Hermes, a folder of
|
||||
Markdown notes, or any other agent can have its own adapter, but Odysseus only
|
||||
needs to understand the normalized manifest.
|
||||
|
||||
## Why not import everything as memory?
|
||||
|
||||
Durable memory should stay compact and useful. Long notes, logs, session
|
||||
transcripts, and project archives are useful context, but they are not all
|
||||
memories. A good migration keeps two layers separate:
|
||||
|
||||
- **Archive documents** preserve source material for search, reading, and later
|
||||
extraction.
|
||||
- **Memory candidates** are short facts or preferences that can be reviewed
|
||||
before being saved into Odysseus memory.
|
||||
|
||||
This keeps Odysseus' existing memory-review flow intact while giving it better
|
||||
source material to review.
|
||||
|
||||
## Manifest shape
|
||||
|
||||
`agent-migration.v1` is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "agent-migration.v1",
|
||||
"generated_at": "2026-06-06T00:00:00Z",
|
||||
"source": {
|
||||
"name": "example-agent",
|
||||
"kind": "generic"
|
||||
},
|
||||
"summary": {
|
||||
"item_count": 3,
|
||||
"counts_by_kind": {
|
||||
"memory": 1,
|
||||
"skill": 1,
|
||||
"conversation_thread": 1,
|
||||
"archive_document": 1
|
||||
},
|
||||
"warning_count": 0
|
||||
},
|
||||
"items": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Each item has a stable `id`, a `kind`, source metadata, and enough content for a
|
||||
future importer to preview it before applying.
|
||||
|
||||
Supported item kinds in the first pass:
|
||||
|
||||
- `memory` — a candidate memory with `text`, `category`, `source`, and
|
||||
provenance metadata.
|
||||
- `skill` — a `SKILL.md` file with content and parsed frontmatter metadata.
|
||||
- `conversation_thread` — a normalized transcript thread from an exported chat
|
||||
history. Message content is optional; adapters can preserve only thread
|
||||
metadata, message counts, timestamps, and hashes when a manifest should stay
|
||||
small or avoid embedding private transcript text.
|
||||
- `archive_document` — long-form source material. Content is optional; adapters
|
||||
can preserve only path/hash/size metadata when a manifest should stay small.
|
||||
|
||||
## Build a manifest
|
||||
|
||||
Use the read-only helper:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name old-agent \
|
||||
--source-kind generic \
|
||||
--memory-json /path/to/memories.json \
|
||||
--skills-dir /path/to/skills \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--archive /path/to/notes \
|
||||
--output /tmp/agent-migration.json
|
||||
```
|
||||
|
||||
The helper does not write to `data/`, call an LLM, import Odysseus modules, or
|
||||
modify the source. It only writes JSON.
|
||||
|
||||
Memory JSON may be:
|
||||
|
||||
```json
|
||||
[
|
||||
"A plain memory string",
|
||||
{
|
||||
"text": "A categorized memory",
|
||||
"category": "preference",
|
||||
"source": "old-agent"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
or an object containing a list under `memories`, `memory`, `items`, or `data`.
|
||||
|
||||
Skills are scanned recursively for `SKILL.md`:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name hermes \
|
||||
--source-kind hermes \
|
||||
--skills-dir ~/.hermes/skills \
|
||||
--output /tmp/hermes-skills-manifest.json
|
||||
```
|
||||
|
||||
Archive documents are metadata-only by default. To embed text content:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name notes-export \
|
||||
--archive /path/to/markdown-notes \
|
||||
--include-archive-content \
|
||||
--output /tmp/notes-manifest.json
|
||||
```
|
||||
|
||||
Conversation exports are also metadata-only by default:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name chatgpt-export \
|
||||
--source-kind chatgpt \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--output /tmp/chatgpt-conversations-manifest.json
|
||||
```
|
||||
|
||||
The first pass supports generic conversation JSON such as:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "thread-1",
|
||||
"title": "Project plan",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Can we design this?"},
|
||||
{"role": "assistant", "content": "Yes, start with a narrow slice."}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
It also recognizes ChatGPT-style `mapping` exports from `conversations.json`.
|
||||
To embed normalized messages:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name chatgpt-export \
|
||||
--source-kind chatgpt \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--include-conversation-content \
|
||||
--max-conversation-messages 2000 \
|
||||
--output /tmp/chatgpt-conversations-with-content.json
|
||||
```
|
||||
|
||||
Content embedding is explicit because exported chat histories can be huge and
|
||||
private. A future source-specific adapter can add ZIP traversal, attachment
|
||||
metadata, and provider-specific project/workspace fields while still emitting
|
||||
the same `conversation_thread` manifest item.
|
||||
|
||||
## Recommended apply behavior
|
||||
|
||||
A future Odysseus importer should treat the manifest as untrusted user-provided
|
||||
data and apply it in stages:
|
||||
|
||||
1. Show a dry-run summary with counts, warnings, duplicates, and sample items.
|
||||
2. Back up current `data/` state before writing anything.
|
||||
3. Import archive documents as documents or another searchable source, not as
|
||||
memory.
|
||||
4. Import conversation threads as searchable archived context first, with
|
||||
citations back to the source thread. Do not turn whole transcripts into
|
||||
memory.
|
||||
5. Show memory candidates for review before saving through the normal memory
|
||||
path.
|
||||
6. Import skills only after name/category conflict checks.
|
||||
7. Skip secrets by default. Credentials need explicit, provider-specific flows.
|
||||
|
||||
## What belongs in source adapters?
|
||||
|
||||
Adapters can be source-specific. The core manifest should not be.
|
||||
|
||||
For example, an OpenClaw adapter may know about OpenClaw's workspace files. A
|
||||
Hermes adapter may know about `~/.hermes/config.yaml` and `~/.hermes/skills`.
|
||||
A ChatGPT adapter may know about `conversations.json`, uploaded-file metadata,
|
||||
and image attachment directories. A Claude adapter may know about Claude's
|
||||
export shape and project boundaries. A generic adapter may only know about
|
||||
memory JSON, conversation JSON, `SKILL.md`, and Markdown folders.
|
||||
|
||||
Nonstandard folders should be adapter details, not required Odysseus concepts.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Attachment References and Upload Storage
|
||||
|
||||
Odysseus stores uploaded bytes once under the configured upload directory and
|
||||
passes stable references through chat history, tools, and future artifact work.
|
||||
The goal is to avoid duplicating large inline media payloads in
|
||||
`chat_messages.content` or the SQLite FTS index.
|
||||
|
||||
## Reference Shape
|
||||
|
||||
Attachment references use this minimum shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "attachment_ref",
|
||||
"attachment_id": "32hex-or-32hex.ext",
|
||||
"name": "original-filename.png",
|
||||
"mime": "image/png",
|
||||
"size": 12345,
|
||||
"checksum_sha256": "hex-digest",
|
||||
"created_at": "2026-07-09T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
Optional fields such as `width`, `height`, `vision`, `vision_model`, and
|
||||
`gallery_id` may be present when the uploader or preprocessing path knows them.
|
||||
|
||||
## Persistence
|
||||
|
||||
The live model call may still receive provider-specific multimodal blocks for
|
||||
the current turn. Persistence is different:
|
||||
|
||||
- `chat_messages.content` stores readable text plus compact attachment reference
|
||||
lines, never raw `data:*;base64,...` upload bytes.
|
||||
- `chat_messages.metadata.attachments` stores structured attachment reference
|
||||
metadata for UI reloads and future processing.
|
||||
- The SQLite FTS migration recreates chat-message FTS triggers so new rows do
|
||||
not index inline media payloads, and it scrubs legacy rows that were already
|
||||
indexed with data URLs.
|
||||
|
||||
## Tool Access
|
||||
|
||||
Agent/tool context receives upload entries as `attachment_ref` manifests with an
|
||||
`odysseus://attachment/<id>` URI and `read_policy: "owner_checked_upload"`.
|
||||
|
||||
For compatibility with existing built-in tools, a local `path` may be included
|
||||
only after all of these checks pass:
|
||||
|
||||
- the upload ID resolves through `UploadHandler.resolve_upload`;
|
||||
- the requested owner is allowed to read the upload;
|
||||
- the file remains inside the configured upload directory;
|
||||
- the file path is inside the tool-readable roots.
|
||||
|
||||
External MCP/custom tools should treat the URI and attachment ID as the stable
|
||||
contract and request bytes through an owner-checked server path, not by assuming
|
||||
host filesystem layout.
|
||||
|
||||
## Retention and Deletion
|
||||
|
||||
Current retention behavior is conservative:
|
||||
|
||||
- uploads are indexed in `uploads.json` with owner, checksum, MIME type, size,
|
||||
and creation time;
|
||||
- admin cleanup first scans persisted chat metadata/content, document versions,
|
||||
PDF source markers, gallery hashes, notes, and calendar records for live
|
||||
references;
|
||||
- cleanup fails closed if that reference scan cannot complete, and the lower-level
|
||||
cleanup API removes nothing unless it receives a complete reference snapshot;
|
||||
- expired, unreferenced uploads are removed during the completed scan, while
|
||||
attachment-bearing writers must first take an owner-checked reservation that
|
||||
serializes with deletion and refreshes the upload's access timestamp;
|
||||
- deliberate removal atomically drops matching `uploads.json` rows before deleting
|
||||
the bytes and restores those rows if filesystem removal fails;
|
||||
- deleting a chat removes the chat rows but does not immediately delete shared
|
||||
upload bytes, because the same upload may also be referenced by gallery items,
|
||||
documents, duplicate-upload rows, or future artifact records.
|
||||
|
||||
There is no distinct artifact table in the current schema. Artifact-like upload
|
||||
references persisted in chat or document text are covered by the canonical
|
||||
attachment-ID scan; any future artifact store must be added to reference discovery
|
||||
before cleanup is allowed to consider its uploads unreferenced.
|
||||
|
||||
Cleanup and write reservations share the upload-index lock. This closes the
|
||||
scan/write/delete race in the documented single-worker deployment; a future
|
||||
multi-process deployment must add an inter-process lock or move lifecycle state
|
||||
into the database before enabling destructive cleanup in more than one worker.
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Backup & Restore
|
||||
|
||||
Odysseus keeps all of your state in the `data/` directory — the SQLite database
|
||||
(`app.db`), the Fernet encryption key (`data/.app_key`), the vault, memory, RAG
|
||||
indexes, personal documents, and uploads. The `scripts/odysseus-backup` tool
|
||||
snapshots that directory into a single gzip tarball and restores it later.
|
||||
|
||||
Snapshots are safe to take while the app is running: SQLite databases are copied
|
||||
through SQLite's own `.backup` API rather than a raw file copy, so an in-flight
|
||||
write can't corrupt the snapshot.
|
||||
|
||||
> **A snapshot contains your secrets.** The tarball includes the Fernet
|
||||
> encryption key (`data/.app_key`), the vault, sessions, and any stored
|
||||
> provider/API tokens — so treat it like a password. Store backups somewhere
|
||||
> private, never commit them to Git, and prefer an encrypted destination when
|
||||
> copying them offsite.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run the tool from the repository root:
|
||||
|
||||
```bash
|
||||
# Create a snapshot → backups/odysseus-backup-<YYYYMMDD-HHMMSS>.tar.gz
|
||||
./scripts/odysseus-backup snapshot
|
||||
|
||||
# List existing snapshots (most recent first)
|
||||
./scripts/odysseus-backup list
|
||||
|
||||
# Check a tarball's integrity without extracting it
|
||||
./scripts/odysseus-backup verify backups/odysseus-backup-20260101-120000.tar.gz
|
||||
|
||||
# Restore (destructive — see the warning below)
|
||||
./scripts/odysseus-backup restore backups/odysseus-backup-20260101-120000.tar.gz --yes
|
||||
```
|
||||
|
||||
The script depends only on the Python standard library, so any `python3` on your
|
||||
`PATH` will run it — you don't need the app's virtualenv active.
|
||||
|
||||
Every command prints a JSON result. Add `--pretty` for indented output.
|
||||
|
||||
## Commands
|
||||
|
||||
### `snapshot`
|
||||
|
||||
Writes a `tar.gz` of `data/` to `backups/<timestamp>.tar.gz`.
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `--out PATH` | Write to a specific path instead of the default `backups/` location. Must be **outside** `data/`. |
|
||||
| `--include-research` | Include `data/deep_research/` (skipped by default — research runs are large). |
|
||||
| `--include-attachments` | Include `data/mail-attachments/` (skipped by default — cached IMAP extractions, re-derivable). |
|
||||
|
||||
By default the snapshot includes everything under `data/` **except**
|
||||
`deep_research/` and `mail-attachments/`. Personal uploads and documents are
|
||||
included.
|
||||
|
||||
```bash
|
||||
# Snapshot straight to a mounted NAS path
|
||||
./scripts/odysseus-backup snapshot --out /mnt/nas/odysseus-$(date +%F).tar.gz
|
||||
|
||||
# Full snapshot including research runs and mail attachments
|
||||
./scripts/odysseus-backup snapshot --include-research --include-attachments
|
||||
```
|
||||
|
||||
### `list`
|
||||
|
||||
Lists the tarballs in `backups/`, most recent first, with size and modification
|
||||
time.
|
||||
|
||||
### `verify PATH`
|
||||
|
||||
Opens the tarball read-only and walks every member to confirm it is intact and
|
||||
safe to restore. Nothing is extracted. Use this before relying on an old backup
|
||||
or after copying one across machines.
|
||||
|
||||
### `restore PATH --yes`
|
||||
|
||||
Overwrites `data/` from a tarball.
|
||||
|
||||
> **Restore is destructive.** It replaces the current `data/` directory. `--yes`
|
||||
> is required so a mistyped command can't wipe your live state.
|
||||
|
||||
Restore is not a blind delete: before extracting, the tool **renames your current
|
||||
`data/` to `data.before-restore-<timestamp>`** in the repository root. If a
|
||||
restore turns out to be wrong, your previous state is still there — delete the
|
||||
restored `data/` and rename the stashed directory back. The restore path is also
|
||||
validated entry-by-entry: archives containing absolute paths, `..` segments,
|
||||
symlinks, or anything outside `data/` are rejected.
|
||||
|
||||
## Scheduling offsite backups
|
||||
|
||||
The tarball output composes cleanly with cron and any copy tool. For example, a
|
||||
nightly snapshot copied offsite:
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /path/to/odysseus && ./scripts/odysseus-backup snapshot --out "/mnt/nas/odysseus-$(date +\%F).tar.gz"
|
||||
```
|
||||
|
||||
Swap the `--out` target for `scp`, `rclone`, `s3cmd`, or similar to push the
|
||||
snapshot to remote storage.
|
||||
|
||||
## Docker vs native installs
|
||||
|
||||
The tool reads `data/` and writes `backups/` relative to the repository root, so
|
||||
where you run it matters:
|
||||
|
||||
- **Native installs** — run it from the repo root as shown above. `data/` and
|
||||
`backups/` are both in the repo directory.
|
||||
- **Docker** — `docker-compose.yml` bind-mounts the host's `./data` to
|
||||
`/app/data`, so the live data is also present on the host. **Run the tool on
|
||||
the host** from the repo root; the snapshot reads the bind-mounted `./data` and
|
||||
writes to `./backups` on the host. Running it *inside* the container is not
|
||||
recommended, because `backups/` is not a mounted volume and the tarball would
|
||||
be lost when the container is recreated.
|
||||
|
||||
> **ChromaDB caveat (Docker only).** In the Docker setup, ChromaDB stores its
|
||||
> vectors in a separate Compose-managed volume (declared as `chromadb-data`),
|
||||
> **not** under `./data`. `odysseus-backup` therefore does not capture the Docker
|
||||
> ChromaDB store. Back it up separately if you need it. Compose prefixes the
|
||||
> volume with the project name, so find the real name first
|
||||
> (`docker volume ls | grep chromadb`), then archive it — for example:
|
||||
>
|
||||
> ```bash
|
||||
> docker run --rm -v <project>_chromadb-data:/data -v "$PWD":/backup \
|
||||
> alpine tar czf /backup/chromadb.tar.gz -C /data .
|
||||
> ```
|
||||
>
|
||||
> On native installs ChromaDB lives at `data/chroma/` and is included in the
|
||||
> snapshot normally.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Outlook / Office 365 email accounts
|
||||
|
||||
Odysseus email accounts currently use IMAP and SMTP with username/password
|
||||
authentication. That works for providers that still allow app passwords or
|
||||
mailbox passwords for IMAP/SMTP.
|
||||
|
||||
Microsoft disables basic authentication for Outlook and Microsoft 365 in most
|
||||
modern accounts and tenants. If you try to add an Outlook account with a normal
|
||||
password, Microsoft may return errors such as:
|
||||
|
||||
- `IMAP: AUTHENTICATE failed`
|
||||
- `SMTP: 535 5.7.139 Authentication unsuccessful, basic authentication is disabled`
|
||||
|
||||
This is expected. Odysseus does not support Microsoft OAuth or Graph Mail yet,
|
||||
so Outlook / Office 365 accounts cannot currently be added through the password
|
||||
form. Use another email provider with app-password support, or track the future
|
||||
Microsoft Graph OAuth integration.
|
||||
Binary file not shown.
@@ -0,0 +1,961 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Odysseus — a self-hosted AI workspace: chat, agents, tools, model serving, email, research, and more. Your models, your hardware, your data.">
|
||||
<title>Odysseus — A Self-Hosted AI Workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
/* Odysseus default theme — exact app tokens */
|
||||
--bg: #282c34;
|
||||
--bg2: #1e2228; /* app code/hl background */
|
||||
--panel: #111; /* app panel surface */
|
||||
--panel2: #1e2228;
|
||||
--fg: #9cdef2; /* signature cyan text */
|
||||
--heading: #9cdef2;
|
||||
--muted: #6b8a94; /* app subheader */
|
||||
--border: #355a66; /* teal border */
|
||||
--accent: #e06c75; /* app accent (the send-button coral) */
|
||||
--accent2: #f0989e; /* lighter coral for gradients */
|
||||
--green: #50fa7b;
|
||||
--gold: #f0ad4e; /* app --warn */
|
||||
--red: #e06c75;
|
||||
--radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 60px; }
|
||||
/* REMOVED: "scroll-snap-type: y proximity"
|
||||
The idea was: >>Each section is a full-viewport "page" with its content centered,
|
||||
so only one shows at a time and the snap is obvious.<<
|
||||
|
||||
PROBLEM: sections easily grow taller than 100vh IRL
|
||||
This cause forced jumps mid-read. It's intrusive UX.
|
||||
The landing-page is not a PowerPoint presentation!
|
||||
|
||||
Preserved: CSS snap-points to avoid destroying code meta-data*/
|
||||
.hero, section {
|
||||
scroll-snap-align: start; min-height: 100vh;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
/* Alternate the page backgrounds: slate (the body) ↔ black, to make each
|
||||
page boundary obvious. */
|
||||
/* Subtle dot-grid texture across the whole page. */
|
||||
section:nth-of-type(odd) {
|
||||
background-color: #111111;
|
||||
background-image: radial-gradient(circle, rgba(156,222,242,0.075) 1px, transparent 1.4px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
section:nth-of-type(even) {
|
||||
background-color: var(--bg);
|
||||
background-image: radial-gradient(circle, rgba(156,222,242,0.06) 1px, transparent 1.4px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
/* Customers section gets a brand-colored gradient glow over the dots. */
|
||||
#testimonials {
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(900px 520px at 80% 8%, rgba(224,108,117,0.14), transparent 60%),
|
||||
radial-gradient(760px 520px at 8% 96%, rgba(53,90,102,0.32), transparent 58%),
|
||||
radial-gradient(circle, rgba(156,222,242,0.06) 1px, transparent 1.4px);
|
||||
background-size: cover, cover, 24px 24px;
|
||||
}
|
||||
/* Domino reveal — each section fades/slides up as it scrolls into view. */
|
||||
.hero, section { opacity: 0; transform: translateY(24px); transition: opacity .6s cubic-bezier(.2,.7,.2,1), transform .6s cubic-bezier(.2,.7,.2,1); }
|
||||
.hero.in, section.in { opacity: 1; transform: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-snap-type: none; }
|
||||
.hero, section { opacity: 1 !important; transform: none !important; transition: none; }
|
||||
}
|
||||
/* Capabilities cards cascade in like the app's domino expand. */
|
||||
#features .feature { opacity: 0; transform: translateY(16px); }
|
||||
#features.in .feature { animation: domino-in .5s cubic-bezier(.2,.7,.2,1) forwards; }
|
||||
#features.in .feature:nth-child(1) { animation-delay: .04s; }
|
||||
#features.in .feature:nth-child(2) { animation-delay: .09s; }
|
||||
#features.in .feature:nth-child(3) { animation-delay: .14s; }
|
||||
#features.in .feature:nth-child(4) { animation-delay: .19s; }
|
||||
#features.in .feature:nth-child(5) { animation-delay: .24s; }
|
||||
#features.in .feature:nth-child(6) { animation-delay: .29s; }
|
||||
#features.in .feature:nth-child(7) { animation-delay: .34s; }
|
||||
#features.in .feature:nth-child(8) { animation-delay: .39s; }
|
||||
#features.in .feature:nth-child(9) { animation-delay: .44s; }
|
||||
@keyframes domino-in { to { opacity: 1; transform: none; } }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1100px 520px at 82% -10%, rgba(224,108,117,0.12), transparent 60%),
|
||||
radial-gradient(900px 520px at 0% 0%, rgba(53,90,102,0.30), transparent 55%),
|
||||
var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 22px; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(17,17,17,0.88);
|
||||
border-bottom: 1px solid #9cdef2;
|
||||
}
|
||||
nav .wrap { display: flex; align-items: center; justify-content: space-between; height: 60px; }
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 17px; letter-spacing: 0.2px; color: var(--heading); }
|
||||
.brand .boat { color: var(--accent); flex-shrink: 0; }
|
||||
.nav-links { display: flex; align-items: center; gap: 22px; }
|
||||
.nav-links a { color: var(--muted); font-size: 14px; font-weight: 500; }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 16px; border-radius: 10px; font-weight: 600; font-size: 14px;
|
||||
border: 1px solid var(--border); color: var(--fg); background: var(--panel);
|
||||
transition: transform .12s ease, border-color .12s ease, background .12s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); border-color: var(--accent); }
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #fff; border: none;
|
||||
}
|
||||
.btn.primary:hover { filter: brightness(1.07); }
|
||||
|
||||
/* Hero */
|
||||
.hero { padding: 86px 0 40px; text-align: center; position: relative; overflow: hidden; }
|
||||
#hero-flow { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; opacity: 0.9; }
|
||||
.hero .wrap { position: relative; z-index: 2; }
|
||||
.hero h1, .hero .lede, .hero .wordmark { text-shadow: 0 2px 20px rgba(0,0,0,0.45); }
|
||||
@media (prefers-reduced-motion: reduce) { #hero-flow { display: none; } }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 12.5px; color: var(--muted); border: 1px solid var(--border);
|
||||
background: var(--panel); padding: 5px 12px; border-radius: 999px; margin-bottom: 22px;
|
||||
}
|
||||
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.hero-logo { display: flex; align-items: center; justify-content: center; gap: 14px; color: var(--accent); margin-bottom: 4px; }
|
||||
.hero-logo svg { filter: drop-shadow(0 4px 18px rgba(224,108,117,0.35)); }
|
||||
.hero-logo .wordmark { font-size: clamp(30px, 6vw, 44px); font-weight: 700; color: var(--heading); letter-spacing: -0.01em; line-height: 1; }
|
||||
.hero h1 {
|
||||
font-size: clamp(32px, 5.4vw, 52px); line-height: 1.12; margin: 0 0 18px;
|
||||
letter-spacing: -0.01em; font-weight: 700; color: var(--heading);
|
||||
}
|
||||
.hero h1 .grad {
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.hero .slogan { font-style: italic; color: var(--accent); font-size: 12px; margin: 0 0 24px; letter-spacing: 0.3px; opacity: 0.9; }
|
||||
.hero p.lede { font-size: clamp(16px, 2.4vw, 20px); color: var(--muted); max-width: 680px; margin: 0 auto 30px; }
|
||||
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* terminal origin card */
|
||||
.term-intro { color: var(--fg); font-size: clamp(13px, 1.8vw, 15px); margin: 34px auto 0; max-width: 560px; }
|
||||
.term {
|
||||
max-width: 620px; margin: 12px auto 0; text-align: left;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.4);
|
||||
}
|
||||
.term-bar { display: flex; align-items: center; justify-content: space-between; padding: 5px 6px 5px 12px; border-bottom: 1px solid var(--border); background: #20242c; }
|
||||
.term-bar .ttl { color: var(--muted); font-size: 12px; font-family: 'Fira Code', ui-monospace, monospace; }
|
||||
.term-bar .winbtns { display: flex; gap: 1px; }
|
||||
.term-bar .winbtns span { cursor: pointer; }
|
||||
.term { transition: opacity .18s ease, transform .18s ease; }
|
||||
/* Minimized = a rounded "pill", like the app's tab-down dock chip. */
|
||||
.term.term-min { max-width: max-content; border-radius: 999px; box-shadow: 0 6px 22px rgba(0,0,0,0.4); }
|
||||
.term.term-min .term-bar { border-bottom: none; border-radius: 999px; padding: 7px 10px 7px 16px; gap: 12px; background: var(--panel); }
|
||||
.term.term-min pre { display: none; }
|
||||
.term.term-closed { opacity: 0; transform: scale(0.96); pointer-events: none; height: 0; margin: 0 auto; border: 0; overflow: hidden; }
|
||||
.term-reopen {
|
||||
display: none; margin: 14px auto 0; font-family: 'Fira Code', monospace; font-size: 12px;
|
||||
color: var(--muted); background: none; border: 1px dashed var(--border); border-radius: 6px;
|
||||
padding: 5px 12px; cursor: pointer;
|
||||
}
|
||||
.term-reopen:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.term-reopen.show { display: inline-block; }
|
||||
.term-bar .winbtns span {
|
||||
width: 28px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px; color: var(--muted); font-size: 12px; line-height: 1;
|
||||
}
|
||||
.term-bar .winbtns span:hover { background: rgba(156,222,242,0.12); color: var(--fg); }
|
||||
.term-bar .winbtns span.x:hover { background: #c0392b; color: #fff; }
|
||||
.term pre {
|
||||
margin: 0; padding: 18px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px; color: var(--fg); line-height: 1.7; white-space: pre-wrap;
|
||||
}
|
||||
.term .cs { color: var(--green); } .term .cm { color: #828997; }
|
||||
.term-cursor { display: inline-block; color: var(--fg); font-weight: 400; animation: term-blink 1.05s steps(1) infinite; }
|
||||
@keyframes term-blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Sections */
|
||||
section { padding: 60px 0; }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.eyebrow svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
h2.h { font-size: clamp(19px, 2.7vw, 26px); margin: 8px 0 12px; letter-spacing: -0.01em; color: var(--heading); font-weight: 700; }
|
||||
.sub { color: var(--muted); max-width: 620px; }
|
||||
.center { text-align: center; }
|
||||
.center .sub { margin: 0 auto; }
|
||||
|
||||
/* Testimonial gag — single featured testimonial, click/swipe to cycle (all sizes) */
|
||||
.tcarousel-wrap { position: relative; max-width: 820px; margin: 36px auto 0; }
|
||||
.tarrow {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 4;
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: rgba(17,17,17,0.85); border: 1px solid var(--border); color: var(--fg);
|
||||
font-size: 20px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color .12s ease, color .12s ease;
|
||||
}
|
||||
.tarrow:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.tarrow.prev { left: 0; }
|
||||
.tarrow.next { right: 0; }
|
||||
.tgrid {
|
||||
display: block; position: relative; overflow: hidden; cursor: pointer;
|
||||
margin: 0 auto; max-width: 740px;
|
||||
}
|
||||
.tgrid .tcard {
|
||||
display: none;
|
||||
flex-direction: row-reverse; align-items: center; gap: 24px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
}
|
||||
.tgrid .tcard.active { display: flex; animation: tslide .25s ease both; }
|
||||
.tgrid .tcard.active.shake { animation: tshake .5s ease-in-out 2 both; }
|
||||
.tcard .av {
|
||||
width: 84px; height: 84px; border-radius: 50%; overflow: hidden;
|
||||
border: 1px solid var(--border); background: var(--panel2); flex: 0 0 auto;
|
||||
}
|
||||
.tcard .av img, .tcard .av svg { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.tcard .tmeta { flex: 1 1 auto; }
|
||||
.tcard .q { font-size: 18px; color: var(--fg); margin: 0 0 12px; }
|
||||
.tcard .stars { font-size: 15px; letter-spacing: 3px; margin: 0 0 8px; color: var(--gold); }
|
||||
.tcard .stars.zero { color: var(--muted); opacity: 0.5; }
|
||||
.tcard .nm { font-weight: 700; font-size: 14.5px; }
|
||||
.tcard .rl { color: var(--muted); font-size: 12.5px; }
|
||||
.tcard.cyclops { border-color: rgba(255,90,90,0.45); background: linear-gradient(180deg, rgba(255,80,80,0.06), var(--panel)); }
|
||||
.tcard.cyclops .q { color: #ff8a8a; font-weight: 700; letter-spacing: 0.4px; word-break: break-word; }
|
||||
.tnav { display: block; text-align: center; margin-top: 18px; }
|
||||
.tdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #39414d; margin: 0 4px; cursor: pointer; }
|
||||
.tdot.on { background: var(--accent); }
|
||||
.thint { font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||
@keyframes tshake {
|
||||
0%,100% { transform: translateX(0) rotate(0); }
|
||||
10% { transform: translateX(-9px) rotate(-1.5deg); }
|
||||
20% { transform: translateX(9px) rotate(1.5deg); }
|
||||
35% { transform: translateX(-7px) rotate(-1deg); }
|
||||
50% { transform: translateX(7px) rotate(1deg); }
|
||||
65% { transform: translateX(-5px); } 80% { transform: translateX(4px); } 92% { transform: translateX(-2px); }
|
||||
}
|
||||
@keyframes tslide { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 36px; }
|
||||
.feature {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px; transition: transform .14s ease, border-color .14s ease;
|
||||
}
|
||||
.feature:hover { transform: translateY(-3px); border-color: var(--accent); }
|
||||
.feature .ico {
|
||||
width: 40px; height: 40px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(224,108,117,0.18), rgba(53,90,102,0.28));
|
||||
border: 1px solid var(--border); color: var(--accent); margin-bottom: 14px;
|
||||
}
|
||||
.feature h3 { margin: 0 0 6px; font-size: 16.5px; }
|
||||
.feature p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* Screenshot strip */
|
||||
.shotrow { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||
.shot {
|
||||
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); font-size: 13px; position: relative;
|
||||
}
|
||||
.shot .ph { display: flex; flex-direction: column; align-items: center; gap: 8px; opacity: 0.7; }
|
||||
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
|
||||
.shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; }
|
||||
|
||||
/* Previews — expanding hover carousel that plays a video on hover/tap */
|
||||
.previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
|
||||
.preview-panel {
|
||||
position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||
transition: flex-grow .5s cubic-bezier(.2,.7,.2,1), height .5s cubic-bezier(.2,.7,.2,1), border-color .25s ease;
|
||||
}
|
||||
.previews:hover .preview-panel { flex-grow: 0.55; height: 300px; }
|
||||
.preview-panel:hover, .preview-panel:focus-visible, .preview-panel.is-active { flex-grow: 3.4 !important; height: 480px !important; border-color: var(--accent); }
|
||||
.preview-panel .ph {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px;
|
||||
color: var(--muted); font-size: 12.5px; opacity: 0.7; text-align: center; padding: 8px;
|
||||
}
|
||||
.preview-panel video {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
|
||||
z-index: 1; opacity: 0; transition: opacity .3s ease; background: transparent;
|
||||
}
|
||||
.preview-panel.has-video video { opacity: 1; }
|
||||
/* These clips have their action on the left, so show the left edge instead of
|
||||
the centered crop. */
|
||||
.preview-panel:has(source[src="document.webm"]) video,
|
||||
.preview-panel:has(source[src="notes.webm"]) video { object-position: right center; }
|
||||
.preview-panel .label {
|
||||
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; padding: 14px 16px;
|
||||
background: linear-gradient(0deg, rgba(0,0,0,0.82), transparent);
|
||||
color: var(--heading);
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
|
||||
}
|
||||
.preview-panel .label .t { display: flex; align-items: center; gap: 8px; white-space: nowrap; font-weight: 700; font-size: 14px; }
|
||||
.preview-panel .label .ico { color: var(--accent); flex-shrink: 0; }
|
||||
.preview-panel .label .desc {
|
||||
font-weight: 400; font-size: 12.5px; line-height: 1.35; color: rgba(255,255,255,0.82);
|
||||
white-space: normal; max-height: 0; opacity: 0; overflow: hidden;
|
||||
transition: max-height .4s ease, opacity .4s ease;
|
||||
}
|
||||
.preview-panel:hover .label .desc, .preview-panel:focus-visible .label .desc, .preview-panel.is-active .label .desc { max-height: 64px; opacity: 1; }
|
||||
@media (max-width: 760px) {
|
||||
.previews { flex-direction: column; height: auto; touch-action: pan-y; }
|
||||
.preview-panel { height: 190px; flex: none; width: 100%; }
|
||||
.preview-panel.is-active { height: 280px !important; }
|
||||
.previews:hover .preview-panel, .preview-panel:hover { flex: none !important; }
|
||||
.preview-panel .label .desc { max-height: 64px; opacity: 1; }
|
||||
}
|
||||
|
||||
/* Fullscreen video background for a section — treated as an ambient, cinematic
|
||||
backdrop (soft blur + slow drift) so it sets a mood without fighting the copy. */
|
||||
.has-bg-video { position: relative; overflow: hidden; }
|
||||
.has-bg-video .sec-bg {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: cover; z-index: 0; pointer-events: none;
|
||||
/* blur softens the busy frame; the extra scale hides the blurred edges */
|
||||
filter: blur(4px) saturate(1.08) brightness(0.92);
|
||||
transform: scale(1.12);
|
||||
transform-origin: 55% 45%;
|
||||
animation: bg-drift 36s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
@keyframes bg-drift {
|
||||
from { transform: scale(1.12) translate(0, 0); }
|
||||
to { transform: scale(1.2) translate(-2.5%, -1.5%); }
|
||||
}
|
||||
.has-bg-video .sec-bg-tint {
|
||||
position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(900px 520px at 78% 18%, rgba(224,108,117,0.16), transparent 60%),
|
||||
radial-gradient(760px 520px at 8% 88%, rgba(53,90,102,0.30), transparent 58%),
|
||||
linear-gradient(180deg, rgba(17,17,17,0.86), rgba(17,17,17,0.62) 42%, rgba(17,17,17,0.92)),
|
||||
radial-gradient(1200px 680px at 50% 46%, rgba(17,17,17,0.18), rgba(17,17,17,0.74));
|
||||
}
|
||||
.has-bg-video .wrap { position: relative; z-index: 2; }
|
||||
/* Lift the copy off the moving backdrop. */
|
||||
.has-bg-video .eyebrow,
|
||||
.has-bg-video .h { text-shadow: 0 2px 22px rgba(0,0,0,0.7); }
|
||||
.has-bg-video .sub { color: #b9e6f4; text-shadow: 0 1px 14px rgba(0,0,0,0.75); }
|
||||
.hero.has-bg-video h1, .hero.has-bg-video .wordmark,
|
||||
.hero.has-bg-video .lede, .hero.has-bg-video .slogan { text-shadow: 0 2px 22px rgba(0,0,0,0.72); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.has-bg-video .sec-bg { animation: none; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
/* Get started */
|
||||
.start {
|
||||
background: linear-gradient(180deg, var(--panel), var(--bg2));
|
||||
border: 1px solid var(--border); border-radius: 18px; padding: 40px; text-align: center;
|
||||
}
|
||||
.codeblock {
|
||||
display: inline-flex; align-items: center; gap: 14px; margin: 18px auto 8px;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 16px; font-family: ui-monospace, monospace; font-size: 14px; color: var(--fg);
|
||||
text-align: left;
|
||||
}
|
||||
.codeblock .prompt { color: var(--accent); }
|
||||
.codeblock .copy-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--muted); cursor: pointer; font-size: 12px; padding: 4px 10px;
|
||||
font-family: inherit; transition: border-color .12s ease, color .12s ease;
|
||||
}
|
||||
.codeblock .copy-btn:hover { border-color: var(--accent); color: var(--fg); }
|
||||
.codeblock .copy-btn.copied { border-color: var(--green); color: var(--green); }
|
||||
.pill-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 44px; }
|
||||
.pill { font-size: 12.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 5px 12px; background: var(--panel); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 30px 0; color: var(--muted); font-size: 13px; scroll-snap-align: end; }
|
||||
footer .wrap { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.shotrow { grid-template-columns: 1fr; }
|
||||
.nav-links a:not(.btn) { display: none; }
|
||||
.codeblock { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; overflow-wrap: anywhere; }
|
||||
.codeblock > span { flex: 1 1 auto; min-width: 0; }
|
||||
.codeblock .copy-btn { margin-left: auto; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.tgrid .tcard { padding: 20px; gap: 16px; }
|
||||
.tcard .av { width: 64px; height: 64px; }
|
||||
.tcard .q { font-size: 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<svg class="boat" viewBox="0 0 32 32" width="24" height="24" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
|
||||
Odysseus
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#testimonials">Testimonials</a>
|
||||
<a href="#how">How it started</a>
|
||||
<a href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<header class="hero">
|
||||
<canvas id="hero-flow" aria-hidden="true"></canvas>
|
||||
<div class="wrap">
|
||||
<div class="hero-logo">
|
||||
<svg viewBox="0 0 32 32" width="48" height="48" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
|
||||
<span class="wordmark">Odysseus</span>
|
||||
</div>
|
||||
<p class="slogan">Yours for the voyage.</p>
|
||||
<h1>Your own <span class="grad">AI workspace</span>,<br>running on your hardware.</h1>
|
||||
<p class="lede">
|
||||
Odysseus is a self-hosted interface for talking to language models — chat,
|
||||
autonomous agents, tools, model serving, email, research, and more. Local-first,
|
||||
privacy-first, and no telemetry. Just you and your models.
|
||||
</p>
|
||||
<p style="font-size:11.5px; color:var(--muted); opacity:0.7; max-width:560px; margin:-18px auto 30px;">
|
||||
(if you want to add an API that's cool too — I'm not here to tell you how to live your life…)
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn primary" href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">View on GitHub</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>Everything, self-hosted</div>
|
||||
<h2 class="h">One app, a lot of capabilities</h2>
|
||||
<p class="sub">Started as an AI chat. Became a workspace. Each piece runs locally against
|
||||
whatever endpoints you point it at.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<h3>Chat & Agents</h3>
|
||||
<p>Multi-turn chat plus autonomous agents that plan, call tools, and work through tasks.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 7.9l-6.9 6.9a2.1 2.1 0 0 1-3-3l6.9-6.9a6 6 0 0 1 7.9-7.9z"/></svg></span>
|
||||
<h3>Tools & MCP</h3>
|
||||
<p>Built-in tools (bash, files, web, memory) plus any MCP server you connect. Toggle per tool.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></span>
|
||||
<h3>Cookbook</h3>
|
||||
<p>Hardware-aware model recommendations and one-click serving across 270+ catalogued models.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg></span>
|
||||
<h3>Email Assistant</h3>
|
||||
<p>AI summaries, style-matched draft replies, auto-tagging and spam triage over IMAP/SMTP.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||||
<h3>Deep Research</h3>
|
||||
<p>Multi-step research runs that gather, read, and synthesize sources into a written report.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg></span>
|
||||
<h3>Compare</h3>
|
||||
<p>Send one prompt to several models at once and compare their answers side-by-side.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/></svg></span>
|
||||
<h3>Memory</h3>
|
||||
<p>Persistent memory the assistant builds up and recalls across all your conversations.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg></span>
|
||||
<h3>Skills <span style="font-size:10.5px;font-weight:700;color:var(--accent);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:4px;vertical-align:middle;">self-evolving</span></h3>
|
||||
<p>The assistant writes, refines, and reuses its own skills — getting more capable over time.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<h3>Private by default</h3>
|
||||
<p>Runs on your machine against your own endpoints. No telemetry, with optional external integrations when you choose them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TESTIMONIALS (gag) -->
|
||||
<section id="testimonials" style="padding-top:30px;">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 5.6a5.2 5.2 0 0 0-7.4 0L12 7l-1.4-1.4a5.2 5.2 0 1 0-7.4 7.4L12 21.4l8.8-8.4a5.2 5.2 0 0 0 0-7.4z"/></svg>Loved by enterprises</div>
|
||||
<h2 class="h">What our customers are saying</h2>
|
||||
</div>
|
||||
|
||||
<div class="tcarousel-wrap">
|
||||
<button class="tarrow prev" type="button" aria-label="Previous testimonial">‹</button>
|
||||
<div class="tgrid" id="tcarousel">
|
||||
|
||||
<!-- Coder guy -->
|
||||
<figure class="tcard">
|
||||
<span class="av"><img src="https://cdn.prod.website-files.com/66708f90d7e407423093fa76/66708f91d7e407423093fd21_john-carter-testimonial-image-dentistry-x-webflow-template.png" alt="Generic Coder Guy" loading="lazy"></span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"Odysseus helped us ship more ships while shipping ships. Truly best-in-class shipping."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Generic Coder Guy</div>
|
||||
<div class="rl">Sr. Engineer, ShipShip Inc.</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Woman -->
|
||||
<figure class="tcard">
|
||||
<span class="av"><img src="https://images.pexels.com/photos/5876695/pexels-photo-5876695.jpeg?auto=compress&cs=tinysrgb&w=160&h=160&fit=crop" alt="A real woman" loading="lazy"></span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"I'm a real person. This is a real testimonial. By a real woman."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Generic Corporate Woman</div>
|
||||
<div class="rl">VP of Verticals, Things LLC</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Cyclops -->
|
||||
<figure class="tcard cyclops" data-shake="1">
|
||||
<span class="av" style="border-color:rgba(255,90,90,0.6);">
|
||||
<svg viewBox="0 0 72 72" width="54" height="54" fill="none" stroke="#cbd5e1" stroke-width="2">
|
||||
<rect x="0" y="0" width="72" height="72" fill="#16241a"/>
|
||||
<circle cx="36" cy="32" r="18" fill="#7fae7f" stroke="#5a7a5a"/>
|
||||
<line x1="29" y1="22" x2="43" y2="34" stroke="#ff5a5a" stroke-width="3"/>
|
||||
<line x1="43" y1="22" x2="29" y2="34" stroke="#ff5a5a" stroke-width="3"/>
|
||||
<ellipse cx="36" cy="45" rx="7" ry="9" fill="#3a0a0a" stroke="#200"/>
|
||||
<path d="M31 51 l-1 4" stroke="#fff" stroke-width="2"/><path d="M41 51 l1 4" stroke="#fff" stroke-width="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"AHHHHHHHHHHHHHHHHHHHHHHHHHHHHH"</p>
|
||||
<div class="stars zero">☆☆☆☆☆</div>
|
||||
<div class="nm">Polyphemus</div>
|
||||
<div class="rl">Cyclops, Cave Solutions (on leave)</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Corporate -->
|
||||
<figure class="tcard">
|
||||
<span class="av">
|
||||
<svg viewBox="0 0 80 80" aria-hidden="true">
|
||||
<rect width="80" height="80" rx="18" fill="#111827"/>
|
||||
<circle cx="40" cy="29" r="14" fill="#d1d5db"/>
|
||||
<path d="M18 70c4-18 15-27 22-27s18 9 22 27" fill="#374151"/>
|
||||
<path d="M28 58h24l-5 12H33z" fill="#e06c75"/>
|
||||
<path d="M32 14h16l6 11H26z" fill="#f8fafc"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"Anyway, as I was saying — best-in-class."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Chad Corporate</div>
|
||||
<div class="rl">Chief Executive Officer</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
<button class="tarrow next" type="button" aria-label="Next testimonial">›</button>
|
||||
</div>
|
||||
<div class="tnav" id="tnav"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- The one-shot prompt it started from (gag) -->
|
||||
<section style="padding-top:0;">
|
||||
<div class="wrap" style="text-align:center;">
|
||||
<p class="term-intro">Odysseus was created by a carefully crafted one-shot AI prompt:</p>
|
||||
<div class="term">
|
||||
<div class="term-bar">
|
||||
<span class="ttl">user@odysseus: ~</span>
|
||||
<span class="winbtns"><span data-term="min" title="Minimize">–</span><span class="x" data-term="close" title="Close">✕</span></span>
|
||||
</div>
|
||||
<pre id="term-pre"><span class="cs">></span> idk what to make come up with something oh make an AI chat but make it good and make it look nice</pre>
|
||||
</div>
|
||||
<button class="term-reopen" type="button">✕ reopen terminal</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PREVIEWS — hover/tap to expand + play -->
|
||||
<section id="previews">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>See it in action</div>
|
||||
<h2 class="h">Hover or tap to take a closer look</h2>
|
||||
<p class="sub center">Each panel expands and plays its preview when you hover or tap it. Swipe on mobile to move through them.</p>
|
||||
</div>
|
||||
<div class="previews">
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>[ Chat & Agents ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="chat.webm" type="video/webm"><source src="chat.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat & Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</span></div>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</span><span class="desc">Download, serve, and manage local models across your machines.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg><span>[ Deep Research ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="research.webm" type="video/webm"><source src="research.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg>Deep Research</span><span class="desc">Ask once: it searches, reads sources, and writes back a cited report.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg><span>[ Compare ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="compare.webm" type="video/webm"><source src="compare.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Compare</span><span class="desc">Send one prompt to many models at once and watch them answer side by side.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg><span>[ Documents ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="document.webm" type="video/webm"><source src="document.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>Documents</span><span class="desc">A document editor that puts you first — work on what you want, with AI help when you want it.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg><span>[ Notes & Tasks ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="notes.webm" type="video/webm"><source src="notes.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg>Notes & Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg><span>[ Image Gallery ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="gallery.webm" type="video/webm"><source src="gallery.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg>Image Gallery</span><span class="desc">Generate, edit, remove backgrounds, and inpaint in your own gallery.</span></div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg><span>[ Themes ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="theme.webm" type="video/webm"><source src="theme.mp4" type="video/mp4"></video>
|
||||
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg>Themes</span><span class="desc">Restyle and make it yours — edit your own, or ask the agent to make one.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT STARTED -->
|
||||
<section id="how" class="has-bg-video">
|
||||
<video class="sec-bg" autoplay muted loop playsinline preload="auto"><source src="bg.webm" type="video/webm"><source src="bg.mp4" type="video/mp4"></video>
|
||||
<div class="sec-bg-tint"></div>
|
||||
<div class="wrap">
|
||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1z"/></svg>How it actually started</div>
|
||||
<h2 class="h">Uncompromised local LLM experience.</h2>
|
||||
<p class="sub" style="max-width:760px;">
|
||||
I started working on the Odysseus project because running local AI felt fun and powerful.
|
||||
But the options at the time to engage with LLMs felt like taking steps back. The idea that you
|
||||
could just self-host AI and not pay for a subscription wasn't there. All the tools and functions
|
||||
that make it all magic were missing.
|
||||
</p>
|
||||
<p class="sub" style="max-width:760px; margin-top:14px;">
|
||||
So I started building Odysseus bit by bit — and the more I gave it to work with, the
|
||||
better it served me. Turns out the more your model knows about you, the more useful it gets.
|
||||
Which is the other reason to self-host: you get all that context without handing your private
|
||||
data to someone else's cloud. </p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GET STARTED -->
|
||||
<section id="start">
|
||||
<div class="wrap">
|
||||
<div class="start">
|
||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
|
||||
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
|
||||
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
|
||||
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus">Copy</button></div>
|
||||
<div>
|
||||
<a class="btn primary" href="https://github.com/odysseus-dev/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||
</div>
|
||||
<div class="pill-row">
|
||||
<span class="pill">Self-hosted</span>
|
||||
<span class="pill">Bring your own models</span>
|
||||
<span class="pill">Local-first</span>
|
||||
<span class="pill">MCP-ready</span>
|
||||
<span class="pill">No telemetry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<div>© 2026 Odysseus · Built from one prompt that refused to stop.</div>
|
||||
<div>No cyclopes were harmed in production.<sup>*</sup></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Hero background: Perlin flow field — colored particle streams (ported from
|
||||
// the app's own background effect, scoped to the hero and brand-colored).
|
||||
(function () {
|
||||
var canvas = document.getElementById('hero-flow');
|
||||
if (!canvas) return;
|
||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
var hero = canvas.parentElement, ctx = canvas.getContext('2d');
|
||||
var dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
var W, H, t = 0, particles = [];
|
||||
var COLORS = ['#9cdef2', '#e06c75', '#5fb6cc']; // cyan, coral, teal
|
||||
var FADE = 'rgba(40,44,52,0.06)'; // trail fade toward --bg
|
||||
function n2(x, y) { var n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; return n - Math.floor(n); }
|
||||
function noise(x, y) {
|
||||
var ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy;
|
||||
var a = n2(ix, iy), b = n2(ix + 1, iy), c = n2(ix, iy + 1), d = n2(ix + 1, iy + 1);
|
||||
var ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy);
|
||||
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
|
||||
}
|
||||
function resize() {
|
||||
W = hero.clientWidth; H = hero.clientHeight;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (!particles.length) {
|
||||
for (var i = 0; i < 260; i++) particles.push({ x: Math.random() * W, y: Math.random() * H, life: Math.random(), c: COLORS[i % COLORS.length] });
|
||||
}
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
function draw() {
|
||||
requestAnimationFrame(draw);
|
||||
ctx.fillStyle = FADE; ctx.fillRect(0, 0, W, H);
|
||||
for (var i = 0; i < particles.length; i++) {
|
||||
var p = particles[i];
|
||||
var ang = noise(p.x * 0.004 + t * 0.0008, p.y * 0.004 + 100) * Math.PI * 6;
|
||||
var sp = 1 + noise(p.x * 0.003, p.y * 0.003 + 50) * 1.5;
|
||||
p.x += Math.cos(ang) * sp; p.y += Math.sin(ang) * sp; p.life -= 0.001;
|
||||
if (p.life <= 0 || p.x < 0 || p.x > W || p.y < 0 || p.y > H) { p.x = Math.random() * W; p.y = Math.random() * H; p.life = 1; }
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, 1.1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.c; ctx.globalAlpha = p.life * 0.18; ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1; t++;
|
||||
}
|
||||
draw();
|
||||
})();
|
||||
|
||||
// Typewriter for the origin terminal: type line 1, pause 2s, line 2, pause
|
||||
// 2s, line 3, hold 4s, then reset and loop. Blinking "|" cursor throughout.
|
||||
(function () {
|
||||
var pre = document.getElementById('term-pre');
|
||||
if (!pre) return;
|
||||
var lines = [
|
||||
{ p: '<span class="cs">></span> ', t: 'idk what to make come up with something oh make an AI chat but make it good and make it look nice' }
|
||||
];
|
||||
var CURSOR = '<span class="term-cursor">|</span>';
|
||||
var TYPE_MS = 40;
|
||||
var done = [], li = 0, timer = null;
|
||||
|
||||
function render(partial) {
|
||||
pre.innerHTML = done.join('\n') + (done.length ? '\n' : '') + partial + CURSOR;
|
||||
}
|
||||
function typeLine() {
|
||||
var ln = lines[li], i = 0;
|
||||
(function step() {
|
||||
if (i <= ln.t.length) {
|
||||
render(ln.p + ln.t.slice(0, i));
|
||||
i++; timer = setTimeout(step, TYPE_MS);
|
||||
} else {
|
||||
done.push(ln.p + ln.t);
|
||||
li++;
|
||||
if (li >= lines.length) timer = setTimeout(reset, 4000); // hold last line 4s
|
||||
else timer = setTimeout(typeLine, 2000); // pause 2s before next
|
||||
}
|
||||
})();
|
||||
}
|
||||
function reset() { clearTimeout(timer); done = []; li = 0; typeLine(); }
|
||||
|
||||
// Start typing only when the terminal scrolls into view (and replay each
|
||||
// time you return to it).
|
||||
if ('IntersectionObserver' in window) {
|
||||
var io2 = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { if (e.isIntersecting) reset(); });
|
||||
}, { threshold: 0.45 });
|
||||
io2.observe(pre);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
})();
|
||||
|
||||
// Previews: hovering/tapping a panel expands it (CSS) and plays its video; the
|
||||
// video only becomes visible once it actually starts playing, so missing
|
||||
// files just leave the labeled placeholder.
|
||||
(function () {
|
||||
var panels = [].slice.call(document.querySelectorAll('.preview-panel'));
|
||||
if (!panels.length) return;
|
||||
var active = -1;
|
||||
function playPanel(p) {
|
||||
var v = p.querySelector('video');
|
||||
if (!v) return;
|
||||
var pr = v.play();
|
||||
if (pr && pr.catch) pr.catch(function () {});
|
||||
}
|
||||
function pausePanel(p) {
|
||||
var v = p.querySelector('video');
|
||||
if (v) v.pause();
|
||||
}
|
||||
function setActive(i, shouldPlay) {
|
||||
active = (i + panels.length) % panels.length;
|
||||
panels.forEach(function (panel, k) {
|
||||
var on = k === active;
|
||||
panel.classList.toggle('is-active', on);
|
||||
panel.setAttribute('aria-expanded', on ? 'true' : 'false');
|
||||
if (!on) pausePanel(panel);
|
||||
});
|
||||
if (shouldPlay !== false) playPanel(panels[active]);
|
||||
}
|
||||
panels.forEach(function (p, i) {
|
||||
var v = p.querySelector('video');
|
||||
if (v) {
|
||||
v.addEventListener('playing', function () { p.classList.add('has-video'); });
|
||||
v.addEventListener('pause', function () { /* keep last frame */ });
|
||||
}
|
||||
p.setAttribute('aria-expanded', 'false');
|
||||
p.addEventListener('mouseenter', function () { setActive(i); });
|
||||
p.addEventListener('focus', function () { setActive(i); });
|
||||
p.addEventListener('mouseleave', function () {
|
||||
if (!window.matchMedia || !window.matchMedia('(hover: none)').matches) {
|
||||
p.classList.remove('is-active');
|
||||
p.setAttribute('aria-expanded', 'false');
|
||||
pausePanel(p);
|
||||
}
|
||||
});
|
||||
p.addEventListener('blur', function () { pausePanel(p); });
|
||||
p.addEventListener('click', function () { setActive(i); });
|
||||
});
|
||||
var strip = document.querySelector('.previews');
|
||||
var sx = null, sy = null;
|
||||
if (strip) {
|
||||
strip.addEventListener('touchstart', function (e) {
|
||||
if (!e.touches.length) return;
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
strip.addEventListener('touchend', function (e) {
|
||||
if (sx === null || sy === null || !e.changedTouches.length) return;
|
||||
var dx = e.changedTouches[0].clientX - sx;
|
||||
var dy = e.changedTouches[0].clientY - sy;
|
||||
if (Math.abs(dx) > 42 && Math.abs(dx) > Math.abs(dy) * 1.25) {
|
||||
setActive((active < 0 ? 0 : active) + (dx < 0 ? 1 : -1));
|
||||
}
|
||||
sx = sy = null;
|
||||
}, { passive: true });
|
||||
}
|
||||
})();
|
||||
|
||||
// Domino reveal: fade/slide each section in as it scrolls into view.
|
||||
(function () {
|
||||
var els = document.querySelectorAll('.hero, section');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach(function (e) { e.classList.add('in'); });
|
||||
return;
|
||||
}
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
|
||||
});
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
els.forEach(function (e) { io.observe(e); });
|
||||
})();
|
||||
|
||||
// Fake terminal window buttons — minimize, maximize, close (and reopen).
|
||||
(function () {
|
||||
var term = document.querySelector('.term');
|
||||
var reopen = document.querySelector('.term-reopen');
|
||||
if (!term) return;
|
||||
term.querySelectorAll('.winbtns [data-term]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var act = b.getAttribute('data-term');
|
||||
if (act === 'min') term.classList.toggle('term-min');
|
||||
else if (act === 'close') {
|
||||
term.classList.add('term-closed');
|
||||
if (reopen) reopen.classList.add('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
if (reopen) reopen.addEventListener('click', function () {
|
||||
term.classList.remove('term-closed', 'term-min');
|
||||
reopen.classList.remove('show');
|
||||
});
|
||||
})();
|
||||
|
||||
// Mobile testimonial carousel: tap or swipe to advance; Polyphemus shakes ~1s.
|
||||
(function () {
|
||||
var carousel = document.getElementById('tcarousel');
|
||||
var nav = document.getElementById('tnav');
|
||||
if (!carousel || !nav) return;
|
||||
var cards = [].slice.call(carousel.querySelectorAll('.tcard'));
|
||||
if (!cards.length) return;
|
||||
var idx = 0;
|
||||
|
||||
var dots = cards.map(function (_, k) {
|
||||
var d = document.createElement('span');
|
||||
d.className = 'tdot';
|
||||
d.addEventListener('click', function (e) { e.stopPropagation(); show(k); });
|
||||
nav.appendChild(d);
|
||||
return d;
|
||||
});
|
||||
var hint = document.createElement('div');
|
||||
hint.className = 'thint';
|
||||
hint.textContent = 'tap or swipe for the next satisfied customer →';
|
||||
nav.appendChild(hint);
|
||||
|
||||
function show(i) {
|
||||
idx = (i + cards.length) % cards.length;
|
||||
cards.forEach(function (c, k) { c.classList.toggle('active', k === idx); c.classList.remove('shake'); });
|
||||
dots.forEach(function (d, k) { d.classList.toggle('on', k === idx); });
|
||||
var cur = cards[idx];
|
||||
if (cur.getAttribute('data-shake') === '1') {
|
||||
void cur.offsetWidth;
|
||||
cur.classList.add('shake');
|
||||
setTimeout(function () { cur.classList.remove('shake'); }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
carousel.addEventListener('click', function () { show(idx + 1); });
|
||||
|
||||
var _prev = document.querySelector('.tarrow.prev');
|
||||
var _next = document.querySelector('.tarrow.next');
|
||||
if (_prev) _prev.addEventListener('click', function (e) { e.stopPropagation(); show(idx - 1); });
|
||||
if (_next) _next.addEventListener('click', function (e) { e.stopPropagation(); show(idx + 1); });
|
||||
|
||||
var sx = null;
|
||||
carousel.addEventListener('touchstart', function (e) { sx = e.touches[0].clientX; }, { passive: true });
|
||||
carousel.addEventListener('touchend', function (e) {
|
||||
if (sx === null) return;
|
||||
var dx = e.changedTouches[0].clientX - sx;
|
||||
if (Math.abs(dx) > 30) { show(idx + (dx < 0 ? 1 : -1)); }
|
||||
sx = null;
|
||||
});
|
||||
|
||||
show(0);
|
||||
})();
|
||||
|
||||
// Copy button for the codeblock command.
|
||||
(function () {
|
||||
var btn = document.querySelector('.codeblock .copy-btn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () {
|
||||
btn.textContent = 'Copied!'; btn.classList.add('copied');
|
||||
setTimeout(function () { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
# Original Harness Capability Policy
|
||||
|
||||
Odysseus Original is the canonical product and the only agent orchestrator.
|
||||
Specialized runners are evidence sources, not merge targets.
|
||||
|
||||
## Architecture
|
||||
|
||||
The supported shape is:
|
||||
|
||||
1. One Original agent loop owns prompting, tool selection, policy, evidence,
|
||||
recovery, compaction, and completion.
|
||||
2. Capability contracts describe what the current environment can do.
|
||||
3. Execution bridges or MCP servers perform work in the owning environment.
|
||||
4. Personal tools remain available in interactive sessions but are excluded
|
||||
from external terminal contracts unless explicitly provided.
|
||||
|
||||
## Capability Decisions
|
||||
|
||||
| Capability | Decision | Canonical form |
|
||||
| --- | --- | --- |
|
||||
| Workspace execution | Keep | Request-scoped `AgentExecutionBridge`, moving toward a Workspace MCP boundary |
|
||||
| File mutation | Keep | `write_file`, `edit_file`, and `apply_patch` with evidence recording |
|
||||
| Process recovery | Keep | Bounded polling, timeout, exit status, and stale-process recovery behind the execution contract |
|
||||
| Repeated-action recovery | Keep | Detect identical calls, unchanged successful results, and failed batches separately |
|
||||
| Completion | Keep | Required artifacts and executable verifier evidence; no evaluator-specific shortcuts |
|
||||
| Context control | Keep | Deterministic compaction with retained user evidence and bounded tool output |
|
||||
| Search | Keep | Existing private SearXNG path |
|
||||
| Browser interaction | Keep | Existing private browser for rendered pages, sessions, clicks, and screenshots |
|
||||
| Personal tools | Keep, isolated | Separate personal capability surface; never substitute editor documents for workspace files |
|
||||
| Tool discovery | Keep | Existing tool RAG and capability-aware selection |
|
||||
| Media ingress | Keep | Bounded image, audio, video-frame, and document ingestion with hashes and trace metadata |
|
||||
| Visual verification | Candidate | Generic source grounding and rendered-artifact checks, gated by available media capabilities |
|
||||
| Document access | Candidate | Structured PDF and Office extraction through `read_file` or a Workspace MCP implementation |
|
||||
| Skills | Keep, generic only | Procedures for artifact completion, recovery, verification, development, media evidence, and research |
|
||||
|
||||
## Rejected Merges
|
||||
|
||||
Do not merge:
|
||||
|
||||
- another agent loop, submit controller, or conversation state machine;
|
||||
- category names, task identifiers, fixed workspace paths, expected answers,
|
||||
grader behavior, scoring rules, or evaluator prompts;
|
||||
- per-category turn thresholds or phase transitions;
|
||||
- terminal multiplexer control when the environment already exposes direct
|
||||
process execution;
|
||||
- repeated prompt guards that do not add new executable evidence;
|
||||
- browser or search replacements for capabilities Original already owns.
|
||||
|
||||
## Merge Gate
|
||||
|
||||
A mechanism may enter Original only when all of these are true:
|
||||
|
||||
1. It is useful outside the evaluation that revealed it.
|
||||
2. It is selected by an environment capability, not a task name or path.
|
||||
3. It fits inside the Original loop or behind an execution boundary.
|
||||
4. Its success or failure produces traceable evidence.
|
||||
5. It has focused regressions and a representative held-out run.
|
||||
6. It removes or contains complexity instead of adding an overlapping mode.
|
||||
|
||||
## Current Priority
|
||||
|
||||
The next capability work is reliable structured document access. Failed
|
||||
binary-file inspections must remain failures; they must not be interpreted as
|
||||
unchanged evidence or trigger early artifact synthesis. Once grounding is
|
||||
reliable, add generic rendered-artifact verification without importing any
|
||||
specialized task phases.
|
||||
@@ -0,0 +1,346 @@
|
||||
# Photo Editor Product Audit
|
||||
|
||||
Date: 2026-08-30
|
||||
|
||||
## Executive Verdict
|
||||
|
||||
Odysseus already has the structure of a real layered raster editor. It is not a
|
||||
mockup: layers, nested groups, masks, selections, retained text, blending,
|
||||
history, document geometry, project recovery, controlled export, and several AI
|
||||
workflows operate on an editable document model.
|
||||
|
||||
It is **roughly 78% of a dependable everyday photo editor**, but only **about
|
||||
35% of a professional Photoshop/Photopea alternative**. Those are deliberately
|
||||
separate scores. The first target needs complete, trustworthy common workflows;
|
||||
the second also needs non-destructive sources and filters, color management,
|
||||
professional file interchange, vector/path tooling, automation, and scale.
|
||||
|
||||
The main product gap is no longer basic layer infrastructure. It is the absence
|
||||
of a strong photo-correction and retouching workflow, combined with insufficient
|
||||
proof that every existing operation preserves pixels, masks, text, and project
|
||||
state across desktop and touch.
|
||||
|
||||
## Current Scorecard
|
||||
|
||||
| Area | Everyday readiness | Current assessment |
|
||||
| --- | ---: | --- |
|
||||
| Canvas navigation and precision | 90% | Pan/zoom, fit and 1:1, rulers, guides, grid, snapping, and numeric geometry are present |
|
||||
| Layers and compositing | 96% | Raster/text layers, nested groups, clipping, masks, blend modes, multi-select, locks, subtree reorder, and shared transforms are strong |
|
||||
| Selections and masks | 97% | Marquee, lasso, wand, SAM, Quick Mask, named selections, affine selection transform, and linked/unlinked layer-mask positioning work |
|
||||
| Text | 65% | Retained text exists; paragraph layout, tracking, font status, stronger hit testing, and mobile proof do not |
|
||||
| Painting and retouching | 70% | Brush, eraser, clone, source-free and sampled healing, smudge, retained linear/radial gradients, fill, AI inpaint, background removal, sharpen, eyedropper, dodge/burn, and brush presets exist; advanced raster retouching remains |
|
||||
| Photo correction | 65% | Retained levels, curves, exposure, white balance, hue/saturation, vibrance, shadows/highlights, color balance, selective color, gradient map, brightness/contrast, and histogram controls exist; camera/lens correction remains |
|
||||
| Non-destructive editing | 60% | Text and adjustment metadata are retained, image imports and pasted selections create source-backed placed layers, raster layers can be converted to sources, and transforms/replacement preserve source pixels; linked instances and richer source editing remain |
|
||||
| Save, recovery, and export | 88% | Versioned project recovery and PNG/JPEG/WebP export are strong; pixel-equivalence, metadata, and color-profile policies remain |
|
||||
| Mobile editing | 45% | Responsive UI, touch navigation, and non-overlapping tool/layer sheets are tested; core editing gestures still need broader coverage |
|
||||
| Performance and color fidelity | 30% | Safety limits exist; workers/tiles, stress evidence, ICC handling, and high-bit-depth editing do not |
|
||||
|
||||
## Verified Baseline
|
||||
|
||||
The current implementation was checked on 2026-08-30:
|
||||
|
||||
- **72 editor-focused unit tests pass.** Coverage includes the v12 document
|
||||
model, migrations, validation, geometry, selections, masks, mask offsets,
|
||||
groups, clipping, multi-select, transforms, retained text, history limits,
|
||||
guides, and export settings.
|
||||
- **81 Chromium Playwright workflows pass.** They cover the layered core path,
|
||||
group masks and reorder, clipping, independent locks, multi-selection,
|
||||
nested groups, shared transforms, linked/unlinked mask movement and reopen,
|
||||
exact draft reopen, export dimensions, project recovery, Quick Mask, precise
|
||||
marquee geometry, transformed selections, named selections, and mobile touch
|
||||
crop, selection, brush, and transform gestures, plus mobile mask persistence,
|
||||
movement, and PNG export.
|
||||
- Mobile editor refresh now has a dedicated recovery gate that restores the
|
||||
active draft, editor tab, saved status, canvas, and layer stack.
|
||||
- The adjustment popup has a 320px phone-width regression gate: slider rows stay
|
||||
inside the sheet and the Apply/Cancel actions remain reachable.
|
||||
- The Gradient Map adjustment has a dedicated 320px gate: its color controls
|
||||
collapse to one responsive column and remain inside the adjustment sheet.
|
||||
- Every retained adjustment popup now has a 320px matrix check for viewport
|
||||
bounds, horizontal overflow, and reachable Apply/Cancel actions.
|
||||
- The adjustment export matrix compares decoded PNG pixels against the visible
|
||||
composite for every retained adjustment family, including Curves and
|
||||
Gradient Map.
|
||||
- The adjustment compositing workflow also compares export pixels when a
|
||||
retained adjustment is clipped, masked, opacity-modified, undone/redone, and
|
||||
reopened from a saved draft.
|
||||
- The retained-adjustment export workflow passes in both Chromium and Firefox;
|
||||
the Playwright harness now supports selecting `chromium`, `firefox`, or
|
||||
`webkit` through `PHOTO_EDITOR_E2E_BROWSER`.
|
||||
- Retained-effect previews now fall back cleanly when a worker cannot be
|
||||
created, and stale worker errors no longer trigger an unnecessary full-size
|
||||
synchronous render.
|
||||
- The mobile layer-sheet workflow also verifies touch mask editing, undo/redo,
|
||||
and mask persistence after browser reload.
|
||||
- The merge-fidelity workflow compares the rendered composite before and after
|
||||
Merge All with retained effects and adjustment layers, ensuring those edits
|
||||
are baked into the resulting raster instead of being dropped.
|
||||
- The grouped-effects workflow compares a retained group effect against the
|
||||
exported PNG pixel-for-pixel and verifies its parameters survive draft reopen.
|
||||
- The export preview workflow verifies matte pixels are cleared when switching
|
||||
back to transparency.
|
||||
- The export dialog workflow restores focus to the Save control after closing,
|
||||
including the menu-launched export path.
|
||||
- The core workflow now compares SHA-256 digests before and after draft reopen,
|
||||
requires exact decoded pixels for native PNG export, and bounds premultiplied
|
||||
pixel error for resized PNG output.
|
||||
- The linked-mask regression gate verifies that an unlinked mask remains at a
|
||||
fixed document position when its parent layer moves or transforms. Brush,
|
||||
Wand, and Fill now resolve independently moved masks from the same origin.
|
||||
|
||||
This is solid Chromium/mouse evidence with focused touch coverage for crop,
|
||||
selection, brush, and transform gestures, plus a focused Firefox export check.
|
||||
It does not establish full Safari/Firefox compatibility, complete touch
|
||||
reliability, large-document responsiveness, metadata or ICC fidelity, or
|
||||
pixel-equivalent exports for every format.
|
||||
|
||||
## What Already Works
|
||||
|
||||
| Capability | Implementation status |
|
||||
| --- | --- |
|
||||
| Layered document | Raster and retained-text layers, nested groups, opacity, visibility, 16 Canvas2D blend modes, clipping, duplicate, merge, and hierarchy-aware reorder |
|
||||
| Layer control | Multi-select, shared-bounds transforms, full/pixel/transparency/position locks, group masks, paintable layer masks, and linked/unlinked mask position |
|
||||
| Selection model | Rectangle/ellipse marquee, lasso, Magic Wand, SAM, new/add/subtract/intersect, animated boundary, exact geometry, move/scale/rotate/flip, nudge, invert, Quick Mask, reselect, and named selections |
|
||||
| Paint and AI | Brush, eraser, clone stamp, selection fill, inpaint, background removal, SAM, harmonize, upscale, denoise, face enhancement, and style operations |
|
||||
| Geometry | Crop, image resize, canvas resize, rotate, flip, move, transforms, rulers, guides, grid, and snapping |
|
||||
| Recovery | Validated v12+ project format, explicit migrations, bounded autosave/history, partial corrupt-project recovery, and exact server-draft reopen |
|
||||
| Delivery | Previewed PNG/JPEG/WebP export with quality, dimensions, aspect lock, transparency/matte, filename, and gallery-copy flow |
|
||||
|
||||
## Release Blockers
|
||||
|
||||
These are the gaps that prevent calling the editor dependable today.
|
||||
|
||||
### P0: Trust Existing Operations
|
||||
|
||||
1. **Pixel-equivalent export proof is still too narrow.**
|
||||
The core layered workflow now proves exact native PNG pixels and bounded
|
||||
resized-PNG error, and format metadata is covered for JPEG/WebP. Grouped
|
||||
blending, clipping, matte pixels, and color-profile behavior still need the
|
||||
same evidence.
|
||||
|
||||
2. **Touch editing is only partially release-tested.**
|
||||
Pan and pinch primitives exist, and paint, selection, crop, and transform
|
||||
now have focused browser coverage, but broader mobile overlap and
|
||||
inaccessible-control assertions are still needed around edge cases.
|
||||
|
||||
3. **Operation contracts are incomplete.**
|
||||
Every destructive operation needs an explicit test matrix for raster layers,
|
||||
retained text, linked/unlinked masks, group masks, selections, locks, clipped
|
||||
layers, and multi-selection. The independently positioned mask bug found in
|
||||
Brush/Wand/Fill demonstrates why shared coordinate helpers are required.
|
||||
|
||||
4. **Large-document behavior is bounded, not proven.**
|
||||
Full-canvas Canvas2D compositing and RGBA history snapshots have hard limits,
|
||||
but there is no stress suite, cancellation contract, worker/offscreen path,
|
||||
memory telemetry, or degraded-preview strategy.
|
||||
|
||||
5. **Color and metadata behavior is undefined.**
|
||||
Import relies on browser decoding and export writes a new bitmap. Users are
|
||||
not told whether ICC, EXIF/IPTC, orientation, DPI, or location metadata is
|
||||
honored, normalized, preserved, or stripped.
|
||||
|
||||
## Missing Everyday Features
|
||||
|
||||
These have higher value than adding more isolated AI tools.
|
||||
|
||||
### P1: Photo Correction
|
||||
|
||||
- Composite and per-channel histogram with clipping warnings
|
||||
- Adjustment presets, reset, and non-destructive before/after compare are implemented
|
||||
- Actual adjustment layers that affect content below, can be clipped/grouped,
|
||||
and have their own masks; the current per-raster-layer stack is not equivalent
|
||||
|
||||
### P1: Retouching and Paint Ergonomics
|
||||
|
||||
- Richer multi-stop and radial gradient controls for raster content
|
||||
- Healing Brush distinct from source-free Spot Healing and Clone Stamp
|
||||
- Blur and Smudge brushes
|
||||
- Content-aware fill workspace built on the existing inpaint capability
|
||||
|
||||
### P1: Geometry, Text, and Layer Workflow
|
||||
|
||||
- Image Size supports staged pixel/percentage resizing, aspect locking, and interpolation choice
|
||||
- Canvas Size supports staged pixel/percentage bounds with anchor control
|
||||
- Align and distribute selected layers (implemented in the multi-selection bar)
|
||||
- Optional canvas auto-select for visible layers (group-level hit testing remains)
|
||||
- Paragraph text boxes, tracking, vertical alignment, font loading/fallback
|
||||
status, and more reliable text hit testing
|
||||
- Mask density, non-destructive feather, invert, disable, link/unlink position
|
||||
behavior, mask-only inspection, and applying true layer masks are implemented;
|
||||
applying a group mask still requires an explicit flattening workflow
|
||||
|
||||
## Missing Professional Features
|
||||
|
||||
These define the gap to Photopea/Photoshop rather than blocking a credible v1.
|
||||
|
||||
### P2: Non-Destructive Core
|
||||
|
||||
- Embedded source layers / smart-object equivalent (toolbar/gallery/drop imports, pasted selections, and manual raster-to-source conversion now exist; richer source editing remains)
|
||||
- Editable transform matrices that preserve original pixels through repeated
|
||||
scale and rotate operations are now covered for placed and converted raster layers
|
||||
- Linked instances and replace-source workflow
|
||||
- Editable filter stacks with visibility, opacity, reorder, masks, and cached
|
||||
previews
|
||||
- Blur, sharpen, denoise, high pass, lens correction, and perspective correction
|
||||
as retained filters
|
||||
- Non-destructive transform masks, including perspective/warp later
|
||||
|
||||
This is the most important architectural gap. Photopea's Smart Objects retain a
|
||||
separate source so repeated transforms can be recalculated without cumulative
|
||||
loss, and its Smart Filters remain editable. Krita similarly models transform
|
||||
and filter masks as non-destructive layer children.
|
||||
|
||||
### P2: File and Color Fidelity
|
||||
|
||||
- Explicit sRGB conversion and ICC profile awareness
|
||||
- 16-bit processing before considering 32-bit/HDR
|
||||
- EXIF/IPTC preservation or intentional stripping controls
|
||||
- Reliable HEIC/TIFF handling and a RAW handoff/development path
|
||||
- PSD import/export feasibility and a published compatibility matrix
|
||||
- DPI/PPI and print-size metadata
|
||||
|
||||
### P2: Vector and Layout Work
|
||||
|
||||
- Shape layers for rectangle, ellipse, line, and custom paths
|
||||
- Pen tool, editable Bezier paths, vector masks, and path-based selections
|
||||
- Layer styles such as stroke, shadow, glow, and overlays
|
||||
- Channels panel and channel operations
|
||||
- Artboards only if multi-output design work is a product goal
|
||||
|
||||
### P3: Production Workflow
|
||||
|
||||
- Actions/macros, batch processing, and batch export
|
||||
- Templates, reusable presets, and layer comps
|
||||
- Soft proofing, gamut warning, and print output
|
||||
- Plugin/filter extension surface
|
||||
- Version history beyond the local bounded undo stack
|
||||
|
||||
## UX Audit
|
||||
|
||||
1. **The toolbar prioritizes AI before correction fundamentals.** Healing,
|
||||
Eyedropper, Gradient, and Curves should be as discoverable as SAM and Inpaint.
|
||||
2. **The distinction between masks is still cognitively expensive.** Selection,
|
||||
Quick Mask, AI masks, layer masks, and group masks need consistent names,
|
||||
thumbnails, active states, and properties rather than relying on sub-row
|
||||
position alone.
|
||||
3. **Properties are fragmented.** Tool controls, layer adjustments, transform
|
||||
values, and mask settings should use one contextual Properties area. This
|
||||
reduces modal popups and makes the selected target obvious.
|
||||
4. **The editor needs clearer destructive-action signaling.** Blur, rasterize,
|
||||
merge, and applied transforms should say when source pixels will be replaced,
|
||||
with a one-step duplicate/convert-to-source option where appropriate.
|
||||
5. **Mobile needs a deliberate mode.** Shrinking desktop controls is not enough;
|
||||
canvas-first editing needs bottom-sheet properties, stable touch targets,
|
||||
stylus behavior, and predictable two-finger navigation while a tool is active.
|
||||
The tool and Layers sheets now claim the viewport exclusively, and mobile
|
||||
layer/mask rows have stable non-scrolling layouts. Paint, crop, transform,
|
||||
mask save/reopen, mask movement, and layer reorder gestures are covered;
|
||||
mobile export now has viewport and download coverage, while broader
|
||||
multi-tool touch workflows remain.
|
||||
|
||||
## Credible V1 Definition
|
||||
|
||||
A dependable everyday editor is reached when all of these workflows pass as a
|
||||
single checked-in desktop and touch gate:
|
||||
|
||||
1. Import a common web image, correct exposure/color, retouch a blemish, crop,
|
||||
resize, add text, and export at a chosen size and quality.
|
||||
2. Build a layered composition with nested groups, clipping, a linked mask and
|
||||
an independently positioned mask, then close and reopen without state loss.
|
||||
3. Apply, cancel, undo, and redo each geometry/filter operation without changing
|
||||
unrelated pixels or retained metadata.
|
||||
4. Compare the flattened visible composite, reopened composite, and exported
|
||||
bitmap within a documented pixel tolerance.
|
||||
5. Complete the same core workflow with mouse and touch, with no inaccessible
|
||||
controls, accidental page gestures, or silent partial edits.
|
||||
6. Reject oversized, corrupt, or unsupported documents with a useful warning
|
||||
while retaining every recoverable layer.
|
||||
|
||||
## Recommended Build Order
|
||||
|
||||
### Milestone 1: Reliability Gate
|
||||
|
||||
- Expand composite-versus-export pixel tests across groups, clipping,
|
||||
adjustments, transparency/mattes, JPEG, and WebP.
|
||||
- Add the operation/target matrix for masks, text, groups, clipping, locks, and
|
||||
multi-select.
|
||||
- Add Chromium touch/mobile workflows and basic Firefox/WebKit smoke coverage.
|
||||
- Centralize document/layer/mask coordinate conversion.
|
||||
- Define import/export color and metadata policy.
|
||||
|
||||
**Exit:** a mixed 20-edit document survives undo/redo, close/reopen, and export
|
||||
with equivalent visible pixels and editable state.
|
||||
|
||||
### Milestone 2: Everyday Photo Workflow
|
||||
|
||||
- Extend raster retained gradients with richer stop editing
|
||||
alongside the existing Eyedropper, Gradient, Healing, and Smudge tools.
|
||||
- Add Curves, Exposure, White Balance, Vibrance, and Shadows/Highlights.
|
||||
- Finish mask properties, Image/Canvas Size dialogs, text layout, alignment, and
|
||||
brush presets.
|
||||
|
||||
**Exit:** crop, correction, blemish removal, annotation, transparent assets, and
|
||||
social-image composition can be completed locally without another editor.
|
||||
|
||||
### Milestone 3: Non-Destructive Editing
|
||||
|
||||
- Introduce source layers and editable transform matrices.
|
||||
- Promote adjustments into real adjustment layers.
|
||||
- Add retained filter stacks and filter masks.
|
||||
|
||||
**Exit:** normal experimentation no longer requires manually duplicating layers
|
||||
to protect the original pixels.
|
||||
|
||||
### Milestone 4: Interchange and Scale
|
||||
|
||||
- Add worker/offscreen rendering, cancellation, telemetry, and stress tests.
|
||||
- Implement color-profile and metadata policy.
|
||||
- Run a PSD/HEIC/TIFF/RAW feasibility spike and publish compatibility limits.
|
||||
|
||||
## Architecture Direction
|
||||
|
||||
The current `layer.canvas + optional metadata` model is reaching its limit.
|
||||
Before adding adjustment layers, vectors, or source layers, move to a typed
|
||||
document contract:
|
||||
|
||||
```text
|
||||
Layer = RasterLayer | TextLayer | GroupLayer | AdjustmentLayer | SourceLayer
|
||||
|
||||
common: id, name, visible, opacity, blendMode, locks, masks, transform
|
||||
raster: mutable pixel surface
|
||||
text: content and typography
|
||||
group: ordered child ids
|
||||
adjustment: operation, parameters, clipping, mask
|
||||
source: immutable embedded pixels, editable transform, filter stack
|
||||
```
|
||||
|
||||
Rendering, serialization, history, geometry, duplicate, merge, and thumbnails
|
||||
should dispatch through that contract. Coordinate conversion should likewise be
|
||||
centralized around document, layer, mask, and viewport spaces instead of being
|
||||
reimplemented inside tools.
|
||||
|
||||
History should evolve toward commands plus periodic checkpoints. Bounded full
|
||||
RGBA snapshots prevent runaway memory today, but remain expensive for large
|
||||
documents and awkward for retained non-raster layer types.
|
||||
|
||||
## Benchmark Basis
|
||||
|
||||
This audit uses mature editors as behavior references, not as a requirement to
|
||||
clone every feature:
|
||||
|
||||
- [Photopea feature map](https://www.photopea.com/learn/)
|
||||
- [Photopea masks and mask properties](https://www.photopea.com/learn/masks)
|
||||
- [Photopea adjustment layers and Smart Filters](https://www.photopea.com/learn/adjustments-filters)
|
||||
- [Photopea Smart Objects](https://www.photopea.com/learn/smart-objects)
|
||||
- [Krita transform masks](https://docs.krita.org/en/reference_manual/layers_and_masks/transformation_masks.html)
|
||||
- [Krita non-destructive filters](https://docs.krita.org/en/reference_manual/filters.html)
|
||||
- [Photoshop color-adjustment workflow](https://helpx.adobe.com/photoshop/using/color-adjustments.html)
|
||||
- [Photoshop Content-Aware Fill](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/fill-objects-selections-layers/content-aware-fills.html)
|
||||
|
||||
## Immediate Next Slice
|
||||
|
||||
Extend the new fidelity/mobile gate across **grouped blending, adjustments,
|
||||
and real touch paint/transform gestures** next. Selection copy, cut, paste, and
|
||||
single-step undo now have checked coverage.
|
||||
After that, implement **Gradient + Healing Brush** as one vertical
|
||||
everyday-photo slice rather than adding disconnected controls.
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
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.
|
||||
|
||||
It is a triage helper, not a replacement for maintainer judgment.
|
||||
|
||||
## What it does
|
||||
|
||||
- Reads open PR metadata from a local JSON file or from `gh`.
|
||||
- Reports files touched by more than one open PR.
|
||||
- Groups active work into broad code areas.
|
||||
- Ranks PRs with a deterministic heuristic score.
|
||||
- Flags possible duplicate candidates based on title keyword overlap and changed-file similarity.
|
||||
- Suggests quieter areas for conservative new work.
|
||||
- Prints Markdown by default, compact terminal output when requested, or machine-readable JSON.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- It does not post comments.
|
||||
- It does not review, approve, label, close, merge, or otherwise mutate PRs.
|
||||
- It does not add or run GitHub Actions.
|
||||
- It does not import the Odysseus application package.
|
||||
- It does not claim that a PR is definitely blocked or duplicated.
|
||||
|
||||
## Read-only safety guarantee
|
||||
|
||||
Offline mode only reads a local JSON file. Live mode runs read-only GitHub CLI commands:
|
||||
|
||||
```bash
|
||||
gh pr list --repo OWNER/REPO --state open --limit 1000 --json number,title,author,files,mergeStateStatus,reviewDecision,updatedAt,url
|
||||
```
|
||||
|
||||
If a PR from that list has missing or empty changed-file metadata, live mode fills it with read-only per-PR REST calls:
|
||||
|
||||
```bash
|
||||
gh api --paginate "repos/OWNER/REPO/pulls/NUMBER/files?per_page=100"
|
||||
```
|
||||
|
||||
If that GraphQL-backed command fails, it falls back to:
|
||||
|
||||
```bash
|
||||
gh api --paginate "repos/OWNER/REPO/pulls?state=open&per_page=100"
|
||||
```
|
||||
|
||||
Per-PR file fetching makes live overlap results useful, but it can be slower on repositories with hundreds of open PRs.
|
||||
|
||||
## Generate input JSON
|
||||
|
||||
For repeatable offline audits, capture PR metadata first:
|
||||
|
||||
```bash
|
||||
gh pr list --repo OWNER/REPO --state open --limit 1000 --json number,title,author,files,mergeStateStatus,reviewDecision,updatedAt,url > open-prs.json
|
||||
```
|
||||
|
||||
## Run offline mode
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json
|
||||
```
|
||||
|
||||
## Run live mode
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO
|
||||
```
|
||||
|
||||
Live mode fetches up to 1000 open PRs by default. Use `--limit` to cap how many open PRs are fetched and analyzed, and `--top` to cap how many rows are displayed in ranked sections:
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --limit 50 --top 10
|
||||
```
|
||||
|
||||
Live mode may take time on large PR queues because it fetches changed-file metadata for each PR that did not include it in the initial list response. Progress is shown on `stderr` by default only when `stderr` is a TTY:
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --progress auto
|
||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --progress always
|
||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --progress never
|
||||
```
|
||||
|
||||
Use `--quiet` to suppress progress and non-fatal warning output. Progress and warnings never go to `stdout`, so redirected reports and `--output` files remain clean.
|
||||
|
||||
For a faster metadata-only scan, skip changed-file metadata entirely:
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --no-fetch-files
|
||||
```
|
||||
|
||||
## JSON output
|
||||
|
||||
Use `--format json` for machine-readable output suitable for scripting or downstream tooling:
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format json
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format json --output report.json
|
||||
```
|
||||
|
||||
JSON output is stable and deterministic for the same input. It uses `sort_keys=True` so field order does not vary between runs. It never includes ANSI escape codes, even with `--color always`. Progress text is always `stderr`-only and never appears in JSON output.
|
||||
|
||||
The top-level object contains these keys:
|
||||
|
||||
- `summary` — scalar overview: `total_prs_analyzed`, `unique_files_touched`, `prs_missing_changed_file_metadata`, `main_overlap_drivers`, `highest_risk_areas`, `recommended_first_review_target`
|
||||
- `locked_areas` — list of objects with `area`, `files` (top paths as a string), `prs` (list of PR numbers), `why`, `priority`
|
||||
- `hot_files` — list of objects with `file`, `pr_count`, `pr_numbers` (list of PR numbers); capped at `--top`
|
||||
- `review_priorities` — ranked list with `rank`, `number`, `score`, `title`, `url`, `merge_state`, `review_decision`, `reasons` (list); capped at `--top`
|
||||
- `duplicate_candidates` — list of objects with `pr_numbers` (list) and `titles` (list, one entry per PR in the group)
|
||||
- `safer_areas` — list of strings
|
||||
|
||||
## Write output to a file
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --output pr-blocker-report.md
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format json --output report.json
|
||||
```
|
||||
|
||||
Markdown and JSON output never include ANSI color codes. ANSI codes are stripped defensively when writing any output file.
|
||||
|
||||
## Terminal output and color
|
||||
|
||||
Use terminal output for quick interactive scans:
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal
|
||||
```
|
||||
|
||||
Terminal output includes locked areas, hot files, review / blocker priorities, possible duplicate candidates, and safer areas.
|
||||
|
||||
Color is readability-only. It is never included in Markdown reports and is stripped defensively when writing output files. Color modes are:
|
||||
|
||||
```bash
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal --color auto
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal --color always
|
||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal --color never
|
||||
```
|
||||
|
||||
`--no-color` is kept as an alias for `--color never`. With `--color auto`, color is used only for terminal output on a TTY when `NO_COLOR` is not set and output is not being written to a file.
|
||||
|
||||
## Interpret locked areas
|
||||
|
||||
Locked areas are broad categories with one or more open PRs. An area is higher priority when several PRs touch it, when PRs share files, or when the highest scoring PR in that area has risk signals. Treat this as a prompt to inspect the PRs together.
|
||||
|
||||
`PRs missing changed-file metadata` counts PRs that still had no changed-file paths after live file fetching, or PRs from offline input that did not include files. Those PRs can still appear in area summaries from title matching, but file overlap analysis is weaker for them.
|
||||
|
||||
`Docs / tooling / tests` is conservative: runtime PRs are not classified there just because they include tests or README changes. Docs-only, README-only, scripts-only, tests-only, or strongly titled docs/tooling/test work still maps there.
|
||||
|
||||
`Other / unclassified` is kept visible for PRs that do not match the area rules. When most of it comes from missing file metadata, the report summarizes that instead of letting long PR lists dominate the locked-area section.
|
||||
|
||||
## Interpret duplicate candidates
|
||||
|
||||
Duplicate candidates are labeled as possible duplicate / needs human review. The script groups PRs only when their file sets are highly similar and their titles share meaningful keywords. Similar PRs can still be complementary.
|
||||
|
||||
## Interpret heuristic scores
|
||||
|
||||
The review priority score is deterministic for the same input. Recency is measured against the newest parseable PR update timestamp in the input, and the score uses simple weights for:
|
||||
|
||||
- direct auth, bearer-token, API-token, privilege, or permission lifecycle signals
|
||||
- security, secret, or data exposure keywords
|
||||
- persistence, migration, database, SQLite, or Postgres keywords
|
||||
- memory, vector, RAG, embedding, or retrieval keywords
|
||||
- overlapping changed files
|
||||
- clean merge state as a small actionability signal
|
||||
- review state
|
||||
- recently updated PRs when timestamp data exists
|
||||
|
||||
Higher scores mean "inspect earlier", not "correct" or "merge-ready". Broad PRs can score high because they overlap many files and may block other work, but they still need normal review and validation.
|
||||
|
||||
Dirty, blocked, conflicting, and unknown merge states are shown as risk/caution reasons. They do not add importance points by themselves.
|
||||
|
||||
## Design note: intentional single-script layout
|
||||
|
||||
`pr_blocker_audit.py` is intentionally kept as one standalone script. The goal is to keep this maintainer/contributor workflow helper low-friction while broader repo tooling and test-suite conventions are still evolving. Splitting it into packages or modules is not ruled out, but is deferred until there is a clearer settled pattern to follow.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Some PRs may still lack changed files if GitHub file metadata calls fail or metadata-only mode is used.
|
||||
- Area classification is intentionally small and editable.
|
||||
- Title keyword matching misses semantic duplicates.
|
||||
- Heuristic scoring cannot know project strategy, reviewer availability, or hidden dependency chains.
|
||||
- Empty or missing file metadata produces a valid report but weak overlap analysis.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
python3 -m py_compile scripts/pr_blocker_audit.py tests/test_pr_blocker_audit.py
|
||||
python3 -m pytest tests/test_pr_blocker_audit.py -q
|
||||
python3 scripts/pr_blocker_audit.py --help
|
||||
git diff --check
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,105 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Security CI guide
|
||||
|
||||
This project runs a set of automated security checks on pull requests and
|
||||
selected branch pushes. This page explains what each one does, whether it can
|
||||
block a merge, and the few one-time settings you should turn on to get the full
|
||||
benefit.
|
||||
|
||||
## What runs, and why
|
||||
|
||||
Most checks live in files under `.github/workflows/`. CodeQL uses the
|
||||
checked-in advanced configuration in `.github/workflows/codeql.yml`. They run
|
||||
automatically; you do not start them.
|
||||
|
||||
| Check | What it protects against | Blocks a merge? |
|
||||
|---|---|---|
|
||||
| **Secret scan** (gitleaks) | An API key, token, or password being committed by mistake or on purpose | Yes |
|
||||
| **Workflow security** (actionlint + zizmor) | A broken or insecure automation file that could leak the repo's access token | Yes |
|
||||
| **Dependency review** | A pull request that adds a software library with a known security hole | Yes |
|
||||
| **pip-audit** | Known security holes in the Python libraries already used | No (advisory) |
|
||||
| **Container scan: hadolint** | Mistakes and insecure patterns in the `Dockerfile` | Yes |
|
||||
| **Container scan: Trivy** | Known security holes in the Docker image | No (advisory) |
|
||||
| **CodeQL** | Real bugs in the app's own code: injection, auth mistakes, path traversal | No (advisory) |
|
||||
|
||||
"Blocks a merge" means a red X appears on the pull request and, once you enable
|
||||
the setting below, the **Merge** button is disabled until it is fixed.
|
||||
|
||||
"Advisory" means it reports problems into the repository's **Security** tab so
|
||||
you can review them on your own schedule, but it never stops a merge. These are
|
||||
advisory on purpose: they often flag long-standing issues in other people's
|
||||
libraries, not something a given pull request introduced.
|
||||
|
||||
## Where results appear
|
||||
|
||||
- **Checks tab of a pull request**: the pass/fail of each check. A green tick is
|
||||
good; a red X needs attention.
|
||||
- **Security tab of the repository**: detailed findings from the advisory
|
||||
scanners (Trivy and CodeQL). This is your dashboard.
|
||||
|
||||
## If a check fails
|
||||
|
||||
- **Secret scan failed**: a real credential may have been committed. Treat it as
|
||||
leaked: rotate (regenerate) that key or token immediately, then remove it from
|
||||
the file. Do not just delete the commit; assume it was seen.
|
||||
- **Dependency review failed**: the pull request adds a library with a known
|
||||
vulnerability. Ask the contributor to use a patched version, or decline the
|
||||
change.
|
||||
- **hadolint / workflow security failed**: the contributor changed the
|
||||
`Dockerfile` or an automation file in a way the linter rejects. Ask them to
|
||||
address the message shown in the failed check.
|
||||
|
||||
## One-time settings to turn on
|
||||
|
||||
These two settings unlock the full value. You only do them once.
|
||||
|
||||
### 1. Require the blocking checks before merging
|
||||
|
||||
This makes the **Merge** button refuse to work until the gating checks pass.
|
||||
|
||||
1. Go to the repository on GitHub.
|
||||
2. Click **Settings** (top right of the repo).
|
||||
3. In the left sidebar, click **Branches**.
|
||||
4. Under **Branch protection rules**, click **Add branch ruleset** (or **Add
|
||||
rule**), and set the branch name pattern to `dev` (this is the branch all
|
||||
pull requests target; `main` is fast-forwarded at releases).
|
||||
5. Enable **Require status checks to pass before merging**.
|
||||
6. In the search box that appears, add these checks by name:
|
||||
- `Python syntax (compileall)`
|
||||
- `JS syntax (node --check)`
|
||||
- `gitleaks`
|
||||
- `actionlint`
|
||||
- `zizmor (Actions SAST)`
|
||||
- `hadolint (Dockerfile lint)`
|
||||
- `dependency-review (PR gate)`
|
||||
|
||||
The first two come from the correctness CI (`ci.yml`); the rest are this
|
||||
security suite. Leave pytest, pip-audit, Trivy, and CodeQL unchecked so they
|
||||
stay advisory.
|
||||
7. Also enable **Require a pull request before merging** and **Require review
|
||||
from Code Owners** (this uses the `.github/CODEOWNERS` file so every change
|
||||
needs your sign-off).
|
||||
8. Click **Create** / **Save changes**.
|
||||
|
||||
Note: a check name only appears in the list after it has run at least once, so
|
||||
let the workflows run on one pull request first, then add them here.
|
||||
|
||||
### 2. Turn on the Security tab features
|
||||
|
||||
1. **Settings -> Code security** (or **Code security and analysis**).
|
||||
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
||||
powers Dependency review and Dependabot.
|
||||
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
||||
4. Under **Code scanning**, keep **Default setup** disabled. CodeQL is
|
||||
configured by `.github/workflows/codeql.yml`; enabling default setup at the
|
||||
same time causes GitHub to reject uploads from the checked-in workflow.
|
||||
|
||||
## Keeping it current
|
||||
|
||||
`.github/dependabot.yml` opens small weekly pull requests to update Python and
|
||||
npm packages, the Docker base image, and the pinned automation actions
|
||||
themselves. Review and merge those like any other pull request; they keep the
|
||||
project patched without manual tracking.
|
||||
@@ -0,0 +1,749 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Odysseus Setup Guide
|
||||
|
||||
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
|
||||
|
||||
## Quick Start
|
||||
|
||||
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/odysseus-dev/odysseus/tree/main).
|
||||
|
||||
Defaults work out of the box: clone, run, then configure models/search/email
|
||||
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
||||
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
||||
|
||||
On first setup, Odysseus creates an admin account (`admin` unless
|
||||
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
|
||||
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](https://github.com/odysseus-dev/odysseus/blob/dev/CONTRIBUTING.md) for setup, testing, and pull request guidelines.
|
||||
|
||||
### Docker (recommended)
|
||||
```bash
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
cd odysseus
|
||||
cp .env.example .env # optional, but recommended for explicit defaults
|
||||
docker compose up -d --build
|
||||
```
|
||||
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
|
||||
|
||||
Open `http://localhost:7000` when the containers are healthy. Docker Compose
|
||||
binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
||||
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
||||
only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
|
||||
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
|
||||
> run natively instead — see [Apple Silicon](#apple-silicon) below.
|
||||
|
||||
### Native Linux / macOS
|
||||
```bash
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
cd odysseus
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
||||
downloads and serves. The app itself is lightweight; local model serving is the
|
||||
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
|
||||
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
### Apple Silicon
|
||||
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
||||
M-series Mac, run Odysseus natively:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
cd odysseus
|
||||
./start-macos.sh
|
||||
```
|
||||
|
||||
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
|
||||
|
||||
```bash
|
||||
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
|
||||
# then open http://<tailscale-ip>:7860
|
||||
```
|
||||
|
||||
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
|
||||
set there are picked up automatically without a command-line override each run.
|
||||
|
||||
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
|
||||
expose this port directly to the public internet. To build a clickable app wrapper:
|
||||
|
||||
```bash
|
||||
./build-macos-app.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
||||
|
||||
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
|
||||
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
|
||||
they are reachable from the host but not exposed to your LAN/public internet
|
||||
unless you opt in.
|
||||
|
||||
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
|
||||
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
|
||||
serve engines live in `./data/local` (`~/.local` in the container), so they
|
||||
survive container recreation.
|
||||
|
||||
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
|
||||
Odysseus SSH key and add the public key to the remote server's
|
||||
`~/.ssh/authorized_keys`. From the host you can also run:
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
||||
```
|
||||
|
||||
**Host Docker access (explicit opt-in).** Default Docker Compose intentionally
|
||||
does not mount `/var/run/docker.sock`. You can still connect Odysseus to
|
||||
existing Ollama, vLLM, and other OpenAI-compatible endpoints without Docker
|
||||
socket access.
|
||||
|
||||
Cookbook/local Docker-daemon management requires the opt-in overlay below. Raw
|
||||
Docker socket access is high-trust because it can effectively grant broad
|
||||
control over the host Docker daemon. Remote server Docker workflows over SSH
|
||||
remain preferred.
|
||||
|
||||
Place these values in `.env`, or export them in the shell before running
|
||||
`docker compose`:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
||||
DOCKER_GID=<host docker group gid>
|
||||
```
|
||||
|
||||
Combine host Docker access with a GPU overlay when both are intentionally
|
||||
required:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
|
||||
# or
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
|
||||
```
|
||||
|
||||
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
||||
only detect GPUs that Docker exposes to the container — if the host runtime or
|
||||
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
||||
CPU instead of your intended GPU.
|
||||
|
||||
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
|
||||
optionally install the host runtime or update `.env`.
|
||||
|
||||
```bash
|
||||
# Read-only diagnostic (default — installs nothing, never edits .env):
|
||||
scripts/check-docker-gpu.sh
|
||||
|
||||
# Print OS-specific install commands without running them:
|
||||
scripts/check-docker-gpu.sh --print-install-commands
|
||||
|
||||
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
|
||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit
|
||||
|
||||
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
|
||||
scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
||||
|
||||
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
||||
```
|
||||
#### Arch Linux NVIDIA Docker notes
|
||||
|
||||
On Arch Linux, verify the host NVIDIA driver and Docker GPU passthrough before enabling the Odysseus NVIDIA overlay.
|
||||
|
||||
Install the required packages:
|
||||
|
||||
```bash
|
||||
sudo pacman -Syu
|
||||
sudo pacman -S docker docker-compose nvidia-container-toolkit nvidia-utils
|
||||
sudo systemctl enable --now docker
|
||||
```
|
||||
|
||||
Configure Docker to use the NVIDIA container runtime:
|
||||
|
||||
```bash
|
||||
sudo nvidia-ctk runtime configure --runtime=docker
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Verify the host GPU:
|
||||
|
||||
```bash
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
Verify Docker GPU passthrough:
|
||||
|
||||
```bash
|
||||
docker run --rm --gpus all nvidia/cuda:12.9.0-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
Then enable the Odysseus NVIDIA compose overlay:
|
||||
|
||||
```env
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||
```
|
||||
|
||||
Rebuild and verify the GPU inside the Odysseus container:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose exec odysseus nvidia-smi -L
|
||||
```
|
||||
|
||||
For first-time local model testing on 8 GB laptop GPUs, start with GGUF/Q4 models on llama.cpp before trying GPTQ/AWQ models on vLLM or SGLang. This keeps the first run simpler while confirming GPU passthrough works.
|
||||
|
||||
**WSL2 + snap Docker.** If the NVIDIA check fails with this error, Docker may be
|
||||
installed via snap:
|
||||
|
||||
```text
|
||||
failed to fulfil mount request: open /usr/lib/wsl/lib/libdxcore.so: no such file or directory
|
||||
```
|
||||
|
||||
Check with `snap list docker` or:
|
||||
|
||||
<!-- {% raw %} -->
|
||||
```bash
|
||||
docker info --format '{{.DockerRootDir}}'
|
||||
```
|
||||
<!-- {% endraw %} -->
|
||||
|
||||
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
|
||||
exist on the host. Reinstalling or reconfiguring `nvidia-container-toolkit` will
|
||||
not fix that. Remove snap Docker, install the official apt-based Docker Engine
|
||||
([Docker docs](https://docs.docker.com/engine/install/ubuntu/)), then configure
|
||||
the NVIDIA runtime again:
|
||||
|
||||
```bash
|
||||
sudo snap remove docker
|
||||
sudo nvidia-ctk runtime configure --runtime=docker
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Then re-run `scripts/check-docker-gpu.sh`.
|
||||
|
||||
Safety notes:
|
||||
- The app never installs host GPU runtime automatically.
|
||||
- The app never edits `.env` automatically.
|
||||
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
|
||||
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
|
||||
bypass the passthrough gate.
|
||||
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
|
||||
Git and the Docker build context.
|
||||
|
||||
To enable manually without the script, add this to `.env`:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||
```
|
||||
|
||||
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
|
||||
|
||||
```bash
|
||||
scripts/check-docker-amd-gpu.sh
|
||||
```
|
||||
|
||||
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
|
||||
numeric render group id:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
|
||||
RENDER_GID=989
|
||||
```
|
||||
|
||||
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
|
||||
|
||||
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
|
||||
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
|
||||
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
|
||||
overlay workflow above. For stack UIs, point the stack at one of the standalone
|
||||
files instead, which bundle the base stack plus the GPU settings:
|
||||
|
||||
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
|
||||
on the host.
|
||||
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
|
||||
`video`/`render` group membership, and `RENDER_GID` when needed.
|
||||
|
||||
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
|
||||
source of truth; the standalone files mirror them for single-file deployments.
|
||||
|
||||
Verify after enabling either overlay:
|
||||
|
||||
```bash
|
||||
docker compose exec odysseus nvidia-smi -L # NVIDIA
|
||||
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
|
||||
```
|
||||
|
||||
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
|
||||
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
|
||||
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
|
||||
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
|
||||
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
|
||||
> not a Docker passthrough failure. Reinstall the serve engine via
|
||||
> **Cookbook → Dependencies** to get a CUDA-enabled build.
|
||||
>
|
||||
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
|
||||
> the container confirms device passthrough, not ROCm userspace or a
|
||||
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
|
||||
> inside the slim Odysseus image.
|
||||
|
||||
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
|
||||
Settings:
|
||||
|
||||
```text
|
||||
http://host.docker.internal:11434/v1
|
||||
```
|
||||
|
||||
Ollama must listen outside its own loopback interface:
|
||||
|
||||
```bash
|
||||
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
||||
```
|
||||
|
||||
This connects Odysseus in Docker to an Ollama server that is already running on
|
||||
your host machine; it does not start Ollama inside the container.
|
||||
`host.docker.internal` is Docker's hostname for the host machine from inside the
|
||||
container. Cookbook **Serve** is a separate workflow for serving downloaded
|
||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||
install usually only need to add the endpoint in Settings.
|
||||
|
||||
**Tool calls not firing on a manually-added Ollama `/v1` endpoint.** By
|
||||
design, a local Ollama `/v1` endpoint defaults to the conservative
|
||||
text-based (fenced-block) tool-calling path rather than native structured
|
||||
tool calls, since some locally-served models mishandle native schemas (see
|
||||
#1567). This is correct for most local setups, but if you know your specific
|
||||
model reliably supports native tool calling (check `ollama show <model>` for
|
||||
`tools` under Capabilities), you can opt that endpoint in explicitly. There
|
||||
is currently no UI control for this on manually-added endpoints (see #5192);
|
||||
the flag can still be set directly against the existing API, from a browser
|
||||
console on an authenticated admin session:
|
||||
|
||||
```js
|
||||
fetch('/api/model-endpoints/<endpoint-id>', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({supports_tools: true})
|
||||
}).then(r => r.json()).then(console.log)
|
||||
```
|
||||
|
||||
Find `<endpoint-id>` by inspecting the `/api/model-endpoints` response (or
|
||||
your browser's network tab while Settings loads the endpoint list). Send
|
||||
`supports_tools: false` to disable native structured tool calls and force the
|
||||
conservative fenced/text path, or `supports_tools: null` to return the endpoint
|
||||
to the Auto heuristic.
|
||||
|
||||
**Useful checks.**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=120 odysseus
|
||||
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
|
||||
```
|
||||
|
||||
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
|
||||
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
|
||||
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
|
||||
do not run on macOS. MLX-only models are not served by Odysseus.
|
||||
|
||||
</details>
|
||||
|
||||
### Native Windows
|
||||
|
||||
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
|
||||
server; safe to re-run):
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
cd odysseus
|
||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
||||
```
|
||||
|
||||
Or do it by hand:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
cd odysseus
|
||||
py -3.11 -m venv venv
|
||||
venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
|
||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||
3.11+ version) for the venv step.
|
||||
|
||||
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
|
||||
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
|
||||
alone leaves the native Windows server on loopback. Pass the launcher's
|
||||
`-BindHost` flag instead:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
|
||||
```
|
||||
|
||||
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
|
||||
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
|
||||
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
|
||||
|
||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||
model downloads and the agent shell tool, also install
|
||||
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
|
||||
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
|
||||
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
|
||||
`http://localhost:11434/v1` in Settings.
|
||||
|
||||
Open `http://localhost:7000`, log in with the generated admin password,
|
||||
and configure everything else inside **Settings**.
|
||||
|
||||
## Troubleshooting & Advanced Setup
|
||||
|
||||
### `chromadb-client` conflicts with embedded ChromaDB
|
||||
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
|
||||
|
||||
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
|
||||
```bash
|
||||
./venv/bin/pip uninstall chromadb-client -y
|
||||
./venv/bin/pip install --force-reinstall chromadb
|
||||
```
|
||||
|
||||
### HTTPS + LAN/Tailscale exposure
|
||||
To expose Odysseus on a local network or Tailscale with HTTPS:
|
||||
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
|
||||
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
|
||||
```bash
|
||||
mkcert -install
|
||||
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
|
||||
```
|
||||
3. Run `uvicorn` with the generated certs:
|
||||
```bash
|
||||
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
||||
```
|
||||
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
||||
|
||||
### Common self-host traps (30-second fixes)
|
||||
A grab-bag of small gotchas that otherwise turn into long debugging sessions.
|
||||
|
||||
- **`AUTH_ENABLED=false` is ignored / you're still forced to log in (Windows).** If you edited `.env` in Notepad it may have saved a UTF-8 **BOM**, turning the first key into `AUTH_ENABLED` so it is never matched. Odysseus loads `.env` with `encoding="utf-8-sig"` to tolerate a leading BOM, but the safe fix is to re-save `.env` as **UTF-8 without BOM** (VS Code: *Save with Encoding → UTF-8*).
|
||||
- **macOS: the app isn't at `http://localhost:7000`.** macOS AirPlay Receiver usually holds port `7000`, so the macOS start script serves on **`7860`** instead — open `http://localhost:7860`. To use `7000`, free it (System Settings → General → AirDrop & Handoff → turn off *AirPlay Receiver*) and set `APP_PORT=7000`.
|
||||
- **Copy buttons do nothing over a plain-HTTP Tailscale/LAN URL.** Browsers only expose the clipboard API (`navigator.clipboard`) on **secure origins** — HTTPS, or `localhost`. Over `http://100.x.y.z:7860` it is blocked. Serve over HTTPS (see *HTTPS + LAN/Tailscale exposure* above); `localhost` is exempt, so copy still works on the host itself.
|
||||
- **Self-hosted ntfy reminders don't reach your phone.** Two things: (1) the bundled ntfy binds to loopback by default — to reach it from your phone set `NTFY_BIND` to your host/Tailscale IP and `NTFY_BASE_URL` to the same server URL in `.env`, then recreate the ntfy container (see the `NTFY_*` block in `.env.example`); (2) in the ntfy **Android** app, subscribe to the topic with **Instant delivery** enabled — non-`ntfy.sh` servers don't get instant push otherwise.
|
||||
- **Local mail (Dovecot) login fails: "Plaintext authentication disallowed on non-encrypted connections."** Your IMAP/SMTP server is refusing cleartext auth over an unencrypted link. Prefer enabling TLS on the mail server; on a trusted LAN only, you can allow cleartext (Dovecot: `disable_plaintext_auth = no`).
|
||||
- **Calendar/contacts (Radicale) won't sync.** Point Odysseus at the **full collection URL** with its trailing slash — e.g. `http://host:5232/<user>/<collection-id>/` — not just the server root. Radicale shows this address for each calendar/address book in its web UI.
|
||||
|
||||
### Optional Dependencies
|
||||
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
||||
|
||||
| Package | Feature unlocked |
|
||||
|---------|-----------------|
|
||||
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
||||
| `kokoro`, `soundfile` | Local Kokoro-82M text-to-speech on a CUDA GPU. The pinned Kokoro release supports Odysseus installs on Python 3.11-3.12; these packages are intentionally skipped on Python 3.13+ (including the Python 3.14 container image). |
|
||||
| `ddgs` | DuckDuckGo as a search provider option. |
|
||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||
|
||||
Install the optional set only when you need these features:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-optional.txt
|
||||
```
|
||||
|
||||
The default Docker image currently uses Python 3.14, while Kokoro 0.9.4 declares Python `>=3.10,<3.13`. Odysseus itself continues to support Python 3.11+, but this pinned optional local-TTS feature requires a native Python 3.11 or 3.12 environment. Kokoro declares `torch`, but the local provider only activates when that torch build has CUDA and a GPU is visible; install the CUDA build appropriate for your host. Browser and configured endpoint TTS remain available on Python 3.13+ and in the container image.
|
||||
|
||||
### Faster, reproducible installs with uv (optional)
|
||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||
|
||||
```bash
|
||||
uv venv venv --python 3.13
|
||||
uv pip install -r requirements.txt
|
||||
# then continue as usual: python setup.py, uvicorn, ...
|
||||
```
|
||||
|
||||
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
|
||||
|
||||
```bash
|
||||
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
|
||||
uv pip sync requirements.lock # reproduce it exactly later
|
||||
```
|
||||
|
||||
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
|
||||
|
||||
### 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 [the Outlook email guide](email-outlook.md) for the
|
||||
current limitation and the planned integration direction.
|
||||
|
||||
## Security Notes
|
||||
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
||||
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
||||
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
|
||||
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
|
||||
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
|
||||
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
||||
|
||||
> **Upgrading an existing install:** `SECURE_COOKIES` used to default to
|
||||
> `false`, so an install set up before scheme derivation may still carry
|
||||
> `SECURE_COOKIES=false` in its own `.env`. That explicit value stays
|
||||
> authoritative, so HTTPS logins keep getting a non-`Secure` session cookie.
|
||||
> Pulling this change updates the tracked Compose files, but nothing rewrites
|
||||
> your `.env` — drop the line from it unless you deliberately serve plain HTTP
|
||||
> alongside HTTPS and want the escape hatch.
|
||||
|
||||
### Private or proxied deployments
|
||||
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
||||
|
||||
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
|
||||
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
|
||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||
4. Keep raw service and model ports internal-only.
|
||||
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
#### Faster over the network: HTTP/2
|
||||
|
||||
The frontend is raw ES modules with no bundler, so a page load is a few hundred
|
||||
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
|
||||
number of concurrent connections per host (commonly around six), so many of
|
||||
those requests are serialized across multiple round trips. On localhost that
|
||||
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
|
||||
part of load time, especially as latency increases.
|
||||
|
||||
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
|
||||
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
|
||||
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
|
||||
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
|
||||
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
|
||||
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
|
||||
HTTPS but not HTTP/2 — uvicorn does not speak it.
|
||||
|
||||
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
|
||||
for your platform; on macOS, `brew install caddy`.
|
||||
|
||||
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
|
||||
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
|
||||
uses `7860`.
|
||||
|
||||
Public domain, Caddy obtains and renews the certificate itself:
|
||||
|
||||
```
|
||||
odysseus.example.com {
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
|
||||
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
|
||||
|
||||
```bash
|
||||
tailscale cert myhost.tailnet-name.ts.net
|
||||
```
|
||||
|
||||
```
|
||||
myhost.tailnet-name.ts.net {
|
||||
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
LAN with your own certificate — same shape, your own files:
|
||||
|
||||
```
|
||||
odysseus.lan {
|
||||
tls /path/to/cert.pem /path/to/key.pem
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
Give `tls` absolute paths: a service starts in a working directory you did not
|
||||
choose. If port 443 is already taken, append a port to the site address
|
||||
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
|
||||
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
|
||||
start with `listen tcp :80: bind: address already in use` if something else
|
||||
holds it. Turn the redirect off with a global block at the top of the file:
|
||||
|
||||
```
|
||||
{
|
||||
auto_https disable_redirects
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run it in the foreground first:**
|
||||
|
||||
```bash
|
||||
caddy run --config ./Caddyfile
|
||||
```
|
||||
|
||||
Once that works, run it as a service:
|
||||
|
||||
```bash
|
||||
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
|
||||
sudo systemctl enable --now caddy # Linux, if your package installed the unit
|
||||
```
|
||||
|
||||
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
||||
run the proxy as another container, or on the host pointing at the published
|
||||
port.
|
||||
|
||||
**4. Point Odysseus at the new origin** in `.env`, then restart it.
|
||||
|
||||
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
|
||||
|
||||
```bash
|
||||
# only if the proxy cannot expose the external HTTPS scheme to Odysseus:
|
||||
SECURE_COOKIES=true
|
||||
# only if you use remote MCP servers with OAuth:
|
||||
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
||||
```
|
||||
|
||||
Gmail OAuth needs nothing here when the proxy runs on the same host: the
|
||||
redirect URI is built from the incoming request, and uvicorn rewrites the
|
||||
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
|
||||
`127.0.0.1`. A proxy in a separate container or on another machine is not
|
||||
trusted, so pin the URI there:
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
|
||||
```
|
||||
|
||||
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
|
||||
environment uvicorn starts with — `.env` is read by the app afterwards, too
|
||||
late for it to take effect.)
|
||||
|
||||
**5. Confirm HTTP/2 is really on:**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
|
||||
# 2
|
||||
```
|
||||
|
||||
The status code is not the thing to check here — a logged-out request redirects
|
||||
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
|
||||
the part that matters. The browser reports the same in the Network panel's
|
||||
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
|
||||
enable it by right-clicking the column headers.
|
||||
|
||||
Three things bite when moving an existing install behind TLS:
|
||||
|
||||
- Leave `SECURE_COOKIES` unset when Odysseus can see the external HTTPS scheme;
|
||||
the cookie then follows the request automatically. If your proxy cannot expose
|
||||
that scheme, set `SECURE_COOKIES=true` **at the same time** you stop serving
|
||||
plain HTTP, not before. An explicit `true` applies to every login, so while an
|
||||
HTTP entrypoint is still reachable the browser will reject the `Secure` cookie
|
||||
there and login will appear to loop.
|
||||
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
||||
Gmail redirect URI it cannot be derived from a request — it is registered
|
||||
with each MCP authorization server up front — so set it to the external
|
||||
origin if you use remote MCP servers over OAuth.
|
||||
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
|
||||
https`. HSTS applies to the whole hostname and ignores the port, so any other
|
||||
plain-HTTP service on that same hostname becomes unreachable in browsers that
|
||||
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
|
||||
the proxy (`header_down -Strict-Transport-Security` in Caddy).
|
||||
|
||||
Server-sent events are not buffered by this configuration, so chat streaming
|
||||
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
|
||||
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
|
||||
for the same reason.
|
||||
|
||||
Changing the external origin also affects state scoped to it. Service workers
|
||||
and their caches are origin-scoped, so moving to a different origin starts with
|
||||
a cold load. Cookies follow their own domain/path/security rules rather than
|
||||
being port-scoped: changing the hostname normally requires a new login, while
|
||||
changing only the scheme or port does not by itself guarantee that existing
|
||||
cookies disappear.
|
||||
|
||||
Common internal-only ports from the default docs/compose setup:
|
||||
|
||||
| Port | Service |
|
||||
|---|---|
|
||||
| `7000` | Odysseus raw app port |
|
||||
| `8080` | SearXNG |
|
||||
| `8091` | ntfy |
|
||||
| `8100` | ChromaDB host port for manual/compose access |
|
||||
| `11434` | Ollama |
|
||||
| `8000-8020` | Common local model/provider APIs |
|
||||
|
||||
## Configuration
|
||||
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
|
||||
for deployment-level defaults and secrets you want present before first boot.
|
||||
Key settings:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
|
||||
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
|
||||
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
|
||||
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
|
||||
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
||||
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||
| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. |
|
||||
| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. |
|
||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||
| `SECURE_COOKIES` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
||||
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
|
||||
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
|
||||
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
|
||||
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
|
||||
|
||||
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
|
||||
|
||||
### Built-in MCP servers (optional setup)
|
||||
|
||||
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
|
||||
|
||||
To enable the browser MCP (page navigation, screenshots, vision), run once:
|
||||
|
||||
```bash
|
||||
npx -y @playwright/mcp@latest --version
|
||||
```
|
||||
|
||||
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
app.py # FastAPI entry point
|
||||
core/ auth, database, middleware, constants
|
||||
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)
|
||||
website/ landing page (index.html) + preview clips
|
||||
```
|
||||
|
||||
## Data
|
||||
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||
|
||||
To back up or restore everything in `data/`, see the
|
||||
[Backup & Restore guide](backup-restore.md).
|
||||
@@ -0,0 +1,220 @@
|
||||
# SFT Expansion Launch Ledger
|
||||
|
||||
## Immutable Inputs
|
||||
|
||||
- Approved seed manifest: `tmp/sft_expansion_20260830/frozen_v1/seed_manifest.json`
|
||||
- Approved seed turns: `tmp/sft_expansion_20260830/frozen_v1/approved_trace.jsonl`
|
||||
- Environment inventory: `tmp/sft_expansion_20260830/environment_inventories.json`
|
||||
- Seed baseline: 561 sessions, 1,372 approved turns
|
||||
- Expansion selection: `tmp/sft_expansion_20260830/selected_200_manifest.json`
|
||||
- Selection size: 50 owner-bound families, projected to 200 environment cases
|
||||
|
||||
## Acceptance Policy
|
||||
|
||||
1. Generate only from inventory facts and marker-scoped reversible mutations.
|
||||
2. Reject unsupported tools, temporal contradictions, vague calendar mutation times, and compound mutation-plus-verification prompts.
|
||||
3. Run each turn through the real 7011 agent surface with Kimi K3.
|
||||
4. Require the expected tool and manager action, successful tool output, a nonempty answer, and no internal narration or false-unavailability claim.
|
||||
5. Restore owner state after every case and delete mechanically failed sessions.
|
||||
6. Review every mechanically passing session with DeepSeek.
|
||||
7. Retain only `keep` verdicts scoring at least 80. Delete all other sessions and trace rows; repair by regenerating and replaying, never by inventing missing tool evidence.
|
||||
8. Preserve the source seed family's train/validation/test split.
|
||||
|
||||
## Pilot Record
|
||||
|
||||
- Harness regression fixed: contextual calendar entries no longer route to `manage_tasks`.
|
||||
- Harness regression fixed: contextual calendar reads require fresh `manage_calendar` evidence.
|
||||
- Runner regression fixed: a manager tool name alone no longer passes; create/list/update/delete actions are checked.
|
||||
- Generator regression fixed: calendar mutations require an exact time or `ask_user`.
|
||||
- Unsafe global skill-directory rollback replaced with marker-scoped cleanup.
|
||||
- Omar calendar lifecycle: deterministic pass, DeepSeek `keep`, 96/100, retained.
|
||||
- Ambiguous Omar predecessor: DeepSeek `repair`, 60/100, deleted from app and trace.
|
||||
- Maya ambiguous-date lifecycle: deterministic reject, deleted before review.
|
||||
|
||||
## Current Corpus
|
||||
|
||||
- Build: `tmp/sft_expansion_20260830/corpus_v2`
|
||||
- Train: 994 turns
|
||||
- Validation: 157 turns
|
||||
- Test: 135 turns
|
||||
- Sessions: 562
|
||||
- Retained expansion sessions: 1
|
||||
- Seed-family leakage: 0
|
||||
|
||||
## Clean Source Baseline
|
||||
|
||||
- Immutable source remains unchanged: `tmp/sft_expansion_20260830/frozen_v1/approved_trace.jsonl`
|
||||
- Deterministic hygiene input: 561 sessions, 1,372 turns
|
||||
- Deterministic hygiene result: 421 sessions, 859 turns retained; 140 sessions rejected
|
||||
- Full DeepSeek semantic review: 336 keep, 77 repair, 8 delete
|
||||
- Kimi repair result: 73 validated repaired sessions; 4 additional sessions excluded
|
||||
- Clean derivative: `tmp/sft_expansion_20260830/clean_v2/approved_trace.jsonl`
|
||||
- Clean derivative size: 409 sessions, 834 turns
|
||||
- Post-repair deterministic recheck: 409/409 sessions pass
|
||||
- Style contract: `docs/sft-style-contract.md`
|
||||
- Assembly report: `tmp/sft_expansion_20260830/clean_v2/assembly_report.json`
|
||||
- Semantic verdicts: `tmp/sft_expansion_20260830/clean_v1/deepseek_verdicts.jsonl`
|
||||
|
||||
The clean source does not yet support the intended balanced expansion by itself.
|
||||
Notes has one clean source session, tasks has two, and session-management tools
|
||||
are absent. Add and review natural live workflows for those domains before the
|
||||
200-case cross-environment run.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/generate_sft_environment_expansion.py \
|
||||
--seed-manifest tmp/sft_expansion_20260830/selected_200_manifest.json \
|
||||
--inventories tmp/sft_expansion_20260830/environment_inventories.json \
|
||||
--out tmp/sft_expansion_20260830/generated_200_cases.json \
|
||||
--workers 4 --timeout 180 --retries 1
|
||||
|
||||
.venv/bin/python scripts/run_sft_environment_expansion.py \
|
||||
--cases tmp/sft_expansion_20260830/generated_200_cases.json \
|
||||
--out tmp/sft_expansion_20260830/generated_200_run.json
|
||||
|
||||
.venv/bin/python scripts/review_sft_environment_expansion.py \
|
||||
--run tmp/sft_expansion_20260830/generated_200_run.json \
|
||||
--out tmp/sft_expansion_20260830/generated_200_review.json
|
||||
|
||||
.venv/bin/python scripts/build_sft_expansion_splits.py \
|
||||
--manifest tmp/sft_expansion_20260830/frozen_v1/seed_manifest.json \
|
||||
--approved-trace tmp/sft_expansion_20260830/frozen_v1/approved_trace.jsonl \
|
||||
--review tmp/sft_expansion_20260830/pilot_atomic_exact_v1.review.json \
|
||||
--review tmp/sft_expansion_20260830/generated_200_review.json \
|
||||
--out-dir tmp/sft_expansion_20260830/corpus_expanded
|
||||
```
|
||||
# All-Tools No-Thinking Validation Update
|
||||
|
||||
## Matched-control finding
|
||||
|
||||
The previously reported email LoRA speed advantage does not reproduce when the
|
||||
raw base model and LoRA are served in the same vLLM process with the same GPU,
|
||||
tool payload, prompt, cache settings, and `enable_thinking=false`.
|
||||
|
||||
- Original 24-case email suite, 14 compact tools:
|
||||
- raw Qwen3.5-9B base: 23/24, 0.914s average
|
||||
- all-tools iteration-2 LoRA: 23/24, 0.874s average
|
||||
- Earlier base result of 9.655s was therefore confounded by serving conditions.
|
||||
- The validated latency win is **thinking off + compact schemas**. LoRA has not
|
||||
yet shown a material independent speed gain.
|
||||
|
||||
## All-tools iterations
|
||||
|
||||
- Iteration 1: 365 train sessions, 40 validation sessions, 92 steps, LR 5e-7,
|
||||
one epoch. Eval loss 0.8364.
|
||||
- Iteration 2: same corpus, 184 steps, LR 2e-6, two epochs. Eval loss 0.7433.
|
||||
- Matched 40-case held-out routing result for base, v67, and iteration 2 was
|
||||
effectively identical: 85% exact tool selection, 85% required-field
|
||||
presence, 70% constrained required-argument accuracy, and no reasoning
|
||||
leakage.
|
||||
- Iteration 2 adapter weights differ from v67 (relative L2 delta 1.02%), so the
|
||||
tie is not caused by an unchanged adapter file.
|
||||
|
||||
## Decision
|
||||
|
||||
Do not promote iteration 1 or 2 as a speed improvement. Before another train:
|
||||
|
||||
1. Build a larger, balanced, genuinely unseen eval across sparse tool families.
|
||||
2. Expand sparse training families with multi-turn, context-dependent traces.
|
||||
3. Evaluate whether LoRA preserves accuracy under more aggressive schema
|
||||
pruning than base; that is the plausible route to an indirect latency win.
|
||||
4. Keep no-thinking routing and compact schema selection as harness features,
|
||||
independent of model promotion.
|
||||
|
||||
## Critical Qwen3.5 LoRA Serving Failure
|
||||
|
||||
### Symptom
|
||||
|
||||
vLLM 0.22 accepted `--enable-lora`, listed each adapter under `/v1/models`, and
|
||||
logged `Loaded new LoRA adapter`, but Qwen3.5 text-tool adapters were not applied
|
||||
to inference. Base, v67, iteration 3, and iteration 4 produced byte-identical tool
|
||||
calls across 177 cases. A direct log-probability probe also returned numerically
|
||||
identical values for base and every dynamic adapter.
|
||||
|
||||
Do **not** treat model registration, successful HTTP responses, different adapter
|
||||
files, or latency differences as evidence that a LoRA is active.
|
||||
|
||||
### Minimal activation check
|
||||
|
||||
Before any benchmark, send the same deterministic request to base and adapter
|
||||
models with `temperature=0`, `logprobs=true`, and thinking disabled. Compare the
|
||||
token log-probabilities, not only generated text. Identical probabilities across
|
||||
several prompts mean the adapter path is inactive.
|
||||
|
||||
Observed inactive result:
|
||||
|
||||
```text
|
||||
base: cob -0.0484729, alt -0.0000684
|
||||
v67: cob -0.0484729, alt -0.0000684
|
||||
iteration4: cob -0.0484729, alt -0.0000684
|
||||
```
|
||||
|
||||
Disabling prefix caching and enabling eager execution did not fix this.
|
||||
|
||||
### Merge trap
|
||||
|
||||
The tool adapters were trained with `AutoModelForCausalLM`, which gives Qwen3.5
|
||||
text keys under:
|
||||
|
||||
```text
|
||||
base_model.model.model.layers.*
|
||||
```
|
||||
|
||||
The old merge script auto-selected `AutoModelForImageTextToText`, whose language
|
||||
tower expects:
|
||||
|
||||
```text
|
||||
base_model.model.model.language_model.layers.*
|
||||
```
|
||||
|
||||
PEFT warned about missing adapter keys, then still wrote a model. That output was
|
||||
effectively unmodified. A successful `save_pretrained` is therefore not proof of
|
||||
a successful merge. Treat any missing-adapter-key warning as a hard failure.
|
||||
|
||||
Merging with the causal loader applied the adapter, but produced a
|
||||
`Qwen3_5TextConfig` model that vLLM's production Qwen3.5 multimodal loader refused.
|
||||
|
||||
### Working merge path
|
||||
|
||||
`scripts/merge_hf_lora.py` now supports the production-safe bridge:
|
||||
|
||||
1. Load the full Qwen3.5 model with the image/multimodal loader.
|
||||
2. Remap causal adapter keys from `model.layers` to
|
||||
`model.language_model.layers`.
|
||||
3. Exclude the visual tower from PEFT target matching.
|
||||
4. Merge and save the full model plus tokenizer and processor metadata.
|
||||
|
||||
```bash
|
||||
python scripts/merge_hf_lora.py \
|
||||
--model-loader image \
|
||||
--remap-qwen35-causal-adapter \
|
||||
--base /mnt/HADES/models/Qwen3.5-9B \
|
||||
--adapter /path/to/final_adapter \
|
||||
--output /path/to/merged-full-bf16
|
||||
```
|
||||
|
||||
Verify there are no missing adapter keys, serve the merged model as a standalone
|
||||
model, and repeat the log-probability activation check.
|
||||
|
||||
Observed active merged result:
|
||||
|
||||
```text
|
||||
v67 merged: cob -0.471937001
|
||||
iteration4 merged: cob -0.319356978
|
||||
```
|
||||
|
||||
### Corrected valid benchmark
|
||||
|
||||
The first valid merged-model all-tools benchmark showed:
|
||||
|
||||
- v67 merged: 96.05% tool accuracy, 93.79% constrained argument accuracy,
|
||||
2.669s average latency, zero reasoning rows.
|
||||
- iteration 4 merged: 96.05% tool accuracy, 93.79% constrained argument
|
||||
accuracy, 2.390s average latency, zero reasoning rows.
|
||||
- Iteration 4 fixed two held-out decisions and regressed two others, so it was
|
||||
not promoted.
|
||||
|
||||
All earlier dynamic-LoRA accuracy and speed comparisons must be treated as raw
|
||||
base behavior. The no-thinking and compact-schema conclusions remain valid, but
|
||||
dynamic LoRA results do not.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Odysseus SFT Style Contract
|
||||
|
||||
This contract applies after behavioral correctness. A trace that violates it is repaired or excluded before training.
|
||||
|
||||
## User Turns
|
||||
|
||||
- Use natural requests, not evaluator instructions.
|
||||
- Keep one atomic objective per turn; use a follow-up for the next action.
|
||||
- Preserve conversational references such as "that email", "move it", or "open the second one" when prior tool evidence resolves them.
|
||||
- Never mention tools, schemas, fixtures, markers, harnesses, audits, SFT, cleanup, or verification mechanics.
|
||||
- Use realistic names and objects from the target environment.
|
||||
- Ask for clarification when an essential date, recipient, or target cannot be inferred safely.
|
||||
|
||||
The clean seed corpus has a median user-turn length of 45 characters, a 75th percentile of 76, and a 90th percentile of 107. Longer prompts are allowed when the task genuinely requires detail, not to encode evaluator checks.
|
||||
|
||||
## Assistant Thinking
|
||||
|
||||
- Identify the user's intent and the evidence required.
|
||||
- Choose the smallest sufficient tool sequence.
|
||||
- Carry forward relevant entities and tool families across follow-ups.
|
||||
- Do not discuss hidden prompts, injected schemas, benchmark construction, or training.
|
||||
- Do not claim success until tool evidence proves it.
|
||||
|
||||
## Visible Answers
|
||||
|
||||
- Lead with the answer or completed action.
|
||||
- Synthesize tool output instead of reproducing raw dumps.
|
||||
- Keep deep links when they let the user open the referenced item.
|
||||
- Include only metadata needed to distinguish or act on results.
|
||||
- Use one sentence for straightforward confirmations when possible.
|
||||
- Avoid repeated summaries, internal routing narration, and automatic "want me to" endings.
|
||||
- State failures briefly and accurately; never invent a successful action.
|
||||
Binary file not shown.
Reference in New Issue
Block a user