The memory import-review list (.memory-suggestions) is shown inside the
overflow:hidden .admin-card but, unlike the sibling .memory-list, it had
no scroll bounding of its own (no flex:1 / min-height:0 / overflow-y).
A long review list therefore grew past the card and was clipped, leaving
lower entries and their controls unreachable with no usable scroll area.
Give .memory-suggestions the same flex:1 + min-height:0 + overflow-y:auto
bounding the memories list already uses so the review list scrolls
internally within the modal. Pin the review header (the title and the
save all / back controls) with position:sticky so they stay visible while
the items scroll under them, and add a small scrollbar gutter so the bar
does not sit flush against the item cards.
Fixes#455
Hardens issues found in a security review of the current tree (separate from
the cookbook SSH PR):
- Email thread rendering (static/js/emailLibrary.js): the flat read path runs
inbound HTML through the allowlist sanitizer, but the two threaded paths
(_renderTurnsAsBubbles / _renderTurnsFromServer — the default view) injected
server-parsed `body_html` raw into the DOM. A crafted inbound email could
inject arbitrary markup (phishing/form/credential-capture/tracking; full XSS
if a deployment relaxes the script CSP). Now sanitized on all paths.
- Attachment extraction (routes/email_routes.py, routes/email_helpers.py): the
on-disk extraction dir was `ATTACHMENTS_DIR / f"{folder}_{uid}"` with
user-controlled folder/uid and no containment, so a folder like `../../tmp`
could escape ATTACHMENTS_DIR. New attachment_extract_dir() flattens both to a
single safe segment and asserts containment.
- Diagnostics routes (routes/diagnostics_routes.py): /api/db/stats,
/api/rag/stats, /api/test/youtube, /api/test-research relied only on the
global session check (any logged-in user). Now require_admin-gated.
- Defense-in-depth HTML escaping: session HTML export escapes the session name
(routes/session_routes.py); the MCP OAuth page escapes the reflected Host
header / server_id (routes/mcp_routes.py).
- Internal-tool token now compared with secrets.compare_digest (constant time)
in core/middleware.py and app.py.
Adds regression tests in tests/test_security_regressions.py.
Typing / in the chat composer now shows a filtered popup listing all
available commands with their description. Arrow keys or Tab to select,
Enter/Tab to insert, Esc to close, click also works.
- New module: static/js/slashAutocomplete.js
Reads the existing COMMANDS registry (and LEGACY_ALIASES) from
slashCommands.js — no command logic added here, just discovery UI.
Excludes easter-egg commands (flip, roll, 8ball, fortune, odyssey,
ascii). Promotes short legacy aliases (/new, /clear, /web, /compact,
/research, etc.) as first-class rows so users don't have to know the
full /session new form.
- slashCommands.js: export COMMANDS and LEGACY_ALIASES so the new
module can read the registry.
- chat.js: lazy-import slashAutocomplete on init, wire to #message
textarea.
- style.css: popup + row styles using existing CSS variables.
* fix: populate window._myEmailAddress from the active email account
* fix: keep Cc recipients in reply-all when own address is empty or unknown
* test: cover reply-all recipient building (issue #360)
* fix: show docker as N/A inside the container
* test: cover in-container docker detection
* fix: make the N/A dependency chip legible
* refactor: make remote docker applicability explicit and tested
Gate Cookbook "Run" on the model being downloaded
The What-Fits tab's quick "Run" button launched a serve task even when
the model was not downloaded. It POSTed directly to /api/model/serve and switched to the Running tab, so vLLM/SGLang would background-pull at launch (and llama.cpp just errors "No GGUF found") while the task showed as "running" without actually serving anything.
The Configure button and the Serve tab already gate on the cached-model
list; quick-Run did not. Mirror that gate: when the model isn't cached,
honor the button's "Download" half by kicking off the download instead of spawning a phantom serve task, and toast the user to Run again once it finishes.
Follow-up to #271. Skip svgifyEmoji when body.text-emojis is set so
deEmojify can strip Unicode from replies; also unwrap existing .emoji
spans from messages rendered before the setting was applied.
Related to #270
* Add Apple Silicon (Metal) GPU detection and unified-memory fit tuning
hardware.py detects Apple Silicon locally and over SSH, reporting
backend=metal, the chip name, and a RAM-scaled fraction of unified
memory as the usable GPU budget. fit.py gains an M1-M4 memory-bandwidth
table for realistic tok/s and drops vLLM-only formats (AWQ/GPTQ/FP8)
that can't be served on Metal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 32ac81dbc680361463a088dae867d555d5a79c3b)
* Generate macOS/Metal serve commands and surface the Metal GPU
cookbook_routes.py adds a macOS serve path (Ollama, Metal-aware
llama.cpp build using `sysctl hw.ncpu` instead of `nproc`, and a clear
error if vLLM is attempted). The frontend defaults Metal serving to
llama.cpp and offers llama.cpp/Ollama instead of vLLM/SGLang. The
odysseus-cookbook CLI's `gpus` command reports the Metal GPU via
sysctl/vm_stat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4ba01ce25d256ae032029898f361c824a34fcd4b)
* Add launchd LaunchAgent for macOS (systemd equivalent)
com.odysseus.ui.plist + install-service-macos.sh run Odysseus at login
and restart on crash, the macOS counterpart to odysseus-ui.service. The
installer auto-fills paths from the venv, so there's no hand-editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 3d4b6b2c7b8b31af32201ed278115df9a559dea9)
* Document macOS install (brew, Ollama, AirPlay port, launchd)
README + setup.py cover the Homebrew / Apple Silicon path: brew install
python@3.11 tmux ollama, Metal serving via Ollama/llama.cpp, the launchd
service, and the macOS AirPlay Receiver conflict on ports 7000/5000.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 8dc9a3578a1726f070ed9f75c0958ae291a6d966)
* Add downloadable macOS launcher app builder
build-macos-app.sh generates dist/Odysseus.app and a drag-to-Applications
dist/Odysseus.dmg. The app starts the local server from this repo's venv and
opens the UI in a chrome-less app window (Chromium --app mode, falling back to
the default browser). It's a launcher wrapper — it drives the venv rather than
bundling Python — so the install path is baked in at build time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 7927940c3810ee34640803b198d334a6ac93474d)
* Harden macOS Cookbook support: hide MLX, fix Metal build cache
Builds on the adopted PR #213 macOS/Metal work with two fixes and tests:
- fit.py: always drop MLX-quantized models. Odysseus only generates serve
commands for llama.cpp/Ollama (Metal) and vLLM/SGLang (CUDA); MLX needs the
mlx_lm runtime and the catalog's MLX repos ship no GGUF alternative, so they
were surfaced on Apple Silicon but could never be served.
- cookbook_routes.py (macOS branch only): `rm -rf build` before configure so a
poisoned CMakeCache from a prior failed CUDA attempt can't make every later
build fail; explicit -DCMAKE_BUILD_TYPE=Release; a clear "brew install cmake"
hint if cmake is missing. Linux/CUDA path unchanged.
- tests/test_hwfit_macos.py: MLX hidden on metal, MLX still hidden on CUDA
(regression guard), Metal detection on Apple Silicon, and skipped on
Linux/Intel (proves non-macOS detection is untouched).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Propagate unified_memory flag and document macOS GPU/Docker caveat
- hardware.py: detect_system now carries the unified_memory flag from GPU
detection into the system dict (it was set by _detect_apple_silicon / AMD-APU
detection but dropped during result assembly, so the API always reported
null). Lets callers distinguish unified from discrete VRAM.
- README: prominent warning that Docker on Apple Silicon can't reach the Metal
GPU (runs a Linux VM) — Cookbook must run natively for GPU serving; fix stale
text that said Cookbook recommends MLX models (now hidden as unservable).
- test: detect_system propagates unified_memory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Put Odysseus's venv bin on PATH for cookbook runners
Native (non-Docker) installs run from a virtualenv whose bin holds the `hf` CLI
and `python3` the cookbook download/serve tmux scripts shell out to. Those
scripts start in a fresh login shell with the venv NOT activated, so on a native
macOS install `hf download` failed with "hf: command not found" — and the
`pip --user` self-heal missed because macOS has no bare `pip` command.
- cookbook_helpers.py: _local_tooling_path_export() — pure helper returning a
PATH export for the running interpreter's bin dir (escaped for double quotes).
- cookbook_routes.py: download + serve runners prepend that dir on local runs
(gated off SSH/Windows); swap the `pip` install fallbacks to `python3 -m pip`.
- tests: helper output for normal and spaced paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Document macOS llama.cpp serving prerequisites
Clarify the two serving paths on Apple Silicon: the recommended zero-build
route (brew install llama.cpp ships a Metal llama-server Cookbook finds on PATH),
and the from-source fallback, which requires cmake + Xcode Command Line Tools.
Without those the build is skipped and serving silently degrades to a slow CPU
build, so new users now know to install them (or use the prebuilt) up front.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Recommend only GGUF-servable models on Metal
Apple Silicon's only serving engines are llama.cpp and Ollama, both GGUF-only
(vLLM/SGLang are CUDA/ROCm and don't run on macOS). The catalog tags raw
safetensors repos with a default Q4_K_M quant, so the fit-ranking was
recommending ~397/501 models that have no GGUF and fail to serve on Metal with
"No GGUF found" (e.g. microsoft/Phi-mini-MoE-instruct).
Drop any model without a real GGUF (is_gguf/gguf_sources) on Apple Silicon —
subsumes the previous AWQ/GPTQ/FP8 special-case into one rule. On CUDA these
stay visible since vLLM serves safetensors directly. Metal recommendations go
501 -> 104, all actually servable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Remove macOS launchd LaunchAgent (cherry-picked extra)
Drop the launchd service from the PR #213 cherry-picks: the
install-service-macos.sh installer, the com.odysseus.ui.plist template, and the
README section documenting them. Tangential to the core Cookbook/Metal support
and not wanted. The build-macos-app.sh launcher is kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add one-command macOS quick start (start-macos.sh)
Running Odysseus natively on a Mac previously meant ~7 manual terminal steps
(brew deps, venv, activate, pip, setup.py, uvicorn with the right port) — not
friendly for a generic macOS user, and the native run is required because Docker
on macOS can't reach the Metal GPU.
- start-macos.sh: installs Homebrew deps (python@3.11, tmux, prebuilt Metal
llama.cpp), creates the venv, installs requirements, runs setup, and launches
on a non-AirPlay port (7860). Idempotent; re-run to start again.
- README: the Apple Silicon section now leads with this one-command quick start
and the clickable .app, with engine/port/manual details folded into a
collapsible block. Added a pointer at the top of the manual-install section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* macOS quick start: auto-open browser when ready
The "open this URL" line scrolled out of view as uvicorn kept logging after it,
so users missed it. Now start-macos.sh waits (in the background) until the
server accepts connections, prints a boxed "ready" banner at that point (i.e.
after the startup burst, not before), and opens the URL in the default browser
automatically. Skippable with ODYSSEUS_NO_OPEN=1 for headless/SSH use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Don't assume/force a specific Python version on macOS
The README claimed "system Python is 3.9" — a machine-specific generalization
that's often wrong (macOS ships no recent Python by default; many users already
have 3.11+). Make it generic, and make start-macos.sh detect an existing
Python 3.11+ and use it, only installing python@3.11 when none is found instead
of forcing it on top of the user's Python.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Align start-macos.sh venv path with build-macos-app.sh
start-macos.sh created the environment in .venv/, but build-macos-app.sh and
the manual install steps use venv/ — so the clickable .app wouldn't reuse the
quick-start's environment and would rebuild a second one. Use venv/ everywhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* README: state clearly that MLX is unsupported on Apple Silicon
Odysseus has no mlx_lm runtime; it serves GGUF (llama.cpp/Ollama) and CUDA
(vLLM/SGLang) only. MLX-only models can't run on a Mac and are hidden from
Cookbook — make that explicit in both the quick start and the details.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* start-macos.sh: build the venv with an arm64 Python on Apple Silicon
A clean-room run surfaced this: with a universal2/x86 Python (e.g. the
python.org installer under /usr/local), the venv's compiled extensions install
as arm64 but get loaded as x86_64 when launched from the .app bundle, so it
crashes with "incompatible architecture (have arm64, need x86_64)". The terminal
run happened to work only because a universal binary defaults to arm64 there.
On Apple Silicon, look only under /opt/homebrew (arm64-only) for the build
Python, and install Homebrew's python@3.11 if none is present — so the venv is
arm64-only and launches correctly from both the terminal and the .app. Intel
and non-mac paths are unchanged. Verified end-to-end in a clean clone: .app now
boots on Metal with no arch error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address dev-exp review: macOS setup robustness + doc/UX fixes
From the voltagent dev-exp review of the branch:
- README: fix broken anchor links (the em-dash heading produced a slug the links
didn't match); simplify the heading to a stable slug.
- cookbook_routes.py: add /opt/homebrew/bin and /usr/local/bin to the serve PATH
so a brew-installed llama-server/ollama is found instead of falling back to a
slow source build.
- start-macos.sh: guard against an empty Python path; fail fast with a clear
message on port-in-use; ERR trap with a "safe to re-run" message; show pip
progress (drop --quiet on the slow requirements install); stop the background
browser-opener cleanly on exit/Ctrl+C (no orphaned poller).
- setup.py: bind hint to 127.0.0.1; suppress the manual run-hint when launched
by start-macos.sh (ODYSSEUS_SKIP_RUN_HINT) so the URL isn't contradictory.
- build-macos-app.sh: the .app only opens the browser once the server is
actually ready (not after the readiness timeout).
- cookbookServe.js: drop "Diffusers" from the Metal backend picker —
diffusion_server.py is CUDA-only, so it was an unservable option on macOS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: yunggilja <yunggilja@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
POST /api/model-endpoints always inserted a new row, so Settings -> Add
Models -> Scan for Servers re-added any endpoint a user had already
registered manually — once under its model name (from the earlier
manual add) and again under its host:port (auto-generated when scan
posts without a name). The success toast then misreported the result
as "added N new".
Look up an existing endpoint with the same base_url accessible to the
caller (shared or owned by them) before inserting. If found, return it
with `existing: true` so the client can tell the difference between
an actual add and a dedupe hit. Toast now reads, e.g.,
"Found 1 server with 1 model — 1 already added".
Tested: POSTing the same base_url three times (incl. trailing-slash
variation) returns the same id each time; only one row exists.
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
routes/mcp_routes.py declares POST /api/mcp/servers with FastAPI
Form(...) params. The Save handler in static/js/settings.js was
sending application/json, so the Form parser saw no fields and
returned 422 with "Field required" for every input — clicking Save
did nothing visible.
Build a FormData object and let the browser set the multipart
Content-Type. args/env are JSON-stringified per the controller
contract (defaults "[]" / "{}"); bad JSON still falls back to
defaults, same as before.
Also check r.ok and surface non-2xx in the form-status span — the
previous code never checked status, so a 422 looked like success.
Matches the FormData pattern already used in this file (uf-mcp-toggle,
~L4036) for the toggle-enable PATCH against the same controller.
Co-authored-by: Toji <ccryptoji@gmail.com>
Hoist the HTML-escape lookup table in static/js/ui.js out of the
String.replace callback so it is allocated once instead of on every
matched character. esc() is the canonical escaper aliased across 27
modules and runs on essentially every render, so this removes a lot of
short-lived garbage on the hottest text path. Output is byte-identical
(verified across null/undefined/emoji/attribute edge cases).
Also build the <select> option lists in cookbook-hwfit.js and group.js
by accumulating a string and assigning innerHTML once, instead of
`innerHTML +=` inside a forEach (which makes the browser re-parse the
element's markup on every iteration). Final DOM is unchanged.
Pure micro-optimizations; no behavior change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>