dispatch_reminder call on line 699 references _gcu(request) which is
never defined. The local helper wrapping get_current_user is _owner.
Every POST to /api/notes/fire-reminder raises NameError and returns 500.
* fix(mcp): invalidate tool prompt cache on connect/disconnect/error
get_tool_descriptions_for_prompt cached its result keyed only on
(disabled_map, len(_tools)). If a server reconnects with the same
tool count (or transitions to error state), the cache was never
busted — the agent received stale tool descriptions for the new
connection state.
Add a _generation counter incremented on every structural change
(successful connect, disconnect, connection error) and include it in
the cache key.
* test(mcp): regression test for _generation cache invalidation
* refactor: single _default_analytics() instead of duplicated default dicts
* fix: merge analytics defaults so an old/partial file doesn't KeyError on record
* test: analytics load merges defaults; record survives a partial file
src/search/ranking.py computed result age as `(datetime.now() - dt).days`, where
`dt` is parsed from a UTC-style published date with no timezone. Using local
`datetime.now()` skewed the age by the host's UTC offset (off-by-up-to-a-day near
boundaries), and was a latent crash: once neighbouring code becomes timezone-aware
the naive/aware subtraction raises TypeError (the landmine called out in #1116).
Recency is now measured against naive UTC. The scoring is also lifted out of the
rank_search_results closure into a module-level, time-injectable `recency_score`
so it's unit-testable, and `_utcnow_naive()` avoids `datetime.utcnow()` (removed in
Python 3.14).
Covered by tests/test_search_ranking_recency.py (5 cases); the existing
tests/test_search_ranking.py still passes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent soft-trims input context to `agent_input_token_budget` (default 6000).
The old computation `min(context_length or budget, budget)` made the 6000 default
a hard ceiling for every model, so 128K/1M context models were silently capped at
6000 input tokens — now that num_ctx is sent correctly (#1056), this was the last
barrier to actually using a long context window.
This derives the default budget from the model's discovered context window
(~85%, capped at a generous hard max) while honouring an explicit user setting
exactly (clamped to the window). When the window is unknown it falls back to the
previous value, so behaviour is unchanged for that case.
- src/context_budget.py: pure `compute_input_token_budget()` (unit-testable)
- src/settings.py: `is_setting_overridden()` to tell an explicit user value from
the merged default (load_settings merges DEFAULT_SETTINGS, so equality alone
can't distinguish them)
- src/agent_loop.py: use the helper in the soft-trim path
Covered by tests/test_context_budget.py (6 cases).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/embeddings/endpoint takes a user-supplied URL and immediately
makes an outbound httpx request to it with no validation. The admin gate
added earlier (PR #80) closed the unauthenticated-access part of #132; this
addresses the remaining request: validate the URL before fetching it.
Odysseus is local-first, so pointing the embedding endpoint at a loopback or
LAN server (local vLLM / llama.cpp / Ollama) is a normal setup — a blanket
private-IP block would break the primary use case. So the guard:
- always rejects non-HTTP(S) schemes (file://, gopher://, ftp:// …),
- always rejects the link-local range (169.254.0.0/16, incl. the cloud
instance-metadata 169.254.169.254 exfil vector) plus multicast /
reserved / unspecified, and IPv4-mapped-IPv6 forms of the above,
- keeps loopback/LAN allowed by default, and
- adds EMBEDDING_BLOCK_PRIVATE_IPS=true for full SSRF lockdown on exposed
multi-tenant deployments.
Logic lives in src/url_safety.py (stdlib only, resolver injectable) so it is
unit-testable without real DNS; the route calls it before the health-check
request. Covered by tests/test_url_safety.py (8 cases).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/health is a liveness ping. This adds /api/ready as a readiness /
integrity self-check that returns 503 unless every critical subsystem is
whole, so an orchestrator (Docker/Compose/k8s) can gate traffic on real
readiness rather than mere process liveness:
- database: opens a connection and runs SELECT 1
- data_dir: confirms the data directory exists and is writable
- local_first: reports whether storage stays on the host (informational;
a remote database is a valid deployment, so it never fails readiness)
The check logic lives in src/readiness.py so it is unit-testable in
isolation; the route is a thin wrapper. Covered by tests/test_readiness.py.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: exclude deepseek from local tool-calling keyword list
deepseek-r1 on Ollama returns HTTP 400 when tool schemas are sent.
The cloud API (api.deepseek.com) is already caught by the _API_HOSTS
check, so the generic 'deepseek' keyword match was only causing false
positives for local Ollama-served models.
* fix: add model no-tools blocklist and regression tests for deepseek-r1
The previous fix removed 'deepseek' from the keyword allow-list, but
_is_api_model is still True for localhost endpoints because 'localhost'
appears in _API_HOSTS — so the keyword change had no effect for Ollama.
Proper fix: add an explicit _model_no_tools blocklist ('deepseek-r1')
that overrides the endpoint URL check. The endpoint's supports_tools DB
flag still takes priority either way (True forces tools on, False forces
them off), so users can override per-endpoint when needed.
Also refined the deepseek allow-list: 'deepseek-v' and 'deepseek-chat'
cover the cloud models (v2, v3, chat) that do support tools, without
matching deepseek-r1 variants.
13 regression tests cover:
- deepseek-r1 on localhost/docker: no tools (was HTTP 400)
- deepseek-v3/chat on api.deepseek.com: tools enabled (no regression)
- endpoint_supports=True/False overrides both lists
- qwen/llama on localhost: unaffected
Use func.lower() when updating SQL owner columns, match prefs keys
case-insensitively, and normalize session usernames before comparing
during rename. Prevents silently skipping legacy mixed-case owner data.
Fixes#1165
Rework read_file / write_file confinement after review feedback:
- Remove $HOME from default allow roots. Only project data/ and system
temp dirs are allowed out of the box.
- Add a sensitive-subpath deny list (.ssh, .gnupg, shell rc files,
.env, .netrc, SSH key filenames). Checked BEFORE allowlist so it
blocks even when a broader root is configured.
- Add "tool_path_extra_roots" setting for opt-in broader access.
- Sensitive subpaths remain blocked regardless of configured roots.
Tests: 24 cases covering /etc/shadow, ~/.ssh/authorized_keys,
symlink into .ssh, traversal, shell rc files, key filenames,
extra roots, and dispatch-level end-to-end.
The Cookbook's manual hardware simulator ("what if I had this setup") let users
pick a backend, but _apply_manual_hardware only accepted cuda/rocm/cpu_x86/
cpu_arm and silently coerced anything else to cuda. So selecting Apple/Metal
simulated a CUDA box instead — and ranked safetensors-only repos a Mac can't
serve, even though the rest of hwfit (services.hwfit.fit, the serve-command
generation) already supports Metal as GGUF-only via llama.cpp/Ollama.
Add "metal" to the accepted backends (now a named _MANUAL_BACKENDS set, kept a
subset of what fit.py understands) and set unified_memory=True for it — Apple
Silicon shares one memory pool with the GPU — while clearing that flag for the
discrete (cuda/rocm) and CPU backends. _apply_manual_hardware is lifted to
module scope so it is directly unit-testable; both route call sites are
unchanged.
Adds tests/test_hwfit_manual_backend.py, including an end-to-end check that a
simulated Metal box only recommends GGUF-servable models.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Scan port 1234 and any custom port from LM_STUDIO_URL, add the LM_STUDIO_URL host to the discovery sweep alongside the Ollama env vars, and tag each discovered endpoint with its provider by fingerprinting the native /api/v1/models response (entries carrying key + architecture). Documents LM_STUDIO_URL in .env.example.
Follow-up to the Venice provider PR. Wire api.venice.ai into the three
host allowlists so Venice behaves like the other paid OpenAI-compatible
clouds:
- agent_loop: add api.venice.ai to _API_HOSTS so the agent sends native
OpenAI tool-call schemas (Venice supports function calling) instead of
degrading to fenced-block parsing.
- teacher_escalation: add api.venice.ai to _SOTA_HOSTS so the escalation
loop stays OFF for Venice (it's a paid top-tier API; no need to add
teacher-model latency).
- webhook_routes: add venice to KNOWN_PROVIDERS so the sync chat webhook
can auto-resolve base_url from provider=venice.
Tests: tests/test_venice_hosts.py pins tool-host matching + SOTA
classification for Venice; py_compile on touched modules.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two bugs in the export_ics path:
1. X-WR-CALNAME was written raw: calendar names containing commas,
semicolons or backslashes produced invalid ICS (RFC 5545 §3.3.11
requires those characters to be escaped as \, \; and \\).
Fix: wrap cal.name in the existing _ics_escape() helper, which is
already used for SUMMARY, DESCRIPTION, and LOCATION on the lines
immediately below.
2. DTSTART and DTEND on non-all-day events always emitted the naive
ISO string (e.g. 20260602T100000) regardless of CalendarEvent.is_utc.
Consumers treat a naive datetime as floating/local time, so UTC
events imported into Google Calendar or Apple Calendar shifted by
the user's timezone offset. Fix: append 'Z' when is_utc is True,
matching the pattern already used by the serialise_event() helper
at line 408.
Read a model's capabilities.vision flag from LM Studio's native /api/v1/models so vision finetunes whose names lack a vision keyword still receive images, falling back to the name heuristic when the endpoint doesn't report it. The probe is short-TTL cached and restricted to local/LAN hosts, so remote/cloud endpoints are never contacted.
The Cookbook → Dependencies tab reported llama-cpp-python[server] as "not
installed" even when it was installed and usable for serving. The local check
looked up distribution metadata as pkg["name"].replace("_", "-") — for the
import name `llama_cpp` that yields "llama-cpp", but the module ships in the
`llama-cpp-python` distribution. importlib.metadata.version("llama-cpp") then
raised PackageNotFoundError and the package was marked missing (the import
itself succeeds, which is why serving still worked).
Derive the distribution name from the package's declared pip spec instead
(stripping [extras] and version markers), falling back to the munged import
name only when no pip spec is declared. New _pip_dist_name() helper.
Adds tests/test_cookbook_package_detection.py covering the llama_cpp mapping,
extras/marker stripping, plain names, the no-pip-spec fallback, and that the
route wires the helper in (guarding against the exact regression).
add_document() and add_documents_batch() derive the persistent ChromaDB
document id from Python's built-in hash():
doc_id = f"doc_{hash(text) % 10**16}"
str hashing is randomized per process (PYTHONHASHSEED is on by default), so
the same document text gets a different doc_id on every restart. The dedup
check right after — self._collection.get(ids=[doc_id]) — therefore misses
on restart, and identical documents are re-embedded and re-added as
duplicates each time the app restarts, bloating the vector store and
skewing retrieval.
Derive the id from a stable hashlib.sha256 of the text via a shared
_generate_doc_id() helper, used by both add paths so they agree.
tests/test_rag_vector_id_stability.py runs _generate_doc_id in subprocesses
under PYTHONHASHSEED=0/1/random and asserts the id is identical across all
of them (and differs for different text). Fails before this change.
* Load .env in start-macos.sh for APP_PORT and APP_BIND
Parses .env at startup (consistent with how app.py reads it via
python-dotenv) so APP_PORT and APP_BIND are honoured without having
to retype them on the command line every run.
Resolution order: shell env (ODYSSEUS_PORT / ODYSSEUS_HOST) → .env
(APP_PORT / APP_BIND) → built-in defaults. Existing ODYSSEUS_* shell
overrides are fully preserved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Document .env support for APP_PORT and APP_BIND in macOS section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Docking a modal to a window edge pushes the chat aside (body padding via
right-dock-active + --right-dock-w). Three problems on close/reopen:
1. Chat stayed offset after closing a docked modal. The close-watcher only
reacted to the `.hidden` class or DOM removal, but the draggable modals
(calendar, plan, workspace, document, …) close via inline `display:none`.
Watch the `style` attribute too and treat `display:none` as closed.
2. Reopening a previously-docked singleton modal floated it off to the side,
overlapping the chat. The reused element kept its docked inline geometry.
Clear the content's inline position/size on close so it reopens at its CSS
default (centered).
3. Undock wasn't animated. The transition lived on `.right/left-dock-active`,
so removing the class dropped the transition with it and padding snapped to
0. Move the transition to the base `body` so the push animates both ways.
Files: static/js/modalSnap.js, static/style.css.
Checks: node --check static/js/modalSnap.js; verified in-browser (dock → close
→ chat animates back; reopen → centered, no overlap).
Multimodal content (list of {type, text/image_url} blocks) couldn't be
stored in the DB Text column, causing silent persist failures. On reload
the frontend fell back to String() on the array, rendering
[object Object],[object Object] in the chat.
- Serialize list content as JSON in _persist_message()
- Deserialize back to list in _db_to_session() via _parse_msg_content()
- Extract text parts from multimodal arrays in sessions.js instead of
String() coercion