Compare commits

...
Author SHA1 Message Date
RaresKeY 96c88c27c8 fix(personal): bound multi-file upload memory 2026-08-15 09:02:55 +00:00
Joeseph Grey 6edd771cc9 Merge branch 'dev' into fix/add-directory-event-loop 2026-08-12 10:39:22 -06:00
StressTestor 938251000b fix(personal): route upload and delete through the index job lock
/api/personal/upload and DELETE /api/personal/file mutated the same
vector and tracking state add/remove/reload serialize on, outside
_index_job_lock and inline on the event loop.

Both now stage async work on the loop, then run the complete transition
(vector writes, disk change, personal_docs_manager update) in one
offloaded critical section under the shared lock, acquired before the
offload so queued requests park on the loop rather than pinning a
threadpool worker.

Adds add-vs-upload and add-vs-file ordering regressions.
2026-08-12 10:17:36 -06:00
RaresKeYandAlexandre Teixeira b52296471b fix(model-routing): keep selected models strict (#5801)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 14:10:07 +01:00
53869d194d fix(cookbook): record real Windows pid for local serve so Stop kills the model (#5912)
* fix(cookbook): record real Windows pid for local serve so Stop kills the model

The Windows-local serve runner recorded Git Bash's `$$`, which is the
MSYS/Cygwin pid, not the Windows pid. Win32 tooling (taskkill,
Get-CimInstance ParentProcessId, Stop-Process) can't match an MSYS pid, so
the frontend Stop-Tree walk found nothing and the llama-server child survived
after Stop, leaving the model loaded and the GPU pinned.

Record the serving shell's true Win32 pid via `/proc/$$/winpid`, falling back
to the outer proc.pid already written from Python when the map is unavailable.

The existing pid-tracking test asserted the buggy `$$` literal at the source
level, so it passed while the feature was broken; update it to the winpid
behavior and add a focused regression test.

* fix(cookbook): make Windows serve pid handoff deterministic

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 10:32:24 +01:00
DocFuriousandAlexandre Teixeira 17ee856d1c fix(teacher): import _TEACHER_SYSTEM_PROMPT from its current module (#5756)
* fix(teacher): import teacher prompt from current module

* test(teacher): make prompt monkeypatch import-order independent

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 08:13:16 +01:00
RaresKeYandAlexandre Teixeira e7eddbae13 fix(email): serialize urgency checkpoint delivery (#5804)
* fix(email): serialize urgency checkpoints

* fix(email): preserve urgency transaction lifecycle

* fix(email): fence stale urgency scans

* fix(email): fence stale urgency delivery

* fix(email): retire stale urgency accounts

* fix(email): fence urgency account retirement

* fix(email): retain urgency registration generation

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 05:05:21 +01:00
RaresKeYandAlexandre Teixeira 93eb10d4f0 fix(email): serialize default-account mutations (#5805)
* fix(email): serialize default account mutations

* fix(email): enforce default account invariant

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 04:59:46 +01:00
RaresKeYandAlexandre Teixeira 3f9633c44f fix(calendar): keep default creation transactional (#5806)
* fix(calendar): keep default creation transactional

* fix(calendar): serialize default calendar creation

* fix(calendar): handle renamed default id collisions

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 04:51:52 +01:00
858c872832 docs(setup): document supports_tools opt-in for manual Ollama /v1 endpoints (#5835)
* docs(setup): document supports_tools opt-in for manual Ollama /v1 endpoints

Manually-added Ollama /v1 endpoints default to the conservative
fenced-block tool-calling path, and there's currently no UI control to
opt a specific endpoint into native tool calling (#5192). The
supports_tools PATCH flag already exists and works, it just wasn't
documented anywhere a user could find it without reading source.

Adds a short section next to the existing "Ollama with Docker" notes
explaining when to use it and the exact API call, framed as an
advanced/opt-in setting per the maintainer's stated preference against
a casual UI toggle (#3195/#3438).

* docs(setup): clarify supports_tools false semantics

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 04:46:11 +01:00
1939a6ad2d fix(thinking): add deepseek-v4 to thinking model patterns (#6000)
* fix(thinking): add deepseek-v4 to thinking model patterns

deepseek-v4-flash emits reasoning_content via the API but was not
recognized in _THINKING_MODEL_PATTERNS (only deepseek-r1 and
deepseek-reasoner were listed). Add the deepseek-v4 prefix so
the model is recognized as thinking-capable.

The between-round _thinkOpen leakage was separately fixed by
PR #5931 (perf(chat): batch live thinking rendering).

Related: #3998, #5931

* test(thinking): cover DeepSeek v4 detection

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 04:26:13 +01:00
RaresKeYandAlexandre Teixeira 937c883c41 ci: make Python validation authoritative (#5940)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 03:35:09 +01:00
RaresKeYandAlexandre Teixeira e0615cda47 fix(upload): recover backups after same-timestamp corruption (#5860)
* fix(upload): harden index cache recovery

* fix: retry upload index loads across replacement

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 03:22:31 +01:00
leepokaiandAlexandre Teixeira 1976fe1b60 fix(tools): parse Hermes/Qwen JSON bodies inside tool_call wrappers (#5887)
parse_tool_blocks fed <tool_call> wrapper bodies only to the XML
iterators (_iter_xml_invoke/_iter_xml_direct), so the canonical
Qwen/Hermes text-mode form — a bare JSON object like
{"name": "bash", "arguments": {"command": "..."}} inside the
wrapper — parsed to zero tool blocks and the agent never executed
anything. Pattern 4d only matches OpenAI-style blobs with a literal
"function" key, which the Hermes format lacks.

Wrapper bodies are now classified first: a JSON-looking body ({ or [)
is parsed by the new _parse_json_tool_call_body, which requires an
object with a string "name" and rejects a non-object "arguments"
instead of coercing it, then converts through the same
function_call_to_tool_block used by the XML paths so aliases and
per-tool argument formatting stay uniform. JSON-looking bodies fail
closed — they are never rescanned by the XML iterators (including the
unclosed-wrapper and bare-invoke fallbacks), so XML-like text inside
JSON argument values stays data instead of selecting a different tool.
Non-JSON bodies keep the existing XML path unchanged.

Fixes #5187

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 02:41:03 +01:00
adfe3ab379 fix(llm): alias tool names that collide with gpt-oss built-ins (#5878)
gpt-oss (harmony) ships BUILT-IN tools named python/browser, invoked
with the raw body as the argument (to=python + bare source), while
custom functions use to=functions.NAME + JSON. Exposing our own tools
under those names makes the model answer with the built-in convention:
it emits raw code, the server parses it as JSON, and the request dies
with 'error parsing tool call: raw=import sys, ...'. In streaming mode
Ollama does not report it at all — it truncates the stream, so the turn
arrives as an empty response and the agent loop reads it as a model
stall. bash collides the same way in practice.

Measured on gpt-oss:20b via Ollama /v1, fixed agentic prompt, 12 runs
per arm: python+bash as-is 2/12, python renamed 10/12, both renamed
12/12. 74 HTTP 500s were logged server-side during investigation with
zero surfaced to the client.

Rename the colliding tools on the outbound payload and map the names
back on responses. Transport-only and gated on gpt-oss: every other
model's schemas pass through untouched (asserted in tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 02:01:52 +01:00
d87a913729 fix(ui): stop stripping the word assistant from rendered text (#5974)
* fix: stop stripping the word 'assistant' from rendered text

The QWEN_BARE_MARKER_RE regex in both the Python backend (tool_parsing.py)
and JS frontend (chatRenderer.js) was matching any standalone occurrence of
the word 'assistant' separated by any whitespace, then replacing it with a
space. This caused normal English uses like 'Home assistant' to render as
'Home '.

Fixed by narrowing the word-boundary check from [\t\r\n ] (any whitespace)
to [\r\n] (line boundaries only), so only Qwen-format role-token leaks
(where 'assistant' appears alone on a line) are stripped.

* fix(tests): update bare-marker test expectations for #5971

Move 'x assistant y' from STRIPPED to KEPT (mid-sentence must survive).
Add 'Before\nassistant\nAfter' to STRIPPED (bare-marker on own line).

* fix(ui): strip whitespace-padded assistant role markers

---------

Co-authored-by: samy <samy@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 01:47:11 +01:00
bea48c749c fix(sidebar): keep minimized icon rail in sync with per-tab visibility (#5987)
* fix(sidebar): keep minimized icon rail in sync with per-tab visibility

Per-tab visibility (Customize UI / Appearance checkboxes, stored in
localStorage under `odysseus-ui-visibility`) was only applied to the full
sidebar elements — `UI_VIS_MAP` never targeted the collapsed `#icon-rail`
launchers. So a user who turned a tab off (e.g. Email) in the full view saw
every tab reappear when minimizing the sidebar to the icon rail.

Pair each tool/section selector with its `#rail-*` counterpart (mapping
mirrors `_railToolMap`), so `applyUIVis()` hides the rail launcher too.
Admin feature-flag handling is unaffected: the features-fetch reconcile at
app.js already re-applies `applyUIVis()`, so rail launchers now track admin
disables exactly like their sidebar buttons.

Adds a static regression test asserting every customizable tab pairs its
rail button.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(sidebar): extract UI visibility into testable module

Move UI_VIS_MAP, UI_VIS_DEFAULT_OFF, and a pure resolveVisibility() into
static/js/ui_visibility.js so the icon-rail visibility rules are unit
testable without a DOM. app.js applies resolveVisibility() to the document,
replacing the ad-hoc tools-section override with an inline parent rule
(tools-section off hides every tool rail launcher). Add edge-case tests
covering per-tool off, the tools-section parent rule, parent+child combos,
email-section, and the tool-library <-> #rail-archive mapping.

Refs #5985

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 01:28:30 +01:00
Manuel Cartagena HerreraandAlexandre Teixeira 5a016e492c fix(gallery): handle MPS float64 mask inputs (#5903)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 01:23:43 +01:00
AshvinandAlexandre Teixeira 93653120d6 fix(skills): stop SKILL.md frontmatter escapes compounding on every save (#5883)
_emit_scalar quotes a frontmatter scalar with json.dumps when it holds
punctuation that would change how the line reads back. _parse_scalar undid that
with a bare raw[1:-1]: it stripped the quotes but never decoded the escapes. So
a description containing ü was written as the escape sequence \u00fc, read
back with that escape still sitting literally in the value, and re-escaped on
the next save. The backslash run doubles every save, so a non-English skill
description degrades into backslash noise after a few edits, and the escapes are
shown verbatim in the skills list and the /skills catalog.

This is not limited to non-ASCII. Any description containing a quote takes the
same path, since the quote is itself what forces the quoted form.

Make the two halves symmetric: emit with ensure_ascii=False, since SKILL.md is
UTF-8 at both ends (skills.py reads it, atomic_write_text writes it) and the
ASCII-escaped form bought nothing; and parse double-quoted scalars with
json.loads, falling back to the previous literal reading when the value is not
valid JSON. Files already corrupted heal one level per load.

ensure_ascii=False on its own would open a smaller hole. json.dumps escapes
every C0 control character but passes NEL, LINE SEPARATOR and PARAGRAPH
SEPARATOR through literally, and parse_frontmatter reads one scalar per line via
str.splitlines(), which breaks on all three. Re-escape those three, and add them
plus the remaining splitlines characters to the set that forces a quoted scalar,
so none of them can reach the file bare.

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 01:09:02 +01:00
Léo c2b9666def perf(frontend): preload the two first-paint Fira Code faces (#5992)
The app font faces are declared in static/style.css, so the browser only
discovers FiraCode-Regular.woff2 and FiraCode-SemiBold.woff2 once the
stylesheet has parsed. On a cold load they start about 145 ms in, behind
the module graph. font-display: swap keeps that from blocking render, so
the cost is a visible swap rather than a stall, but the fetch can start
immediately instead.

Two preload hints move the request into the head. Measured cold on a
scratch instance with an empty cache, three runs per arm: request start
142-203 ms becomes 15-19 ms, response end 174-248 ms becomes 46-63 ms.
The total request count is unchanged and each face is still fetched
exactly once.

crossorigin is required even though these are same-origin: fonts are
always fetched in CORS mode, and without it the preload is discarded and
the font fetched again. Dropping the attribute produces four font entries
in the Resource Timing list instead of two.

Only Fira Code 400 and 600 are preloaded. They are the only faces first
paint uses. Inter, OpenDyslexic and Fira Code 300 stay unloaded on both
desktop and mobile, with or without a saved font preference.
2026-08-12 00:50:55 +01:00
1183fe0ff1 fix(llm): normalise Mistral structured content in llm_call_async (#5882)
llm_call_async returned raw list content for Mistral thinking models,
breaking callers that expect a str (e.g. auto-title). Match the sync
and streaming parsers by running list content through
_normalize_mistral_content.

Fixes #5435

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 00:40:16 +01:00
Léo 663d6879b7 fix(ui): stop the whirlpool spinner animating when it is never attached (#5990)
_drawWhirlpool re-armed requestAnimationFrame forever whenever its element
had never been connected to the document. The grace period is there so a
spinner can keep drawing between start() and the caller appending the
element, but it had no deadline: while the element has never been connected
_wpWasConnected stays false, so the guard stays true and the else branch is
unreachable. Any caller that starts a spinner and then takes an early return,
such as an aborted request or a panel that resolved from cache, leaves a loop
redrawing an 84-segment spiral into a detached canvas at one frame per
displayed frame until the tab closes.

Put a 2 second deadline on the grace period. Callers append in the same task
as start(), so that is far more slack than any of them need. A spinner that
is actually in the document is unaffected.

Two supporting changes in the same file:

- Both self-terminate paths now call stop() instead of setting isRunning
  directly, so termination always runs one cancelAnimationFrame and never
  depends solely on inferring DOM connectivity. Both draw functions bail at
  the top when they are no longer running, and _requestFrame() clears rafId
  as the callback enters so it is a truthful "a frame is pending" flag.
- start() arms a visibilitychange listener and stop() removes it. A hidden
  tab cancels the pending frame, a re-shown tab re-arms it. Chrome throttles
  background rAF but does not reliably stop the canvas work, and owning the
  listener from start/stop means a dead spinner never leaves one behind.

Adds tests/test_spinner_stops_when_never_attached_js.py, which drives the
real module under node with a fake clock and a manual frame pump. It covers
all four exits and, importantly, the converse: a spinner that is attached
keeps running well past the grace window.
2026-08-12 00:25:03 +01:00
Léo 3bea7a53ee fix(email): derive the Google OAuth redirect URI scheme from the request (#5995)
Both the authorize and callback routes built the redirect URI with a
hardcoded `http://` and the Host header. Behind any TLS terminator that
produces `http://host:443/api/email/oauth/google/callback` — the wrong
scheme and, on a split-port setup, a dead port. Google then refuses the
authorize request or the token exchange, so OAuth email is unusable on
every HTTPS deployment unless GOOGLE_OAUTH_REDIRECT_URI is pinned by hand.

uvicorn's proxy-headers middleware already rewrites the scheme from
X-Forwarded-Proto for trusted proxies (on by default, trusting 127.0.0.1),
so request.url.scheme is correct both directly and behind a proxy.

Google requires the callback's redirect_uri to match the authorize one
exactly, so both sites change together. An explicit
GOOGLE_OAUTH_REDIRECT_URI still wins, unchanged.
2026-08-12 00:04:32 +01:00
Amir FathiandAlexandre Teixeira 22e0af2a58 fix(core): stop atomic writes from colliding on a constant PID suffix (#5721)
atomic_write_json/atomic_write_text build their temp filename as
"{path}.tmp.{os.getpid()}". os.getpid() is constant for the life of a
process, so it only ever distinguishes concurrent writers that live in
different OS processes. Odysseus runs as a single long-lived process
per container, so two concurrent writers to the same path (e.g. two
request handlers racing a settings save) always compute the identical
temp path. Whichever finishes os.replace() first removes the shared
tmp file out from under the other, which then raises FileNotFoundError
on its own os.replace() instead of landing its write.

Fix: derive the temp suffix from uuid4() instead of the PID, so every
call gets a distinct temp path regardless of process/thread identity.

routes/prefs_routes.py's _save() had an independent, hand-rolled copy
of the exact same PID-suffix logic (not the shared core.atomic_io
helper other routes already use, e.g. routes/auth_routes.py) with the
same bug. Replaced it with a call to atomic_write_json.

Fixes #5596

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-11 13:36:56 +01:00
Tal.Yuan c00ef8f9c2 refactor(routes): move mcp domain into routes/mcp/ subpackage (#5899)
Slice 2o of the route-domain reorganization (#4082/#4071). Moves
mcp_routes.py (697 lines) into routes/mcp/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.

The shim uses sys.modules replacement so sys.modules.pop + re-import,
monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", ...), and __file__
introspection in test_security_regressions.py all reach the canonical
module. One source-introspection path string repointed (line 1001).

Canonical module imports only from core/, src/, and stdlib (zero internal
routes/ coupling). Adds tests/test_mcp_routes_shim.py.

Verified: compileall clean; full suite 4804 passed, 3 skipped.
2026-08-11 02:24:55 -06:00
Boody 1fef4929cf Merge pull request #5920 from adabarbulescu/fix/windows-workspace-access
fix(agent): use Git Bash for Windows workspace shell
2026-08-11 03:50:03 +03:00
RaresKeYandLéo 651bf714de perf(chat): batch live thinking rendering and bound timer updates (#5931)
* perf(chat): batch live thinking DOM updates

* test(chat): cover live thinking scheduler lifecycle

* fix(chat): guard background stop-state, restore live thinking text, drop source-text tests

- _closeOpenThinkingMarkup no longer overwrites currentAccumulated for
  backgrounded streams. It now mirrors the guard the delta path already uses
  (`if (!_isBg) currentAccumulated = accumulated`). Without it a backgrounded
  stream's text is written into the foreground session's stop-state, which
  abortCurrentRequest and detachCurrentStream then put in the wrong bubble.

- Split _extractLiveThinkingText into _liveThinkingText (strip every think tag)
  and _closedThinkingText (via extractThinkingBlocks). Slicing from the first
  <think> to the first </think> pinned the live box to "The" for the rest of the
  stream on the `<think>The</think>` + untagged-thinking pattern that the
  hasUnclosedThink detection deliberately keeps streaming through.

- The background transition now flushes with rich:true, so a stream that
  backgrounds mid-thinking isn't left as pre-wrap plain text permanently.

- Move the throttle to static/js/liveThinkingThrottle.js and import it. The
  .mjs suite imports the module instead of slicing it out of chat.js with
  vm.runInNewContext and marker comments.

- Replace the source-text assertions in tests/test_live_thinking_scheduler_js.py
  with behavioral coverage, per tests/TESTING_STANDARD.md. The .mjs suite grows
  from 3 to 6 cases.

- Collapse the duplicated tool_start/agent_step finalizers into one
  _endLiveThinkingSection().

* fix(chat): hoist thinking teardown out of the try block so catch can reach it

In an ES module a function declared inside `try { }` is scoped to that block,
and `catch` is a sibling scope rather than a nested one. _closeOpenThinkingMarkup
was declared inside the try and called from catch, so the call threw
ReferenceError and killed the rest of the error path: the stream never
finalized and the thinking block was never torn down.

Declare _closeOpenThinkingMarkup and a new _endThinkingOnTerminalPath next to
the existing _flushLiveThinking / _cancelLiveThinkingWork outer lets and assign
them inside the try, which is the pattern those two already use for exactly
this reason.

Verified against a live stream in a browser: before, clicking stop mid-thinking
logged "_closeOpenThinkingMarkup is not defined" and left no finalized thinking
section; after, the block collapses to "View thinking process" correctly.

* perf(chat): extract live thinking at commit cadence

* fix(chat): bound live thinking work

* test(chat): update stream invariant assertions

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 20:11:48 +01:00
RaresKeYandLéo d449a9d431 fix(history): defer full transcript hydration to model sends (#5929)
* fix(history): defer full hydration to model sends

* fix(session): key hydration on real rows, fork through get_session

Two regressions from the display/model-context split, both reproducible
against dev.

The hydration gate compared the cached transcript against the
denormalized sessions.message_count column. That column drifts in normal
operation — _persist_message swallows a failed insert while add_message
has already appended in memory, so the next successful persist writes
rows+1 — and _db_to_session re-read the same column after each reload, so
the shortfall never closed. Every send, edit, delete and truncate on a
warm session re-selected the whole message table: the cost this change
set out to remove, relocated onto the hot path. The other direction was
just as bad — a persist for an uncached session writes message_count = 0,
and a stale-low counter with a partly filled cache meant no hydration at
all and a silently truncated transcript for the model.

sync_session_metadata now reconciles message_count against COUNT(*) on
chat_messages (one indexed count inside the connection it already opens),
and _db_to_session trusts the rows it just loaded. A hydrate always
closes the gap, so the next read is a cache hit.

fork_session read session_manager.sessions directly and never hydrated.
keep_count indexes into source.history, and display pagination no longer
fills that cache, so forking after a restart returned HTTP 200 with an
empty conversation and no error surfaced. It goes through get_session
now.

_hydrate_session_history_from_db is gone with its helper: get_session is
the hydration seam, and rebuilding session.history from raw rows in the
display fallback overwrote the parsed multimodal content and the _db_id
edit/delete keys that had just been set.

Tests drive a real SessionManager over a temp DB instead of a stub that
only proved the stub hydrates — both drift directions, the send path
warm and cold, and a fork taken after a restart. All five fail without
this change. The brittle SQL-text assertions are dropped; the page
bounds are already proven by the response body.

* fix(history): route pagination through canonical handler

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:39:21 +01:00
RaresKeYandLéo dbeed4b63f perf(ui): stop session loading from blocking shell (#5927)
* perf(ui): stop session loading from blocking shell

* fix(startup): open routes on their own data, retire the loader for good

Follow-up to review on #5927.

- Route openers are now classified by the data they actually read. Only
  /email touches the hydrated session list (its new-chat path falls back to
  the most recent session's model when no default chat is set), so every
  other route opens as soon as module wiring completes instead of queueing
  behind /api/sessions. This is the deferred-route half of #5926, which the
  first pass left unimplemented.
- index.html's 5s fallback removes the loader node again. Leaving it in the
  DOM indefinitely kept _shouldPreserveStartupComposer true forever on a
  hung /api/sessions, so the composer stopped clearing on session switch.
- A missing session module settles hydration instead of leaving the sidebar
  on "Loading chats…" and dropping the user's route on the floor.
- Startup sequencing moved to static/js/startupShell.js so it can be run by
  tests. The source-text assertions in test_startup_shell_session_loading.py
  are replaced by node-driven behavioural tests, per tests/TESTING_STANDARD.md.
- Reverted the unrequested loader a11y rework, removed the duplicated inert
  writes (the module stops the wave interval through a callback), and moved
  the bootstrap row's inline styles into .session-list-bootstrap.

* fix: preserve session bootstrap failure state

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:37:21 +01:00
RaresKeY 96aca52094 perf(email): make library prewarm idle and bounded (#5925)
* perf(email): make library prewarm idle and bounded

* fix(email): preserve idle prewarm and prioritize foreground

* fix(email): retry interrupted idle prewarm safely
2026-08-10 19:14:10 +01:00
RaresKeYandLéo 8f2f483725 fix(email): make unread opens one authoritative IMAP operation (#5923)
* fix(email): mark opened messages seen in one IMAP operation

* fix(email): collapse unread opens and ignore stale responses

* fix(email): send seen flags as an IMAP flag list

Wrap the authoritative \\Seen STORE operand in parentheses so strict IMAP servers such as GreenMail accept both cache-miss and cached-open transitions. Tighten the focused fake IMAP contract to reject the previously emitted bare flag atom.

* fix(email): guard stale authoritative opens

* fix(email): report a failed \Seen instead of withholding the message

The authoritative-open contract made a failed STORE fatal to the read: the
cold path raised after the body was already fetched and parsed, and the
cached path discarded an in-memory message to return
{"error": "Failed to mark email read"}. A transient IMAP failure therefore
turned a readable message into one that could not be opened at all.

Being authoritative should mean the reported flag state is truthful, not
that the body is withheld. The read now always returns the message and
carries mark_seen_failed so the client can roll its optimistic unread
marker back:

- _read_email_sync logs and reports a rejected STORE rather than raising,
  and only writes the local index/list-cache transition when the provider
  accepted it, so local state cannot drift ahead of the mailbox.
- A mailbox that refuses a read-write SELECT (shared archives, some
  provider folders) falls back to a read-only selection and reports the
  flag failure instead of failing the open.
- The route strips mark_seen_failed before caching, so a one-off failure is
  never replayed to later readers.
- mark_seen now defaults to False on _read_email_sync. It was inert before
  this branch and now mutates provider state; the one caller that wants it
  off already passes it explicitly.

emailInbox and emailLibrary keep the message rendered when mark_seen_failed
is set and restore the unread state, rather than showing a failed reader.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 18:45:34 +01:00
Matyas GosztonyiandMatyas Fenyves 42da399b4d fix(email): route summaries through shared LLM adapter (#5841)
* fix(email): route summaries through shared llm adapter

* chore(ci): refresh PR checks

* fix(email): preserve scheduled summary safeguards

---------

Co-authored-by: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
2026-08-08 23:06:41 +02:00
adabarbulescu 48cf08328f fix(agent): use Git Bash for Windows workspace shell 2026-08-07 23:46:48 +03:00
Wes HuberandClaude Fable 5 e4fa4ae5dd fix(brain): give the Add Memory form a submit button and reliable Enter handling (#5830)
The Brain > Add tab rendered only a text input and category select with no
submit control, and Enter submission relied on a deprecated keypress
listener that is not guaranteed to fire, so the form could not be
submitted at all (#5828).

Add a labelled submit button styled like the neighbouring Skill Import
button (theme-io-btn, inline SVG icon), switch the Enter handler to
keydown with preventDefault, ignore IME composition, and pin both submit
paths with a source-level regression test.

Fixes #5828

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:07:07 +02:00
Samyandsamy 378518f6df Fix #5870: stale skills panel data on tab reopen (#5876)
Remove early-return guard in loadSkills() that skipped both API re-fetch
and renderSkillsList() when the Skills tab was reopened after first load.
The cascade entrance animation is already handled inside renderSkillsList()
via _cascadeNext, so the guard was unnecessary and caused deleted/edited
skills to remain visible until a full page reload.

Co-authored-by: samy <samy@users.noreply.github.com>
2026-08-07 22:06:17 +02:00
Samyandsamy f06a0a30a8 fix(session): restore session URL hash writes (removed in cf4e240a) (#5872)
* Fix: restore session URL hash writes (removed in cf4e240a)

Restores history.replaceState() calls in selectSession() and
materializePendingSession() that were dropped during the July 23 merge.
Without these, chat URLs never update the address bar hash, making
sessions unshareable and causing bare-URL reloads to land on the
welcome screen instead of restoring the last active chat.

Root cause: selectSession() had its hash-write deliberately removed;
materializePendingSession() lost its during a larger refactor that
added the stale-response and incognito guards.

Fixes #5870 (upstream)

* fix: session URL hash lost when sending message mid-stream

Two independent bugs caused the session hash to disappear from the URL:

Bug 1 — ReferenceError in catch block silently killed error recovery
  In handleChatSubmit, two const variables (streamingTTS at line 1922 and
  abortCtrl at line 1741) were declared inside the try block but referenced
  in the catch block. Since const is block-scoped in JavaScript, they were
  undefined in catch, causing a ReferenceError that silently aborted the
  error handler. This prevented materializePendingSession() from ever being
  called, so no hash was written to the URL.
  Fix: Hoisted both as let declarations before the try { block.

Bug 2 — Dual sessions.js ES module instances with mismatched state
  app.js imported sessions.js with a version query string
  (?v=20260722ctxheader4) while every other module imported ./sessions.js
  without one. The browser treated them as different URLs, creating two
  separate module instances with independent _pendingChat and
  currentSessionId state. createDirectChat() set pending on one instance
  while handleChatSubmit() checked hasPendingChat() on the other — so the
  pending session never materialized.
  Fix: Removed the version query string from the sessions.js import in
  app.js and from the modulepreload + script tags in index.html. All
  modules now share a single sessions.js instance.

Bonus guard: _adoptOpenedSessionBeforeAutoCreate() now checks
hasPendingChat() before adopting a stale DOM-active session, preventing
the send path from landing in the wrong session when a New Chat is pending.

---------

Co-authored-by: samy <samy@users.noreply.github.com>
2026-08-07 22:04:53 +02:00
Husam 99566d28b5 fix(chat): stop ArrowUp from eating an unsent multi-line prompt (#5875)
static/app.js carried a near-verbatim copy of the prompt-recall logic in
static/js/composerArrowUpRecall.js, wired as a second capture-phase
keydown listener on the same #message textarea. The copy omitted the
draft guard the module has: it called preventDefault() and
stopImmediatePropagation() unconditionally, then recalled history[0]
over whatever the user had typed.

Because it stopped immediate propagation, the copy won regardless of
registration order — if it ran first the module never saw the event, and
if it ran second the module had already declined to stop propagation on
an unmatched draft. The guard at composerArrowUpRecall.js:109 was
unreachable on the real page, so ArrowUp on a multi-line draft replaced
it with the last sent prompt instead of moving the caret up a line.

Delete the duplicate. The module keeps ownership of ArrowUp/ArrowDown
recall, which is the behavior MODULE_SUMMARY.md documents ("on an empty
composer") and the behavior tests/test_composer_arrow_up_recall_js.py
already pins via test_non_empty_composer_does_not_recall and
test_multiline_caret_navigation_preserved.

Also correct a stale comment in the module that described the deleted
behavior and contradicted the guard 35 lines above it, and add a
regression test asserting app.js does not reintroduce a second handler.

Fixes #5862
2026-08-07 19:34:50 +02:00
Husam f1e96d102e fix(tool_parsing): require a pipe on the Qwen bare end marker (#5829)
The `end` branch of _QWEN_BARE_MARKER_RE had both pipes optional
(`\|?end\|?`), so it also matched a bare `end` between whitespace and
replaced it with a space. Messages containing Ruby, Lua or shell code that
closes a block with a lone `end` had those lines deleted, and ordinary prose
lost the word too.

Require at least one pipe so only real turn markers match; `|end`, `end|`,
`|end|` and `/|end|` strip exactly as before. Applied to the duplicated
pattern in static/js/chatRenderer.js as well.

Fixes #5547
2026-08-07 19:33:14 +02:00
Jakub Grula 36d4098421 fix: Edit box formatting was removing triple tick boxes (#5737) 2026-08-07 19:15:50 +02:00
adabarbulescu 5ddef23d94 fix(welcome): rotate startup tips (#5871) 2026-08-07 19:12:21 +02:00
Ashvin c8a012d4d2 fix(memory): don't let an unreadable store get overwritten with an empty one (#5831)
* fix(memory): don't let an unreadable store get overwritten with an empty one

load_all() answered a failed read the same way it answered an empty store:
with []. Every mutation path is a read-modify-write (load the whole file,
change it, save it back), so a failed read became

    load_all() -> []  ->  [].append(new)  ->  save([new])

and save() is atomic, so the replacement stuck.

The case that actually destroys data is a store that is READABLE but not
parseable - a truncated file, or one holding {} instead of []. Nothing
obstructs the write, so adding a memory returns HTTP 200 and every memory
already stored is gone. Verified end-to-end against a running instance: on the
current code a truncated memory.json plus one add leaves the file holding only
the new entry. Truncation is reachable - core/database.py rewrites memory.json
during migration with a plain open(.., "w") + json.dump, which is not atomic.

A live exclusive lock is not the dangerous case: it blocks the read and the
os.replace alike, so the save fails too and the store survives. That path
currently 500s and loses nothing.

_read_entries() now returns [] only when the file genuinely does not exist and
raises MemoryStoreUnreadable for every other failure, including a store that
parses but is not a JSON array. load_all() keeps the old lenient behaviour so
display, search and context injection still degrade quietly instead of
breaking chat. The read-modify-write callers switch to load_all_for_update(),
which propagates the error: the memory routes turn it into a 503 and change
nothing, backup import refuses rather than saving only the incoming rows, and
auto-extraction and the audit merge skip the write. The audit merge mattered
most - it rebuilds the whole file from one owner's slice plus everyone else's
rows, so an empty read there dropped every other tenant's memories.

The corrupt-JSON path still gets its one shot at the legacy memory.txt
migration before raising, so that recovery is unchanged.

The two updated fakes gained load_all_for_update because the real class has it;
MagicMock would otherwise hand the import path a Mock instead of the seeded list.

Fixes #5673

* fix(memory): fail closed on the remaining read-modify-write add paths

The strict loader landed with the routes, the backup import and the extractor
converted, but three read-modify-write sinks still called load_all(), which
degrades an unreadable store to []. Two of them are the paths users actually
reach, so the data loss in #5673 stayed reproducible:

- src/ai_interaction.py do_manage_memory, action "add" — reached from ordinary
  chat via src/tool_execution.py:793 -> dispatch_ai_tool. "Remember that I
  prefer X" against an unreadable store wrote a one-entry file over it and
  reported success.
- mcp_servers/memory_server.py, action "add" — the same shape through
  _scope_entries(), registered as a built-in in src/builtin_mcp.py.
- src/memory_provider.py NativeMemoryProvider.remember and .delete — wired
  into app state in src/app_initializer.py but not consumed outside tests yet,
  converted here so the pattern is uniform before it goes live.

The MCP server takes _scope_entries(for_update=True) so list keeps the lenient
read. The edit and delete branches on both tool paths were already fail-closed
by accident — an empty view matches nothing and returns before the save — so
they are left alone.

The three new tests drive the real entry points rather than replaying the
shape, and use a truncated store, which is the case that reads back fine so
nothing stops the save. Each asserts memory.json is byte-identical afterwards;
all three fail on the previous commit with the store overwritten.
2026-08-06 02:33:50 -06:00
adabarbulescu 20e7fc0164 fix(skills): require manage_skills action (#5856) 2026-08-04 04:17:45 -06:00
Ashvin 9d686180dd fix(integrations): pin api_call to the SSRF-validated IP (#5727)
* fix(integrations): pin api_call to the SSRF-validated IP

execute_api_call runs check_outbound_url on the target, but that guard only
resolves the host to answer (ok, reason) and hands back no address. The request
right after it opened a plain httpx.AsyncClient, which resolves the host again at
connect time. A base_url host on a low TTL can pass the guard as a public IP and
then flip to 169.254.169.254 for the connect, so the call lands on cloud metadata
with the integration's stored auth headers attached.

Resolve once, remember the IPs the guard actually validated, and pin the client's
socket to that set through a small AnyIO-backed transport. SNI and the Host header
still come from the URL, so TLS and vhost routing are unchanged; connect-time
fallback stays inside the approved address set over one shared deadline. This is
the same pinning the webhook sender and web-fetch paths already do -- api_call was
the last outbound path that skipped it.

Fixes #5513

* fix(integrations): de-duplicate the pinned IP list

_default_resolver calls getaddrinfo(host, None) with no socktype filter, so
glibc returns one record per socktype and a single-homed host comes back three
times over. _validated_ips kept every entry, so the transport pinned the same
address repeatedly and the connect fallback could spend its shared deadline
retrying one dead address instead of moving on to a genuinely different one.

Windows getaddrinfo collapses those duplicate records, which is why the
ip-literal pin test only failed on CI and not locally.
2026-08-04 04:17:41 -06:00
Tal.Yuan bb719f217a refactor(routes): move document domain into routes/document/ subpackage (#5885)
Slice 2m of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves document_routes.py
(1810 lines) and document_helpers.py (243 lines) into routes/document/,
leaving backward-compat sys.modules shims at the old paths. Pure file
reorganization, no behavior change.

Both shims use sys.modules replacement so the `import ... as droutes` +
`droutes.SessionLocal = ...` / `monkeypatch.setattr(droutes, ...)` pattern
in multiple tests, and the `sys.modules.pop("routes.document_helpers")` +
re-import pattern in test_security_regressions.py, all reach the canonical
modules.

The canonical document_routes.py imports helpers from the canonical path
(routes.document.document_helpers), not the legacy shim.

Three source-introspection test sites repointed to the new canonical path:
- test_imap_mailbox_quoting.py
- test_model_helper_owner_scope.py
- test_vision_owner_scope.py (shared with other domains; document entry repointed)

Adds tests/test_document_routes_shim.py to pin the sys.modules shim contract
for both modules.

Verified: compileall clean; full suite 4789 passed, 3 skipped.
2026-08-04 03:54:55 -06:00
Tal.Yuan fb8c391a88 refactor(routes): move webhook domain into routes/webhook/ subpackage (#5781)
Slice 2l of the route-domain reorganization (#4082/#4071). Moves
webhook_routes.py into routes/webhook/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
One source-introspection test repointed (test_api_chat_security.py).
2026-08-03 20:44:31 +02:00
Tal.Yuan 0de76c4056 refactor(routes): move vault domain into routes/vault/ subpackage (#5780)
Slice 2k of the route-domain reorganization (#4082/#4071). Moves
vault_routes.py into routes/vault/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
2026-08-03 20:44:00 +02:00
RaresKeY 25c9e735ef fix(email): open settings after OAuth callback (#5803) 2026-07-30 14:57:07 +01:00
RaresKeY 28c333e647 fix(email): preserve OAuth SMTP security (#5802) 2026-07-30 12:24:39 +01:00
HusamandAlexandre Teixeira 84709a00d9 fix(llm): omit temperature for major-only Opus ids (claude-opus-5) (#5761)
The version pattern in _anthropic_rejects_temperature() required a minor
component, so major-only ids like `claude-opus-5` never matched and the
guard reported that the model accepts `temperature`. Anthropic rejects the
field outright on Opus 4.7+, so every such call returned HTTP 400 and the
stream aborted with zero tokens ("the model returned an empty response").

Make the minor optional and read a missing minor as `.0`. The major is also
capped at 1-2 digits with a no-trailing-digit lookahead, mirroring the
minor: once the minor is optional, a greedy major would swallow the date in
`claude-3-opus-20240229` and read it as version 20240229, dropping
temperature from a model that accepts it.

Fixes #5753

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-07-30 11:30:00 +01:00
Husam 578312200a fix(markdown): restore extracted blocks verbatim so $& and $$ survive (#5768)
The placeholder-restore pass in mdToHtml put code, math, mermaid and
allowed-HTML blocks back with a string replacement, so String.replace read
`$&`, `` $` ``, `$'` and `$$` in the *replacement* as substitution patterns.
A fenced block containing them rendered corrupted: `$&` re-inserted the
placeholder (`perl -pe 's/world/$& again/'` became
`s/world/___CODE_BLOCK_0___amp; again/`), `` $` `` and `$'` spliced in the
surrounding document, and `$$` collapsed to a single `$`.

Pass a function replacer at all four sites, matching the inline-code site
below them, which was already fixed this way. A function's return value is
inserted verbatim with no `$` interpretation.

The inline-code comment claimed `echo $1` would be read as a back-reference;
with a string search value there are no capture groups, so `$1` is already
literal. Reworded to name the four sequences that do corrupt.

Fixes #5663
2026-07-30 10:48:31 +01:00
HusamandAlexandre Teixeira f23221420f fix(skills): replace deprecated utcnow in skill timestamp helper (#5777)
* fix(skills): replace deprecated utcnow in skill timestamp helper

_now_iso() builds the 'created' value in skill frontmatter. datetime.utcnow()
returns a naive datetime and has been deprecated since Python 3.12, scheduled
for removal. Switch to the timezone-aware datetime.now(timezone.utc), keeping
the serialized YYYY-MM-DDTHH:MM:SSZ shape unchanged so existing skill files
keep parsing.

timezone.utc is used rather than the datetime.UTC alias, which is 3.11+ only.

Adds regression tests covering the deprecation, the serialized shape, and
UTC correctness under a non-UTC local timezone -- the last guards against a
bare datetime.now(), which yields the same shape but local wall time.

Fixes #5697

* test(skills): skip timezone mutation where unsupported

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-07-30 09:54:59 +01:00
holden093 6a84398e75 fix(skills): use utility model for skill tests instead of chat default (#5746)
Skill tests are background automation tasks (like auto-naming and
memory audit) and should use the configured utility model. Previously
they resolved via resolve_endpoint("default") which returned the
chat model, bypassing the utility model entirely.

This completes the sweep started in PR #4027 which fixed auto-naming
and memory audit but missed skill tests.
2026-07-30 09:06:31 +01:00
RaresKeY 3250a4ce68 fix(ci): clear review label when issues close (#5813)
The issue-close lifecycle change is narrowly scoped and correct. Closed issues remove the stale \`ready for review\` label and return before normal validation can restore it. Focused regressions cover closure and subsequent edits to a closed issue.

The branch was updated onto current \`dev\`. The focused test, merged-result validation, diff checks, and GitHub CI passed. No blocking review threads remain.
2026-07-29 22:04:28 +01:00
Boody cb0f6af002 Merge pull request #5822 from bitboody/tts_cache_fix
feat(tts): implement TTS cache size limit and eviction policy
2026-07-29 16:48:41 +03:00
Boody 9297bed5b9 add ODYSSEUS_TTS_CACHE_MAX_BYTES environment variable to docker-compose 2026-07-29 12:54:55 +03:00
Boody 2e631ad816 improve cache size calculation by filtering file types 2026-07-29 12:47:55 +03:00
Boody d183fe545b add test for cache eviction handling unlink errors gracefully 2026-07-29 12:42:20 +03:00
Boody 9914651cc9 improve cache eviction logic to handle file access errors and ensure stability 2026-07-29 12:41:31 +03:00
Boody 46905ab9b0 added ODYSSEUS_TTS_CACHE_MAX_BYTES env variable to docker compose files 2026-07-29 12:32:43 +03:00
Boody 61c138d9e7 fixed .env.example ODYSSEUS_TTS_CACHE_MAX_BYTES into correct 500 MBs 2026-07-29 12:26:09 +03:00
Tal.Yuan 25a4d134b1 refactor(routes): move search domain into routes/search/ subpackage (#5779)
Slice 2j of the route-domain reorganization (#4082/#4071). Moves
search_routes.py into routes/search/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
2026-07-28 22:26:29 +02:00
Boody 98e4d8451b fix(tests): update environment variable for TTS cache limit to include ODYSSEUS prefix 2026-07-28 22:00:54 +03:00
Boody 5104a9a967 feat(tts): implement TTS cache size limit and eviction policy 2026-07-28 21:34:03 +03:00
RaresKeY 01790c2f08 fix(mcp): keep built-in servers on SDK v1 (#5820) 2026-07-28 18:11:34 +01:00
StressTestor e222e92153 fix(personal): serialize add/remove/reload on an async job lock
The #5558 fix took the job lock INSIDE the threadpool worker and only on the
add path, so (1) remove_directory and /reload mutated PersonalDocsManager's
unsynchronized list/index concurrently with an in-flight add — the inconsistent
state the PR claimed to prevent — and (2) a queued add blocked on the lock while
holding an AnyIO threadpool token, starving the shared pool.

Move the lock to an asyncio.Lock acquired in the async handler BEFORE offloading,
and route add, remove and reload through it. A waiting request now parks on the
event loop instead of pinning a worker, and all three mutators are serialized so
the 'add/remove are serialized and cannot leave inconsistent state' guarantee
holds. remove and reload also run their blocking work off the event loop. The
lock is per-router so each app binds it to its own loop; single-process scope.

Tests: add-vs-remove and add-vs-reload serialization regressions (async via
ASGITransport, since asyncio.Lock deadlocks starlette TestClient's portal); the
existing add-vs-add test converted to the same driver.
2026-07-27 20:43:04 +00:00
StressTestor b91f48f50a fix(personal): run directory indexing off the event loop (#5558)
POST /api/personal/add_directory called rag.index_personal_documents
inline from an async handler, so the whole indexing job (os.walk, file
reads, per-chunk embedding, Chroma inserts) ran on the event loop and
every other request queued behind it. Indexing a real directory froze
the UI and API for 25+ minutes with no sign of life.

Move the blocking section into the threadpool via run_in_threadpool.
personal_docs_manager.add_directory stays inside it because its
refresh_index() re-extracts text across tracked directories, which is
also blocking work. A module-level lock serializes index jobs so the
threadpool move does not introduce parallel jobs racing
PersonalDocsManager's unsynchronized list mutations and file writes;
they previously serialized on the blocked loop, so one-at-a-time is
behavior parity.
2026-07-27 20:43:04 +00:00
154 changed files with 17018 additions and 4837 deletions
+1
View File
@@ -189,6 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
# ============================================================
# Host Docker access (explicit opt-in)
+10 -3
View File
@@ -153,6 +153,16 @@ module.exports = async ({ github, context, core }) => {
}
}
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
// Closed issues are no longer awaiting review.
// This also prevents later edits to closed issues from restoring the label.
if (issue.state === 'closed') {
await dropLabel(LABEL_GOOD);
return;
}
// ── Find existing bot comment to update in-place ──────────────────────────
const MARKER = '<!-- issue-description-check -->';
const { data: comments } = await github.rest.issues.listComments({
@@ -160,9 +170,6 @@ module.exports = async ({ github, context, core }) => {
});
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
if (failures.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
+2 -5
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
pull_request:
# Least privilege: none of the jobs write to the repo.
@@ -103,10 +103,7 @@ jobs:
python-tests:
name: Python tests (pytest)
runs-on: ubuntu-latest
# Informational for now: the suite has known flaky / environment-dependent
# failures (test isolation + embedding-model assertions). Tracked under the
# ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true
# Make Python test validation authoritative for the configured scope.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@@ -2,7 +2,7 @@ name: ci / issue description check
on:
issues:
types: [opened, edited, reopened]
types: [opened, edited, reopened, closed]
permissions:
issues: write
+5 -5
View File
@@ -692,7 +692,7 @@ from routes.history.history_routes import setup_history_routes
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
# Search
from routes.search_routes import setup_search_routes
from routes.search.search_routes import setup_search_routes
app.include_router(setup_search_routes(config))
# Presets
@@ -739,7 +739,7 @@ app.include_router(setup_stt_routes(stt_service))
logger.info("STT service initialized (provider managed via settings)")
# Documents (artifacts/canvas)
from routes.document_routes import setup_document_routes
from routes.document.document_routes import setup_document_routes
document_router = setup_document_routes(session_manager, upload_handler)
app.include_router(document_router)
@@ -805,7 +805,7 @@ app.include_router(setup_font_routes())
# MCP (Model Context Protocol)
from src.mcp_manager import McpManager
from src.agent_tools import set_mcp_manager
from routes.mcp_routes import setup_mcp_routes
from routes.mcp.mcp_routes import setup_mcp_routes
mcp_manager = McpManager()
set_mcp_manager(mcp_manager)
@@ -820,7 +820,7 @@ set_ai_rag_manager(rag_manager, personal_docs_mgr)
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
# Webhooks
from routes.webhook_routes import setup_webhook_routes
from routes.webhook.webhook_routes import setup_webhook_routes
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
# API Tokens
@@ -852,7 +852,7 @@ app.include_router(setup_codex_routes(
))
app.include_router(setup_claude_routes())
from routes.vault_routes import setup_vault_routes
from routes.vault.vault_routes import setup_vault_routes
app.include_router(setup_vault_routes())
# Contacts (CardDAV)
+8 -4
View File
@@ -15,17 +15,21 @@ from __future__ import annotations
import json
import os
import uuid
from typing import Any, Optional
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
"""Atomically persist `data` as JSON at `path`.
The temp file uses the live PID as a suffix so two processes saving the
same file (e.g. unit tests) don't collide on the rename target.
The temp file uses a random suffix so two concurrent writers saving the
same file don't collide on the rename target. A PID suffix does not do
this: the PID is constant for the life of a process, so two writers on
the same path within one process (or one single-process container, where
the PID never changes at all) still race for the same temp file.
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}"
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent)
f.flush()
@@ -37,7 +41,7 @@ def atomic_write_text(path: str, text: str) -> None:
if not isinstance(text, str):
raise TypeError("atomic_write_text expects a string")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}"
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
+218 -60
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import unquote, urlparse
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.types import TypeDecorator
from sqlalchemy.ext.declarative import declarative_base, declared_attr
@@ -430,6 +430,93 @@ class EmailAccount(TimestampMixin, Base):
)
class EmailAccountOwnerLock(Base):
"""Durable per-owner mutex for email-account default mutations.
Row-locking databases serialize mutations by locking this row before they
inspect or stage EmailAccount changes. SQLite uses ``BEGIN IMMEDIATE``
instead, because it ignores ``SELECT ... FOR UPDATE``; keeping the table in
the shared metadata still makes the non-SQLite path available without a
separate migration. The empty key represents the normalized legacy /
unconfigured scope shared by ``owner IS NULL`` and ``owner = ''`` rows.
"""
__tablename__ = "email_account_owner_locks"
owner_key = Column(String, primary_key=True)
_EMAIL_ACCOUNT_DEFAULT_INDEX = "ux_email_accounts_one_default_per_owner"
_EMAIL_ACCOUNT_DEFAULT_INDEX_DDL = {
"sqlite": (
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
"ON email_accounts (COALESCE(owner, '')) WHERE is_default = 1"
),
"postgresql": (
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
"ON email_accounts ((COALESCE(owner, ''))) WHERE is_default IS TRUE"
),
}
# SQLAlchemy cannot express one portable partial, functional index across the
# two supported database families. Register dialect-specific DDL so fresh
# databases get the invariant as part of create_all(); the startup migration
# below installs the same index on existing databases after normalizing legacy
# duplicate rows.
for _dialect_name, _index_ddl in _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.items():
event.listen(
EmailAccount.__table__,
"after_create",
DDL(_index_ddl).execute_if(dialect=_dialect_name),
)
def lock_email_account_owner_mutations(db, *owners: str) -> None:
"""Lock normalized email-account owner scopes in canonical order.
``NULL`` and the empty string are one legacy/single-user owner partition,
matching the unique default-account index. SQLite has only a database
writer reservation, while row-locking databases use durable mutex rows.
Sorting all requested owner keys keeps multi-owner operations such as user
rename from deadlocking with another mutation that requests the same keys
in the opposite order.
"""
from sqlalchemy.exc import IntegrityError
owner_keys = sorted({owner or "" for owner in owners} or {""})
if db.get_bind().dialect.name == "sqlite":
db.execute(text("BEGIN IMMEDIATE"))
return
for owner_key in owner_keys:
lock_row = db.get(
EmailAccountOwnerLock,
owner_key,
with_for_update=True,
)
if lock_row is not None:
continue
inserted = False
try:
with db.begin_nested():
db.add(EmailAccountOwnerLock(owner_key=owner_key))
db.flush()
inserted = True
except IntegrityError:
# A competing transaction created the mutex row first. Once its
# insert commits, lock that durable row before touching accounts.
pass
if not inserted:
(
db.query(EmailAccountOwnerLock)
.filter(EmailAccountOwnerLock.owner_key == owner_key)
.with_for_update()
.one()
)
class ModelEndpoint(TimestampMixin, Base):
"""Admin-configured model endpoints. Models are auto-discovered via /v1/models."""
__tablename__ = "model_endpoints"
@@ -1812,72 +1899,142 @@ class Integration(TimestampMixin, Base):
def _migrate_seed_email_account():
"""If email_accounts is empty and settings.json has legacy flat imap_host/smtp_host
keys, create a single default account from them so nothing breaks for users who
upgraded. Safe to run repeatedly — it short-circuits once any row exists."""
def _migrate_email_account_default_invariant():
"""Normalize legacy duplicates and install durable at-most-one enforcement.
Older databases only had a non-unique ``(owner, is_default)`` lookup index.
Keep the oldest default deterministically in each normalized owner scope,
then add the same partial functional unique index used for fresh schemas.
"""
dialect_name = engine.dialect.name
index_ddl = _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.get(dialect_name)
if index_ddl is None:
logger.warning(
"Email-account default uniqueness is not available for database "
"dialect %s; mutations remain serialized but are not protected by "
"a database constraint",
dialect_name,
)
return
try:
with engine.connect() as conn:
tables = [r[0] for r in conn.execute(text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='email_accounts'"
))]
if "email_accounts" not in tables:
return
existing = conn.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
if existing > 0:
with engine.begin() as conn:
if not inspect(conn).has_table(EmailAccount.__tablename__):
return
default_rows = conn.execute(text("""
SELECT id, owner
FROM email_accounts
WHERE is_default IS TRUE
ORDER BY
COALESCE(owner, ''),
CASE WHEN created_at IS NULL THEN 1 ELSE 0 END,
created_at,
id
""")).mappings()
seen_owner_keys = set()
duplicate_ids = []
for row in default_rows:
owner_key = row["owner"] or ""
if owner_key in seen_owner_keys:
duplicate_ids.append(row["id"])
else:
seen_owner_keys.add(owner_key)
import json as _json
import uuid as _uuid
from pathlib import Path
settings_file = Path(SETTINGS_FILE)
if not settings_file.exists():
return
try:
s = _json.loads(settings_file.read_text(encoding="utf-8"))
except Exception:
return
for account_id in duplicate_ids:
conn.execute(
text("UPDATE email_accounts SET is_default = :value WHERE id = :id"),
{"value": False, "id": account_id},
)
conn.execute(text(index_ddl))
imap_host = (s.get("imap_host") or "").strip()
smtp_host = (s.get("smtp_host") or "").strip()
if not imap_host and not smtp_host:
return # nothing to migrate
if duplicate_ids:
logger.warning(
"Normalized %d duplicate default email account(s) before "
"installing %s",
len(duplicate_ids),
_EMAIL_ACCOUNT_DEFAULT_INDEX,
)
except Exception:
# Starting without the constraint would silently retain the race this
# migration is intended to close. Fail startup so an operator sees and
# can repair an incompatible schema instead of accepting unsafe writes.
logger.exception("Failed to enforce the email-account default invariant")
raise
def _migrate_seed_email_account():
"""Atomically seed one legacy default account when no account exists.
Reading settings is intentionally done before taking the owner mutex. The
decisive emptiness check and insert share one locked transaction, so two
application workers starting together cannot both seed a default row.
"""
import json as _json
import uuid as _uuid
settings_file = Path(SETTINGS_FILE)
if not settings_file.exists():
return
try:
s = _json.loads(settings_file.read_text(encoding="utf-8"))
except Exception:
return
imap_host = (s.get("imap_host") or "").strip()
smtp_host = (s.get("smtp_host") or "").strip()
if not imap_host and not smtp_host:
return
db = None
try:
if not inspect(engine).has_table(EmailAccount.__tablename__):
return
db = SessionLocal()
lock_email_account_owner_mutations(db, "")
existing = db.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
if existing > 0:
return
now = utcnow_naive()
with engine.begin() as conn:
conn.execute(text("""
INSERT INTO email_accounts
(id, owner, name, is_default, enabled,
imap_host, imap_port, imap_user, imap_password, imap_starttls,
smtp_host, smtp_port, smtp_user, smtp_password,
from_address, created_at, updated_at)
VALUES
(:id, :owner, :name, :is_default, :enabled,
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
:from_address, :created_at, :updated_at)
"""), {
"id": _uuid.uuid4().hex,
"owner": None,
"name": "Default",
"is_default": True,
"enabled": True,
"imap_host": imap_host,
"imap_port": int(s.get("imap_port") or 993),
"imap_user": s.get("imap_user") or "",
"imap_password": s.get("imap_password") or "",
"imap_starttls": bool(s.get("imap_starttls", True)),
"smtp_host": smtp_host,
"smtp_port": int(s.get("smtp_port") or 465),
"smtp_user": s.get("smtp_user") or "",
"smtp_password": s.get("smtp_password") or "",
"from_address": s.get("email_from") or "",
"created_at": now,
"updated_at": now,
})
logging.getLogger(__name__).info("Seeded email_accounts 'Default' from settings.json")
db.execute(text("""
INSERT INTO email_accounts
(id, owner, name, is_default, enabled,
imap_host, imap_port, imap_user, imap_password, imap_starttls,
smtp_host, smtp_port, smtp_user, smtp_password,
from_address, created_at, updated_at)
VALUES
(:id, :owner, :name, :is_default, :enabled,
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
:from_address, :created_at, :updated_at)
"""), {
"id": _uuid.uuid4().hex,
"owner": None,
"name": "Default",
"is_default": True,
"enabled": True,
"imap_host": imap_host,
"imap_port": int(s.get("imap_port") or 993),
"imap_user": s.get("imap_user") or "",
"imap_password": s.get("imap_password") or "",
"imap_starttls": bool(s.get("imap_starttls", True)),
"smtp_host": smtp_host,
"smtp_port": int(s.get("smtp_port") or 465),
"smtp_user": s.get("smtp_user") or "",
"smtp_password": s.get("smtp_password") or "",
"from_address": s.get("email_from") or "",
"created_at": now,
"updated_at": now,
})
db.commit()
logger.info("Seeded email_accounts 'Default' from settings.json")
except Exception as e:
logging.getLogger(__name__).warning(f"seed email account migration: {e}")
if db is not None:
db.rollback()
logger.warning("seed email account migration: %s", e)
finally:
if db is not None:
db.close()
# WARNING: Foreign-key enforcement is enabled globally for all SQLite connections.
@@ -1960,6 +2117,7 @@ def init_db():
_migrate_add_crew_member_id()
_migrate_add_assistant_columns()
_migrate_add_email_smtp_security()
_migrate_email_account_default_invariant()
_migrate_seed_email_account()
_migrate_add_calendar_metadata()
_migrate_add_calendar_is_utc()
+41 -12
View File
@@ -194,7 +194,12 @@ class SessionManager:
is_important=getattr(db_session, 'is_important', False) or False,
)
session.message_count = getattr(db_session, 'message_count', len(history))
# The rows just loaded are the whole transcript, so they — not the
# denormalized sessions.message_count column — are the truth for this
# cached object. get_session's hydration gate compares against this
# number; seeding it from a drifted column would ask for a reload that
# can never close the gap.
session.message_count = len(history)
return session
# ------------------------------------------------------------------
@@ -398,30 +403,50 @@ class SessionManager:
# ------------------------------------------------------------------
def get_session(self, session_id: str) -> Session:
"""Get a session by ID, loading from DB if needed.
"""Get a session by ID, loading complete DB history when needed.
Sessions seeded by `load_sessions` start with empty history. The
first read here hydrates them with the message rows.
Sessions seeded by ``load_sessions`` start with empty history, and a
cached session can also become partially stale. Refresh metadata first,
then hydrate whenever the cached transcript is short of the stored rows.
Model-send routes enter through this method before building context,
while paginated display history reads SQLite directly.
The gate compares against ``sync_session_metadata``'s reconciled count
(the real ``chat_messages`` total), never the denormalized column, so a
hydrate always closes the gap and the next read is a cache hit.
"""
if session_id not in self.sessions:
self._load_session_from_db(session_id)
else:
cached = self.sessions[session_id]
# Lazy hydrate: metadata-only entries get their messages on first read.
if not cached.history and getattr(cached, "message_count", 0) > 0:
self._load_session_from_db(session_id)
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
# DB row while a session object is still cached in RAM.
# DB row while a session object is still cached in RAM. Refreshing first
# also exposes the authoritative message count before completeness is
# checked.
self.sync_session_metadata(session_id)
cached = self.sessions[session_id]
cached_count = len(cached.history or [])
stored_count = int(getattr(cached, "message_count", 0) or 0)
if cached_count < stored_count:
self._load_session_from_db(session_id)
# Update last_accessed
self._touch_session(session_id)
return self.sessions[session_id]
def sync_session_metadata(self, session_id: str) -> bool:
"""Refresh non-message session fields from the DB into the cached object."""
"""Refresh non-message session fields from the DB into the cached object.
``message_count`` is reconciled against the real ``chat_messages`` rows
rather than copied from the denormalized ``sessions.message_count``
column. That column drifts in normal operation — ``_persist_message``
swallows a failed insert but ``add_message`` has already appended in
memory, so the next successful persist writes rows+1, and a persist for
an uncached session writes 0. Hydration keys off this number: a
drifted-high column would reload the whole transcript on every warm
read, and a drifted-low one would leave the model a truncated one.
"""
session = self.sessions.get(session_id)
if session is None:
return False
@@ -444,7 +469,11 @@ class SessionManager:
session.archived = db_session.archived
session.owner = getattr(db_session, "owner", None)
session.is_important = getattr(db_session, "is_important", False) or False
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
session.message_count = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.count()
)
return True
except Exception as e:
logger.error(f"Error syncing session metadata {session_id}: {e}")
+1
View File
@@ -67,6 +67,7 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+1
View File
@@ -66,6 +66,7 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+1
View File
@@ -55,6 +55,7 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+26
View File
@@ -309,6 +309,32 @@ 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
+22 -4
View File
@@ -17,6 +17,8 @@ from mcp.types import Tool, TextContent
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.memory import MemoryStoreUnreadable
server = Server("memory")
# Late-initialized managers (set during first tool call)
@@ -29,6 +31,10 @@ _OWNER_SCOPE_ERROR = (
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
)
_UNREADABLE_STORE_ERROR = (
"Error: Memory store is temporarily unreadable — nothing was saved. "
"Repair or restore memory.json, then retry."
)
def _configured_owner() -> str | None:
@@ -51,9 +57,21 @@ def _owner_scoped_store(entries: list[dict]) -> bool:
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
"""Return configured owner, all entries, visible entries, and optional error."""
entries = _memory_manager.load_all()
def _scope_entries(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
"""Return configured owner, all entries, visible entries, and optional error.
``for_update=True`` is for read-modify-write callers. They save the ``all
entries`` list back, so an unreadable store must be reported as an error
instead of degrading to ``[]`` otherwise the save writes their one new
entry over the whole store (issue #5673).
"""
if for_update:
try:
entries = _memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
return None, [], [], f"{_UNREADABLE_STORE_ERROR} ({e})"
else:
entries = _memory_manager.load_all()
owner = _configured_owner()
if owner is None and _owner_scoped_store(entries):
return None, entries, [], _OWNER_SCOPE_ERROR
@@ -161,7 +179,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
category = arguments.get("category", "fact")
if not text:
return _text_result("Error: Memory text cannot be empty")
owner, memories, _visible, scope_error = _scope_entries()
owner, memories, _visible, scope_error = _scope_entries(for_update=True)
if scope_error:
return _text_result(scope_error)
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
+4 -1
View File
@@ -38,7 +38,10 @@ python-dateutil
caldav
cryptography
bcrypt
mcp
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
# servers are migrated together.
mcp<2
pyotp
qrcode[pil]
croniter
+53 -1
View File
@@ -345,9 +345,61 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
# docs, email accounts, tasks, etc.
try:
from sqlalchemy import func
from core.database import Base, SessionLocal
from core.database import (
Base,
EmailAccount,
SessionLocal,
lock_email_account_owner_mutations,
)
db = SessionLocal()
try:
# Email-account defaults are protected by per-owner mutex rows.
# A rename crosses two owner partitions, so lock both in the
# shared helper's canonical order before inspecting either.
lock_email_account_owner_mutations(
db, old_username, new_username
)
source_default_ids = [
row[0]
for row in (
db.query(EmailAccount.id)
.filter(
func.lower(EmailAccount.owner) == old_username,
EmailAccount.is_default == True, # noqa: E712
)
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
.all()
)
]
destination_default_ids = [
row[0]
for row in (
db.query(EmailAccount.id)
.filter(
func.lower(EmailAccount.owner) == new_username,
EmailAccount.is_default == True, # noqa: E712
)
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
.all()
)
]
if destination_default_ids:
clear_default_ids = (
destination_default_ids[1:] + source_default_ids
)
else:
clear_default_ids = source_default_ids[1:]
if clear_default_ids:
(
db.query(EmailAccount)
.filter(EmailAccount.id.in_(clear_default_ids))
.update(
{EmailAccount.is_default: False},
synchronize_session=False,
)
)
for mapper in Base.registry.mappers:
model = mapper.class_
if not hasattr(model, "owner"):
+10 -1
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request, Response
from core.middleware import require_admin
from services.memory import MemoryStoreUnreadable
from src.auth_helpers import get_current_user
from src.settings import load_settings, save_settings, load_features, save_features
@@ -76,7 +77,15 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
# ── Memories ──
if "memories" in body and isinstance(body["memories"], list):
existing = memory_manager.load_all()
# Strict load: importing on top of an unreadable store would write
# only the incoming rows back and drop everything already saved.
try:
existing = memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Refusing to import memories: %s", e)
raise HTTPException(
503, "Memory store is temporarily unreadable — nothing was imported."
)
# Dedup against THIS user's own memories only. Using every tenant's
# rows (load_all) meant a memory whose text matched any other
# user's was silently skipped, so the importing user lost their own
+115 -7
View File
@@ -10,6 +10,7 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from sqlalchemy import or_, and_
from sqlalchemy.exc import IntegrityError
from dateutil.rrule import rrulestr
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
@@ -221,22 +222,125 @@ class EventUpdate(BaseModel):
# ── Helpers ──
_DEFAULT_CALENDAR_NAMESPACE = uuid.UUID("4840613a-9847-4a3b-bd75-19e6bc5fc3ce")
def _default_calendar_id(owner: str, collision_index: int = 0) -> str:
"""Return one stable primary-key candidate for an owner's lazy default.
Slot zero preserves the original owner-derived identifier. Later slots
let a username be reused after its prior calendar was migrated to another
owner during a rename, without making concurrent first use choose random
and therefore divergent identifiers.
"""
if collision_index == 0:
candidate_name = owner
else:
candidate_name = json.dumps(
[owner, collision_index],
ensure_ascii=False,
separators=(",", ":"),
)
return str(uuid.uuid5(_DEFAULT_CALENDAR_NAMESPACE, candidate_name))
def _begin_sqlite_default_write(db) -> None:
"""Serialize an absent-default check with other SQLite writers.
SQLite's default deferred transactions allow two workers to both read an
empty calendar set before either writes. ``BEGIN IMMEDIATE`` acquires the
writer reservation before the second, authoritative lookup. We issue it
only when the driver has not already opened a write transaction; a caller
with a pending write already owns the required reservation.
"""
connection = db.connection()
dbapi_connection = connection.connection
driver_connection = getattr(
dbapi_connection,
"driver_connection",
dbapi_connection,
)
if not getattr(driver_connection, "in_transaction", False):
connection.exec_driver_sql("BEGIN IMMEDIATE")
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
"""Create default calendar if none exist for this owner."""
"""Return the owner's calendar, staging a default in the caller's transaction.
A stable owner-derived primary key makes concurrent first-use inserts
converge on one row on every SQL backend. SQLite additionally serializes
the absent-row check because its deferred transactions otherwise permit
both workers to read the gap before either writes. Other backends recover
a lost insert race inside a savepoint so the caller's event transaction
remains usable and atomic.
"""
owner = owner or FALLBACK_OWNER
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
if not cal:
if cal:
return cal
dialect = db.get_bind().dialect.name
if dialect == "sqlite":
_begin_sqlite_default_write(db)
# Another worker may have committed while BEGIN IMMEDIATE waited.
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
if cal:
return cal
collision_index = 0
while True:
default_id = _default_calendar_id(owner, collision_index)
if dialect == "sqlite":
# BEGIN IMMEDIATE above makes this occupancy check authoritative:
# another SQLite writer cannot rename, delete, or claim this slot
# until the caller commits or rolls back.
occupant = db.query(CalendarCal).filter(
CalendarCal.id == default_id,
).first()
if occupant is not None:
if occupant.owner == owner:
return occupant
collision_index += 1
continue
cal = CalendarCal(
id=str(uuid.uuid4()),
id=default_id,
owner=owner,
name="Personal",
color="#5b8abf",
source="local",
)
db.add(cal)
db.commit()
db.refresh(cal)
return cal
if dialect == "sqlite":
db.add(cal)
db.flush()
return cal
try:
# A uniqueness failure rolls back only this savepoint, not an event
# or reminder already staged by the caller's outer transaction.
with db.begin_nested():
db.add(cal)
db.flush()
return cal
except IntegrityError:
# Use a locking/current read so repeatable-read backends can observe
# the row that won after our transaction's original empty snapshot.
occupant = db.query(CalendarCal).filter(
CalendarCal.id == default_id,
).with_for_update().first()
if occupant is None:
# Do not misclassify an unrelated integrity failure as an ID
# collision and loop forever. A concurrently deleted winner is
# safe for the caller to retry as a fresh transaction.
raise
if occupant.owner == owner:
return occupant
# A renamed calendar owns this deterministic slot. Advance to the
# next stable slot; concurrent callers for this owner will still
# converge there.
collision_index += 1
# Per-request user time context. chat_routes sets this from browser timezone
@@ -1015,6 +1119,9 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
db = SessionLocal()
try:
_ensure_default_calendar(db, owner)
# Listing calendars intentionally lazily creates a durable default.
# Other callers commit it with the event they are creating.
db.commit()
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
return {"calendars": [
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
@@ -1023,6 +1130,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error("Failed to list calendars: %s", e)
raise HTTPException(500, "Failed to list calendars")
finally:
+11 -11
View File
@@ -21,6 +21,7 @@ from src import agent_runs
from src.model_context import estimate_tokens
from src.chat_helpers import coerce_message_and_session
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
from src.foreground_model_routing import build_foreground_model_candidates
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
@@ -1399,14 +1400,14 @@ def setup_chat_routes(
thinking_response = ""
last_metrics = None
# Configured fallback chain for the default chat model. Tried in
# order if the session's primary model fails before producing
# output. Resolved once per request.
try:
from src.endpoint_resolver import resolve_chat_fallback_candidates
_fallback_candidates = resolve_chat_fallback_candidates(owner=_user)
except Exception:
_fallback_candidates = []
# Foreground Chat and Agent requests use one owner-aware policy
# boundary. Legacy `default_model_fallbacks` data is not eligible.
_foreground_candidates = build_foreground_model_candidates(
sess.endpoint_url,
sess.model,
sess.headers,
owner=_user,
)
# Send model name early so the frontend can show it during streaming
_model_suffix = "Research" if effective_do_research else None
@@ -1522,9 +1523,8 @@ def setup_chat_routes(
_actual_model = None
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
try:
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
async for chunk in stream_llm_with_fallback(
_chat_candidates,
_foreground_candidates,
messages,
temperature=ctx.preset.temperature,
# Respect the preset; 0/unset = let the server decide (no
@@ -1710,7 +1710,7 @@ def setup_chat_routes(
disabled_tools=disabled_tools if disabled_tools else None,
tool_policy=tool_policy,
owner=_user,
fallbacks=_fallback_candidates,
fallbacks=_foreground_candidates[1:],
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,
+40 -2
View File
@@ -73,6 +73,30 @@ _HF_TOKEN_STATUS_SNIPPET = (
)
def _windows_local_pid_record_line(pid_path: Path, ready_path: Path) -> str:
"""Build the Git Bash prelude that records a Win32-stoppable PID.
Python publishes the detached outer process's Win32 PID first, then touches
``ready_path``. The inner Git Bash runner waits for that publication before
replacing the fallback with its own Win32 PID from /proc/<msys-pid>/winpid.
Missing, malformed, or late mappings leave the valid outer PID untouched.
"""
pp = shlex.quote(pid_path.as_posix())
rp = shlex.quote(ready_path.as_posix())
return (
"i=0; "
f"while [ ! -e {rp} ] && [ \"$i\" -lt 500 ]; do "
"i=$((i+1)); sleep 0.01; done; "
f"if [ -e {rp} ]; then "
"winpid=\"$(cat /proc/$$/winpid 2>/dev/null || true)\"; "
"case \"$winpid\" in ''|*[!0-9]*) ;; "
f"*) printf '%s\\n' \"$winpid\" > {pp} ;; esac; "
"fi; "
f"rm -f {rp}"
)
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
@@ -978,15 +1002,18 @@ def setup_cookbook_routes() -> APIRouter:
directly (simple commands only). Returns the launched job record."""
log_path = TMUX_LOG_DIR / f"{session_id}.log"
pid_path = TMUX_LOG_DIR / f"{session_id}.pid"
pid_ready_path: Path | None = None
bash = find_bash()
if bash:
# Run the existing bash wrapper verbatim through Git Bash, redirecting
# all output to the log the poller reads. Paths handed to bash use
# POSIX form + shell-quoting so drive paths / spaces survive.
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
pp = shlex.quote(pid_path.as_posix())
pid_ready_path = TMUX_LOG_DIR / f"{session_id}.pid.ready"
pid_ready_path.unlink(missing_ok=True)
inner.write_text(
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
_windows_local_pid_record_line(pid_path, pid_ready_path) + "\n"
+ "\n".join(bash_lines) + "\n",
encoding="utf-8",
)
lp = shlex.quote(log_path.as_posix())
@@ -1020,7 +1047,18 @@ def setup_cookbook_routes() -> APIRouter:
env=env,
**detached_popen_kwargs(),
)
# Publish a valid Win32 ancestor first. The Git Bash runner may then
# replace it with its own Win32 pid, but never before this fallback exists.
pid_path.write_text(str(proc.pid), encoding="utf-8")
if pid_ready_path is not None:
try:
pid_ready_path.touch()
except OSError as e:
logger.warning(
"Could not publish Windows local PID handoff for %s: %s",
session_id,
e,
)
return {"pid": proc.pid, "log_path": str(log_path)}
@router.post("/api/model/download")
+6
View File
@@ -0,0 +1,6 @@
"""Document route domain package (slice 2m, #4082/#4071).
Contains document_routes.py and document_helpers.py, migrated from the flat
routes/ directory. Backward-compat shims at routes/document_routes.py and
routes/document_helpers.py re-export from here.
"""
+243
View File
@@ -0,0 +1,243 @@
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
"""Document routes — CRUD for living documents with version history."""
import logging
import os
import re
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
from pydantic import BaseModel
from core.database import Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler
logger = logging.getLogger(__name__)
# ---- Request schemas ----
class DocumentCreate(BaseModel):
session_id: Optional[str] = None
title: str = "Untitled"
language: Optional[str] = None
content: str = ""
class DocumentUpdate(BaseModel):
content: str
summary: Optional[str] = None
force_version: bool = False
class DocumentPatch(BaseModel):
title: Optional[str] = None
language: Optional[str] = None
session_id: Optional[str] = None # link/unlink document to a session
# ---- Helpers ----
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
return {
"id": doc.id,
"session_id": doc.session_id,
"title": doc.title,
"language": doc.language,
"current_content": doc.current_content,
"version_count": doc.version_count,
"is_active": doc.is_active,
"archived": bool(getattr(doc, "archived", False)),
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
# Source-email provenance (set when doc was created from an email
# attachment) — drives the "Send signed reply" menu item.
"source_email_uid": getattr(doc, "source_email_uid", None),
"source_email_folder": getattr(doc, "source_email_folder", None),
"source_email_account_id": getattr(doc, "source_email_account_id", None),
"source_email_message_id": getattr(doc, "source_email_message_id", None),
}
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
return {
"id": v.id,
"document_id": v.document_id,
"version_number": v.version_number,
"content": v.content,
"summary": v.summary,
"source": v.source,
"created_at": v.created_at.isoformat() if v.created_at else None,
}
def _verify_doc_owner(db, doc: Document, user: str):
"""Verify `user` owns this document. Raise 404 if not.
Documents now carry their own `owner` column, so a doc whose session
was deleted (session_id NULL) can still prove ownership and stay
openable / cloneable. We trust that column first and only fall back to
the session join for any not-yet-backfilled legacy row.
"""
if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required")
if doc.owner is not None:
if doc.owner != user:
raise HTTPException(404, "Document not found")
return
# Legacy fallback: derive ownership from the linked session.
if not doc.session_id:
raise HTTPException(404, "Document not found")
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
if not session or session.owner != user:
raise HTTPException(404, "Document not found")
def _owner_session_filter(q, user):
"""Restrict a documents query to those owned by `user`.
Documents now carry their own `owner` column (backfilled at boot from
the linked session, or assigned to the admin user for legacy/orphaned
docs). We filter on that directly rather than on a session join, so a
document whose session was deleted (session_id NULL) still shows up
for its owner instead of silently vanishing from the Library + search.
The owner backfill runs in init_db before the app serves requests, so
by the time this filter is live there are no NULL-owner rows to leak;
we therefore match the owner strictly for authenticated callers."""
if not user:
if user == "" or _auth_disabled():
return q
return q.filter(False)
return q.filter(Document.owner == user)
def _slug(name: str) -> str:
"""Filesystem-friendly version of a document title.
Whitespace becomes underscores; other unsafe punctuation is dropped.
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
"""
import re as _re
s = (name or "").strip()
# Drop the trailing extension if the title happens to include one
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
s = _re.sub(r'\s+', '_', s)
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
s = _re.sub(r'_+', '_', s).strip('_')
return s or "form"
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
_PDF_RENDER_SCALE = 2.0
def _upload_path_inside(upload_dir: str, path: str) -> bool:
base = os.path.realpath(upload_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
def _resolve_user_upload_path(
upload_handler: Any,
upload_id: str,
owner: Optional[str],
auth_manager=None,
) -> Optional[str]:
"""Resolve an upload id to a filesystem path the caller may read."""
if upload_handler is None:
return None
resolved = upload_handler.resolve_upload(
upload_id,
owner=owner,
auth_manager=auth_manager,
)
if not isinstance(resolved, dict) or not resolved:
return None
path = resolved.get("path")
upload_dir = getattr(upload_handler, "upload_dir", None)
if path and upload_dir and not _upload_path_inside(upload_dir, path):
logger.warning("Upload path outside upload directory: %s", path)
return None
return path
def _locate_upload(
upload_dir: str,
file_id: str,
owner: Optional[str] = None,
auth_manager=None,
upload_handler: Any = None,
):
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
if upload_handler is None:
from src.upload_handler import UploadHandler
base_dir = os.path.dirname(os.path.abspath(upload_dir))
upload_handler = UploadHandler(base_dir, upload_dir)
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
def _assert_pdf_marker_upload_owned(
request: Request,
content: str,
user: Optional[str],
upload_handler: Any,
) -> None:
"""Reject document content whose pdf_source marker points at another user's upload."""
if upload_handler is None:
return
from src.pdf_form_doc import find_source_upload_id
upload_id = find_source_upload_id(content or "")
if not upload_id:
return
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
raise HTTPException(
400,
"Document PDF marker references an upload you do not own",
)
def _derive_title(content: str) -> str:
"""Derive a title from document content."""
import re
if not isinstance(content, str):
return "Untitled"
text = content.strip()
if not text:
return "Untitled"
# Markdown header
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
if md:
title = md.group(1).strip()
if len(title) > 50:
title = title[:48] + ""
return title
# HTML heading
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
if html:
title = html.group(1).strip()
if len(title) > 50:
title = title[:48] + ""
return title
# First non-empty line (if short enough)
for line in text.split('\n'):
line = line.strip()
if line and 2 <= len(line) <= 60:
title = re.sub(r'[:#*`]+$', '', line).strip()
if title and len(title) > 50:
title = title[:48] + ""
return title or "Untitled"
return "Untitled"
File diff suppressed because it is too large Load Diff
+10 -239
View File
@@ -1,243 +1,14 @@
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
"""Backward-compat shim — canonical location is routes/document/document_helpers.py.
"""Document routes — CRUD for living documents with version history."""
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.document_helpers``, ``from routes.document_helpers import
X``, and the ``sys.modules.pop("routes.document_helpers")`` + re-import
pattern used by test_security_regressions.py all operate on the *same* object.
Keeps existing import paths working after slice 2m (#4082/#4071).
"""
import logging
import os
import re
from typing import Any, Dict, Optional
import sys as _sys
from fastapi import HTTPException, Request
from pydantic import BaseModel
from routes.document import document_helpers as _canonical # noqa: F401
from core.database import Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler
logger = logging.getLogger(__name__)
# ---- Request schemas ----
class DocumentCreate(BaseModel):
session_id: Optional[str] = None
title: str = "Untitled"
language: Optional[str] = None
content: str = ""
class DocumentUpdate(BaseModel):
content: str
summary: Optional[str] = None
force_version: bool = False
class DocumentPatch(BaseModel):
title: Optional[str] = None
language: Optional[str] = None
session_id: Optional[str] = None # link/unlink document to a session
# ---- Helpers ----
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
return {
"id": doc.id,
"session_id": doc.session_id,
"title": doc.title,
"language": doc.language,
"current_content": doc.current_content,
"version_count": doc.version_count,
"is_active": doc.is_active,
"archived": bool(getattr(doc, "archived", False)),
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
# Source-email provenance (set when doc was created from an email
# attachment) — drives the "Send signed reply" menu item.
"source_email_uid": getattr(doc, "source_email_uid", None),
"source_email_folder": getattr(doc, "source_email_folder", None),
"source_email_account_id": getattr(doc, "source_email_account_id", None),
"source_email_message_id": getattr(doc, "source_email_message_id", None),
}
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
return {
"id": v.id,
"document_id": v.document_id,
"version_number": v.version_number,
"content": v.content,
"summary": v.summary,
"source": v.source,
"created_at": v.created_at.isoformat() if v.created_at else None,
}
def _verify_doc_owner(db, doc: Document, user: str):
"""Verify `user` owns this document. Raise 404 if not.
Documents now carry their own `owner` column, so a doc whose session
was deleted (session_id NULL) can still prove ownership and stay
openable / cloneable. We trust that column first and only fall back to
the session join for any not-yet-backfilled legacy row.
"""
if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required")
if doc.owner is not None:
if doc.owner != user:
raise HTTPException(404, "Document not found")
return
# Legacy fallback: derive ownership from the linked session.
if not doc.session_id:
raise HTTPException(404, "Document not found")
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
if not session or session.owner != user:
raise HTTPException(404, "Document not found")
def _owner_session_filter(q, user):
"""Restrict a documents query to those owned by `user`.
Documents now carry their own `owner` column (backfilled at boot from
the linked session, or assigned to the admin user for legacy/orphaned
docs). We filter on that directly rather than on a session join, so a
document whose session was deleted (session_id NULL) still shows up
for its owner instead of silently vanishing from the Library + search.
The owner backfill runs in init_db before the app serves requests, so
by the time this filter is live there are no NULL-owner rows to leak;
we therefore match the owner strictly for authenticated callers."""
if not user:
if user == "" or _auth_disabled():
return q
return q.filter(False)
return q.filter(Document.owner == user)
def _slug(name: str) -> str:
"""Filesystem-friendly version of a document title.
Whitespace becomes underscores; other unsafe punctuation is dropped.
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
"""
import re as _re
s = (name or "").strip()
# Drop the trailing extension if the title happens to include one
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
s = _re.sub(r'\s+', '_', s)
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
s = _re.sub(r'_+', '_', s).strip('_')
return s or "form"
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
_PDF_RENDER_SCALE = 2.0
def _upload_path_inside(upload_dir: str, path: str) -> bool:
base = os.path.realpath(upload_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
def _resolve_user_upload_path(
upload_handler: Any,
upload_id: str,
owner: Optional[str],
auth_manager=None,
) -> Optional[str]:
"""Resolve an upload id to a filesystem path the caller may read."""
if upload_handler is None:
return None
resolved = upload_handler.resolve_upload(
upload_id,
owner=owner,
auth_manager=auth_manager,
)
if not isinstance(resolved, dict) or not resolved:
return None
path = resolved.get("path")
upload_dir = getattr(upload_handler, "upload_dir", None)
if path and upload_dir and not _upload_path_inside(upload_dir, path):
logger.warning("Upload path outside upload directory: %s", path)
return None
return path
def _locate_upload(
upload_dir: str,
file_id: str,
owner: Optional[str] = None,
auth_manager=None,
upload_handler: Any = None,
):
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
if upload_handler is None:
from src.upload_handler import UploadHandler
base_dir = os.path.dirname(os.path.abspath(upload_dir))
upload_handler = UploadHandler(base_dir, upload_dir)
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
def _assert_pdf_marker_upload_owned(
request: Request,
content: str,
user: Optional[str],
upload_handler: Any,
) -> None:
"""Reject document content whose pdf_source marker points at another user's upload."""
if upload_handler is None:
return
from src.pdf_form_doc import find_source_upload_id
upload_id = find_source_upload_id(content or "")
if not upload_id:
return
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
raise HTTPException(
400,
"Document PDF marker references an upload you do not own",
)
def _derive_title(content: str) -> str:
"""Derive a title from document content."""
import re
if not isinstance(content, str):
return "Untitled"
text = content.strip()
if not text:
return "Untitled"
# Markdown header
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
if md:
title = md.group(1).strip()
if len(title) > 50:
title = title[:48] + ""
return title
# HTML heading
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
if html:
title = html.group(1).strip()
if len(title) > 50:
title = title[:48] + ""
return title
# First non-empty line (if short enough)
for line in text.split('\n'):
line = line.strip()
if line and 2 <= len(line) <= 60:
title = re.sub(r'[:#*`]+$', '', line).strip()
if title and len(title) > 50:
title = title[:48] + ""
return title or "Untitled"
return "Untitled"
_sys.modules[__name__] = _canonical
+13 -1806
View File
File diff suppressed because it is too large Load Diff
+120
View File
@@ -247,6 +247,7 @@ import re as _re_reply
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
def _extract_reply(text: str) -> str:
@@ -277,6 +278,125 @@ def _extract_reply(text: str) -> str:
return _strip_think(t).strip()
def _build_email_summary_messages(sender: str, subject: str, body_for_llm: str) -> list[dict[str, str]]:
return [
{
"role": "system",
"content": (
"You are an email summarizer. Format: 1-3 short bullet points "
"(use '- '). Cover: main point, action items, deadlines. If the "
"email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR "
"CONTENTS - pull invoice totals, deadlines, key clauses, concrete "
"numbers/dates from PDFs/docs into the bullets. Be terse.\n\n"
"OUTPUT FORMAT: Put ONLY the bullet points between these exact "
"markers, each on its own line:\n"
"<<<SUMMARY>>>\n"
"- ...\n"
"<<<END>>>\n"
"Any reasoning must come BEFORE <<<SUMMARY>>> (ideally inside "
"<think>...</think>). Only the text between the markers is kept."
),
},
{
"role": "user",
"content": (
f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}"
"\n\n---\n\nSummarize the email. Output the bullets between "
"<<<SUMMARY>>> and <<<END>>>."
),
},
]
async def _generate_email_summary(
url: str,
model: str,
sender: str,
subject: str,
body_for_llm: str,
*,
headers: dict | None = None,
max_tokens: int = 8192,
timeout: int = 180,
) -> str:
"""Generate an interactive email summary through the shared LLM adapter."""
from src.llm_core import llm_call_async
raw = await llm_call_async(
url=url,
model=model,
messages=_build_email_summary_messages(sender, subject, body_for_llm),
temperature=0.3,
max_tokens=max_tokens,
headers=headers,
timeout=timeout,
workload="foreground",
)
return _normalize_email_summary(raw)
async def _generate_scheduled_email_summary(
url: str,
model: str,
sender: str,
subject: str,
body_for_llm: str,
*,
headers: dict | None = None,
owner: str | None = None,
max_tokens: int = 8192,
timeout: int = 180,
) -> str:
"""Generate a scheduled summary through the background task candidate chain."""
from src.task_endpoint import task_llm_call_async
raw = await task_llm_call_async(
messages=_build_email_summary_messages(sender, subject, body_for_llm),
fallback_url=url,
fallback_model=model,
fallback_headers=headers,
owner=owner,
temperature=0.3,
max_tokens=max_tokens,
timeout=timeout,
)
return _normalize_email_summary(raw)
def _normalize_email_summary(raw) -> str:
"""Extract a stable cache/UI summary from provider output."""
raw_text = raw or ""
if _REPLY_OPEN_RE.search(raw_text):
summary = _extract_reply(raw_text)
if summary:
return summary
cleaned = _strip_think(raw_text).strip()
bullets = [
line.strip()
for line in cleaned.splitlines()
if _SUMMARY_BULLET_RE.match(line.strip())
]
if bullets:
return "\n".join(bullets)
return cleaned.strip()
EMAIL_SUMMARY_ERROR_CODE = "email_summary_unavailable"
EMAIL_SUMMARY_ERROR_MESSAGE = "Failed to summarize"
def _email_summary_failure_log_detail(exc: BaseException) -> str:
"""Return useful provider-failure metadata without echoing exception text."""
detail = f"type={type(exc).__name__}"
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(getattr(exc, "response", None), "status_code", None)
if isinstance(status, int):
detail += f" status={status}"
return detail
def _apply_email_style_mechanics(text: str) -> str:
"""Enforce deterministic writing-style mechanics that models often miss."""
if not text:
+23 -9
View File
@@ -40,6 +40,7 @@ from routes.email_helpers import (
_pre_retrieve_context,
_attach_compose_uploads, _cleanup_compose_uploads, _q,
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
)
logger = logging.getLogger(__name__)
@@ -653,6 +654,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
no_msgid = 0
examined = 0
_summaries_created = 0
_summary_failed = 0
_events_created = 0
_replies_drafted = 0
_reply_failed = 0
@@ -785,16 +787,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if need_sum:
try:
summary = await task_llm_call_async(
messages=[
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
],
fallback_url=url, fallback_model=model, fallback_headers=headers,
summary = await _generate_scheduled_email_summary(
url=url,
model=model,
sender=sender,
subject=subject,
body_for_llm=body_for_llm,
headers=req_headers,
owner=account_owner or None,
temperature=0.3, max_tokens=16384, timeout=240,
max_tokens=16384,
timeout=240,
)
summary = _extract_reply((summary or "").strip())
if summary:
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
@@ -808,10 +811,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_summaries_created += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
else:
_summary_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary empty · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
except Exception as e:
_summary_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
logger.warning(f"Auto-summary {uid} failed: {e}")
logger.warning(
"Auto-summary uid=%s failed %s",
_uid_text,
_email_summary_failure_log_detail(e),
)
if need_reply:
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
@@ -1320,6 +1332,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts.append(f"processed {processed} new")
if auto_sum:
parts.append(f"summarized {_summaries_created}")
if _summary_failed:
parts.append(f"{_summary_failed} summary failed")
if auto_reply_draft:
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
if _reply_failed:
+242 -115
View File
@@ -45,6 +45,7 @@ from src.upload_limits import read_upload_limited, EMAIL_COMPOSE_UPLOAD_MAX_BYTE
from routes.email_helpers import (
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account,
_account_visible_to_owner,
_q, _attach_compose_uploads, _cleanup_compose_uploads,
_load_settings, _save_settings, _get_email_config,
_send_smtp_message, _smtp_security_mode,
@@ -57,7 +58,8 @@ from routes.email_helpers import (
_extract_attachment_to_disk, _extract_html, _extract_text,
_fetch_sender_thread_context, _pre_retrieve_context,
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
_friendly_email_auth_error,
_friendly_email_auth_error, _email_summary_failure_log_detail,
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
SendEmailRequest, ExtractStyleRequest,
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
@@ -194,6 +196,64 @@ def _coerce_port(value, default):
return None, f"Invalid port {value!r}; must be a whole number"
def _lock_email_account_owner_mutation(db, *owners: str) -> None:
"""Delegate account/default serialization to the shared DB primitive."""
from core.database import lock_email_account_owner_mutations
lock_email_account_owner_mutations(db, *owners)
def _email_account_owner_scope(query, owner: str):
"""Restrict a query to one normalized EmailAccount owner partition."""
from core.database import EmailAccount
from sqlalchemy import or_
if owner:
return query.filter(EmailAccount.owner == owner)
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
def _discover_email_account_mutation_scope(account_id: str, owner: str) -> str:
"""Read the initial lock key and fail closed before a mutation session."""
from core.database import EmailAccount, SessionLocal
db = SessionLocal()
try:
row = db.get(EmailAccount, account_id)
if row is None or (owner and not _account_visible_to_owner(row, owner)):
raise HTTPException(404, "Account not found")
return row.owner or ""
except HTTPException:
raise
except Exception as exc:
logger.error("Account-owner mutation check failed: %s", exc)
raise HTTPException(503, "Account check failed")
finally:
db.close()
def _lock_and_reload_email_account(db, account_id: str, owner: str, scope: str):
"""Lock, reload, and revalidate an account, retrying if its owner moved."""
from core.database import EmailAccount
owner_scopes = {scope or ""}
while True:
_lock_email_account_owner_mutation(db, *owner_scopes)
row = db.get(EmailAccount, account_id, populate_existing=True)
if row is None or (owner and not _account_visible_to_owner(row, owner)):
raise HTTPException(404, "Account not found")
current_scope = row.owner or ""
if current_scope in owner_scopes or db.get_bind().dialect.name == "sqlite":
return row
# The account changed owner after discovery but before lock acquisition.
# Release the partial lock set and reacquire all observed scopes in the
# shared helper's canonical order, then validate from the database again.
db.rollback()
owner_scopes.add(current_scope)
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
aliases = [owner or ""]
try:
@@ -2860,13 +2920,22 @@ def setup_email_routes():
return indexed_response
return {"emails": [], "total": 0, "error": "Mail operation failed"}
def _read_email_sync(uid, folder, account_id, owner, mark_seen=True, full=False):
def _read_email_sync(uid, folder, account_id, owner, mark_seen=False, full=False):
"""Sync IMAP read — wrapped in to_thread by the async handler.
The normal reader path fetches the headers plus a bounded body prefix.
That avoids downloading multi-megabyte attachments just to open a
message. Full-message fetch remains available for flows that need
attachment metadata immediately, such as forwarding.
`mark_seen` defaults to False because it mutates provider state: it
selects the mailbox read-write and issues a STORE. Only a foreground
open should ask for it, and it has to ask explicitly.
A failed \\Seen transition is reported as `mark_seen_failed` on an
otherwise normal response, never as an error. The body has already been
fetched at that point, so refusing to return it would turn a cosmetic
flag failure into an unreadable message.
"""
import time as _t
_t0 = _t.monotonic()
@@ -2874,9 +2943,28 @@ def setup_email_routes():
preview_bytes = 384 * 1024
_t_select = 0.0
_t_fetch = 0.0
mark_seen_failed = False
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder), readonly=True)
# A foreground open owns both the body fetch and the \Seen
# transition. Keep them on one read-write IMAP selection so the
# route never schedules a second connection that can race the
# response. Prefetch/read-only callers retain BODY.PEEK and a
# read-only mailbox selection.
try:
conn.select(_q(folder), readonly=not mark_seen)
except Exception as select_exc:
if not mark_seen:
raise
# Read-only mailboxes (shared archives, some provider
# folders) reject a read-write SELECT. Serve the message
# read-only and report the flag failure.
logger.warning(
f"read-write SELECT rejected for {folder!r}; "
f"serving read-only without \\Seen: {select_exc}"
)
conn.select(_q(folder), readonly=True)
mark_seen_failed = True
_t_select = _t.monotonic() - _t0
fetch_query = "(BODY.PEEK[])" if full else f"(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.{preview_bytes}>)"
status, msg_data = _imap_uid_fetch(conn, uid, fetch_query)
@@ -2902,22 +2990,44 @@ def setup_email_routes():
header_part = msg_data[0][1] or b""
raw = header_part + b"\r\n" + text_part
msg = email_mod.message_from_bytes(raw)
# Parse the fetched payload before mutating provider state. If
# the message is malformed enough that the reader cannot build
# a response, the caller gets an error while the message stays
# unread instead of receiving a false optimistic rollback.
msg = email_mod.message_from_bytes(raw)
subject = _decode_header(msg.get("Subject", "(no subject)"))
sender = _decode_header(msg.get("From", "unknown"))
to = _decode_header(msg.get("To", ""))
cc = _decode_header(msg.get("Cc", ""))
date_str = msg.get("Date", "")
message_id = msg.get("Message-ID", "")
in_reply_to = msg.get("In-Reply-To", "")
references = msg.get("References", "")
body = _extract_text(msg)
body_html = _extract_html(msg)
subject = _decode_header(msg.get("Subject", "(no subject)"))
sender = _decode_header(msg.get("From", "unknown"))
to = _decode_header(msg.get("To", ""))
cc = _decode_header(msg.get("Cc", ""))
date_str = msg.get("Date", "")
message_id = msg.get("Message-ID", "")
in_reply_to = msg.get("In-Reply-To", "")
references = msg.get("References", "")
body = _extract_text(msg)
body_html = _extract_html(msg)
sender_name, sender_addr = email.utils.parseaddr(sender)
parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None
attachments = _list_attachments_from_msg(msg) if full else (_email_attachment_meta_cache_get(owner, account_id, folder, uid) or [])
if mark_seen and not mark_seen_failed:
seen_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
if seen_status != "OK":
# Report, don't raise. The parsed body below is still a
# valid response; only the flag claim is untrue.
logger.warning(
f"IMAP STORE \\Seen failed for UID {uid} in {folder!r}: {seen_status}"
)
mark_seen_failed = True
# Only record the local flag transition when the provider actually
# accepted it, so the index and list cache cannot drift ahead of
# the mailbox.
if mark_seen and not mark_seen_failed:
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
sender_name, sender_addr = email.utils.parseaddr(sender)
parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None
attachments = _list_attachments_from_msg(msg) if full else (_email_attachment_meta_cache_get(owner, account_id, folder, uid) or [])
related_attachments = []
if full and not _has_visible_attachments(msg):
related_attachments = _related_thread_attachments_sync(
@@ -3038,20 +3148,29 @@ def setup_email_routes():
"boundaries": cached_boundaries,
"thread_turns": cached_turns,
"sender_signature": cached_sender_sig,
# Per-request, not part of the message: the route strips this
# before caching so a one-off flag failure is never replayed to
# later readers.
"mark_seen_failed": mark_seen_failed,
}
except Exception as e:
logger.error(f"Failed to read email {uid}: {e}")
return {"error": "Mail operation failed"}
def _mark_email_seen_sync(uid, folder, account_id, owner):
"""Synchronously mark a cached email seen and report success."""
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Seen")
conn.select(_q(folder), readonly=False)
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
if status != "OK":
return False
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
return True
except Exception as e:
logger.debug(f"mark-seen after cached read failed uid={uid}: {e}")
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
return False
@router.get("/read/{uid}")
async def read_email_by_uid(
@@ -3077,32 +3196,32 @@ def setup_email_routes():
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
cached = None
if cached is not None:
if mark_seen:
try:
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
except RuntimeError:
pass
# A cache hit already holds a complete, valid message. Await the
# STORE so the response reports the real flag state, but never let
# a failed STORE withhold a body we are holding in memory.
if mark_seen and not await _asyncio.to_thread(
_mark_email_seen_sync, uid, folder, account_id, owner
):
return {**cached, "mark_seen_failed": True}
return cached
if not full:
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
_read_cache_put(ck, persisted)
if mark_seen:
try:
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
except RuntimeError:
pass
if mark_seen and not await _asyncio.to_thread(
_mark_email_seen_sync, uid, folder, account_id, owner
):
return {**persisted, "mark_seen_failed": True}
return persisted
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
if result and not result.get("error"):
_read_cache_put(ck, result)
# `mark_seen_failed` describes this request, not the message, so it
# must not enter either cache — a later reader would otherwise be
# told a STORE failed that it never issued.
cacheable = {k: v for k, v in result.items() if k != "mark_seen_failed"}
_read_cache_put(ck, cacheable)
if not full:
_email_preview_cache_put(owner, account_id, folder, uid, result)
if mark_seen:
try:
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
except RuntimeError:
pass
_email_preview_cache_put(owner, account_id, folder, uid, cacheable)
return result
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
@@ -4766,8 +4885,6 @@ def setup_email_routes():
"""Generate a quick AI summary of an email body."""
try:
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
import requests as _req
body = data.get("body", "")
subject = data.get("subject", "")
@@ -4778,7 +4895,11 @@ def setup_email_routes():
if account_id:
_assert_owns_account(account_id, owner)
if not body:
return {"success": False, "error": "No body provided"}
return {
"success": False,
"error": "No body provided",
"error_code": "email_summary_missing_body",
}
# If we know which UID this is, fetch the raw message and pull
# attachment text so the summary can reference invoice totals,
@@ -4807,53 +4928,43 @@ def setup_email_routes():
if not url:
url, model, headers = resolve_endpoint("default", owner=owner)
if not url or not model:
return {"success": False, "error": "No LLM endpoint configured"}
return {
"success": False,
"error": "No model configured for email summaries",
"error_code": "email_summary_not_configured",
}
req_headers = {"Content-Type": "application/json"}
if headers:
req_headers.update(headers)
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload = {
"model": model,
"messages": [
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull invoice totals, deadlines, key clauses, concrete numbers/dates from PDFs/docs into the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
],
tok_key: 8192,
"temperature": 0.3,
"stream": False,
}
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
if _restricts_temperature(model):
payload.pop("temperature", None)
resp = await asyncio.to_thread(
_req.post, url, json=payload, headers=req_headers, timeout=180
)
if not resp.ok:
return {"success": False, "error": f"LLM HTTP {resp.status_code}"}
rdata = resp.json()
msg = (rdata.get("choices") or [{}])[0].get("message", {})
content = (msg.get("content") or "").strip()
content = _extract_reply(content)
try:
content = await _generate_email_summary(
url=url,
model=model,
sender=sender,
subject=subject,
body_for_llm=body_for_llm,
headers=req_headers,
max_tokens=8192,
timeout=180,
)
except Exception as e:
logger.warning(
"Email summary LLM call failed %s",
_email_summary_failure_log_detail(e),
)
return {
"success": False,
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
"error_code": EMAIL_SUMMARY_ERROR_CODE,
}
if not content:
# Model put everything in reasoning_content — extract bullet points
rc = (msg.get("reasoning_content") or "").strip()
# Find bullet-point style output (lines starting with -, •, *, or numbered)
bullet_lines = []
for line in rc.split("\n"):
stripped = line.strip()
if re.match(r"^[-•*]\s+|^\d+[.)]\s+", stripped):
bullet_lines.append(stripped)
if bullet_lines:
content = "\n".join(bullet_lines)
else:
# Last resort: take the last paragraph
paragraphs = [p.strip() for p in rc.split("\n\n") if p.strip()]
content = paragraphs[-1] if paragraphs else rc[:500]
if not content:
return {"success": False, "error": "Empty response from model"}
return {
"success": False,
"error": "The model returned an empty summary",
"error_code": "email_summary_empty",
}
# Cache the summary if we have a message_id
mid = data.get("message_id", "")
@@ -4876,8 +4987,15 @@ def setup_email_routes():
return {"success": True, "summary": content, "model_used": model}
except Exception as e:
logger.error(f"Failed to summarize: {e}")
return {"success": False, "error": "Mail operation failed"}
logger.error(
"Email summary route failed %s",
_email_summary_failure_log_detail(e),
)
return {
"success": False,
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
"error_code": EMAIL_SUMMARY_ERROR_CODE,
}
@router.post("/translate")
async def translate_email(data: dict, owner: str = Depends(require_owner)):
@@ -5209,9 +5327,9 @@ def setup_email_routes():
# Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here)
# doesn't kill AI Reply outright — fall through to the
# user's Utility / Default endpoints AND their configured
# fallback chains. Dedupe by url+model so we don't retry
# the same broken endpoint.
# user's Utility / Default endpoints and the active Utility
# fallback chain. The retired default-fallback hook stays empty.
# Dedupe by url+model so we don't retry the same broken endpoint.
from src.llm_core import llm_call_async_with_fallback
from src.endpoint_resolver import (
resolve_utility_fallback_candidates,
@@ -5240,7 +5358,7 @@ def setup_email_routes():
_add(_d_url, _d_model, _d_headers)
except Exception:
pass
# Configured fallback chains last.
# Active Utility fallbacks, then the retired default hook.
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
@@ -5428,9 +5546,9 @@ def setup_email_routes():
import uuid as _uuid
db = SessionLocal()
try:
_lock_email_account_owner_mutation(db, owner)
q = db.query(EmailAccount).filter(EmailAccount.is_default == True) # noqa: E712
if owner:
q = q.filter(EmailAccount.owner == owner)
q = _email_account_owner_scope(q, owner)
row = q.first()
if row is None:
row = EmailAccount(id=_uuid.uuid4().hex, owner=owner, name="Default", is_default=True, enabled=True)
@@ -5456,8 +5574,7 @@ def setup_email_routes():
if data.get("smtp_password"):
row.smtp_password = _enc(data["smtp_password"])
clear_q = db.query(EmailAccount).filter(EmailAccount.id != row.id)
if owner:
clear_q = clear_q.filter(EmailAccount.owner == owner)
clear_q = _email_account_owner_scope(clear_q, owner)
clear_q.update({EmailAccount.is_default: False})
db.commit()
finally:
@@ -5552,6 +5669,7 @@ def setup_email_routes():
return {"ok": False, "error": port_err}
db = SessionLocal()
try:
_lock_email_account_owner_mutation(db, owner)
row = EmailAccount(
id=_uuid.uuid4().hex,
name=name,
@@ -5578,9 +5696,7 @@ def setup_email_routes():
# the one-default invariant — but scope it to THIS user's accounts,
# otherwise creating a default would clear every other user's
# default flag too.
scope_q = db.query(EmailAccount)
if owner:
scope_q = scope_q.filter(EmailAccount.owner == owner)
scope_q = _email_account_owner_scope(db.query(EmailAccount), owner)
existing_count = scope_q.count()
if row.is_default or existing_count == 0:
scope_q.update({EmailAccount.is_default: False})
@@ -5631,28 +5747,39 @@ def setup_email_routes():
@router.delete("/accounts/{account_id}")
async def delete_email_account(account_id: str, owner: str = Depends(require_user)):
_assert_owns_account(account_id, owner)
initial_scope = _discover_email_account_mutation_scope(account_id, owner)
from core.database import SessionLocal, EmailAccount
db = SessionLocal()
try:
row = db.get(EmailAccount, account_id)
if not row:
return {"ok": False, "error": "Account not found"}
row = _lock_and_reload_email_account(
db, account_id, owner, initial_scope
)
row_scope = row.owner or ""
was_default = bool(row.is_default)
db.delete(row)
db.commit()
# Flush the removal before staging a replacement default. The
# partial unique index is checked statement-by-statement, and the
# ORM is otherwise free to UPDATE the promoted row before DELETE.
db.flush()
# If the deleted row was default, promote the next-oldest enabled
# row owned by THIS user. Without the owner filter we'd promote
# another user's account and the deleter would silently inherit
# it as their default.
if was_default:
promote_q = db.query(EmailAccount).filter(EmailAccount.enabled == True) # noqa: E712
if owner:
promote_q = promote_q.filter(EmailAccount.owner == owner)
promote = promote_q.order_by(EmailAccount.created_at.asc()).first()
promote_q = db.query(EmailAccount).filter(
EmailAccount.id != account_id,
EmailAccount.enabled == True, # noqa: E712
)
promote_q = _email_account_owner_scope(promote_q, row_scope)
promote = promote_q.order_by(
EmailAccount.created_at.asc(), EmailAccount.id.asc()
).first()
if promote:
promote.is_default = True
db.commit()
# Deletion and any replacement promotion are one durable state
# transition, so another worker can never observe or race the old
# split-commit gap.
db.commit()
return {"ok": True}
finally:
db.close()
@@ -5865,18 +5992,18 @@ def setup_email_routes():
@router.post("/accounts/{account_id}/set-default")
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
_assert_owns_account(account_id, owner)
initial_scope = _discover_email_account_mutation_scope(account_id, owner)
from core.database import SessionLocal, EmailAccount
db = SessionLocal()
try:
row = db.get(EmailAccount, account_id)
if not row:
return {"ok": False, "error": "Account not found"}
# SECURITY: scope the "clear other defaults" sweep to this user's
# accounts so we don't unset another user's default flag.
clear_q = db.query(EmailAccount)
if owner:
clear_q = clear_q.filter(EmailAccount.owner == owner)
row = _lock_and_reload_email_account(
db, account_id, owner, initial_scope
)
# Scope the sweep to the target row's normalized owner partition;
# this also handles visible legacy NULL/empty-owner accounts.
clear_q = _email_account_owner_scope(
db.query(EmailAccount), row.owner or ""
)
clear_q.update({EmailAccount.is_default: False})
row.is_default = True
db.commit()
@@ -5895,7 +6022,7 @@ def setup_email_routes():
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
redirect_uri = (
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
)
state = make_oauth_state(account_id, owner)
params = urllib.parse.urlencode({
@@ -5932,7 +6059,7 @@ def setup_email_routes():
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
redirect_uri = (
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
)
import httpx as _httpx
try:
+21 -8
View File
@@ -127,6 +127,25 @@ def _load_grounding_backend():
return cached
def _model_input_to_device(value, device: str, torch):
if not hasattr(value, "to"):
return value
if (
device == "mps"
and hasattr(torch, "float64")
and getattr(value, "dtype", None) == torch.float64
):
return value.to(device=device, dtype=torch.float32)
return value.to(device)
def _model_inputs_to_device(inputs, device: str, torch) -> Dict[str, Any]:
return {
key: _model_input_to_device(value, device, torch)
for key, value in inputs.items()
}
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
query = (text or "").strip()
if not query:
@@ -142,10 +161,7 @@ def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
labels.append(f"a photo of {query}")
try:
inputs = processor(text=[labels], images=image, return_tensors="pt")
model_inputs = {
k: (v.to(device) if hasattr(v, "to") else v)
for k, v in inputs.items()
}
model_inputs = _model_inputs_to_device(inputs, device, torch)
with torch.no_grad():
outputs = model(**model_inputs)
target_sizes = torch.tensor([[image.height, image.width]])
@@ -1869,10 +1885,7 @@ def setup_gallery_routes() -> APIRouter:
try:
inputs = processor(image, **kwargs)
model_inputs = {
k: (v.to(device) if hasattr(v, "to") else v)
for k, v in inputs.items()
}
model_inputs = _model_inputs_to_device(inputs, device, torch)
with torch.no_grad():
outputs = model(**model_inputs)
masks = processor.image_processor.post_process_masks(
+16 -58
View File
@@ -137,44 +137,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
entry["metadata"] = meta
return entry
def _db_message_metadata(m: DbChatMessage) -> Dict[str, Any]:
meta = {}
if m.meta_data:
try:
meta = json.loads(m.meta_data) or {}
except (json.JSONDecodeError, ValueError):
meta = {}
if m.timestamp and "timestamp" not in meta:
meta["timestamp"] = m.timestamp.isoformat() + "Z"
return meta
def _hydrate_session_history_from_db(session_id: str, rows: list[DbChatMessage]) -> None:
"""Rebuild in-memory context from raw DB rows after a history load.
The browser history endpoint can return paged/display-trimmed messages,
but the next model call reads ``session.history``. After a restart or a
stale in-memory session, selecting an old chat through the paged endpoint
used to show the transcript while the model only saw fresh context.
"""
if not rows:
return
try:
session = session_manager.get_session(session_id)
except KeyError:
return
session.history = [
ChatMessage(role=m.role, content=m.content, metadata=_db_message_metadata(m) or None)
for m in rows
]
session.message_count = len(session.history)
def _session_needs_db_history_hydration(session_id: str, total: int) -> bool:
try:
session = session_manager.get_session(session_id)
except KeyError:
return False
return len(session.history or []) < int(total or 0)
@router.get("/api/history/{session_id}")
async def get_session_history(
request: Request,
@@ -198,6 +160,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
)
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
page_offset = max(0, min(page_offset, total))
# Keep display pagination page-scoped. ``get_session`` is the
# full model-context hydration seam and must not be entered here.
rows = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
@@ -206,14 +170,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.limit(page_limit)
.all()
)
if _session_needs_db_history_hydration(session_id, total):
full_rows = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.order_by(DbChatMessage.timestamp)
.all()
)
_hydrate_session_history_from_db(session_id, full_rows)
history_dict = [
entry for entry in (_db_history_entry(m) for m in rows)
if not (entry.get("metadata") or {}).get("hidden")
@@ -258,7 +214,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
entry["metadata"] = msg["metadata"]
history_dict.append(entry)
# Fallback: load from DB if in-memory is empty
# Fallback: load from DB if in-memory renders empty. Display only —
# get_session above is the hydration seam, so nothing here writes back
# into session.history — rebuilding it from raw rows would overwrite
# parsed multimodal content and the _db_id edit/delete keys it just set.
if not history_dict:
db = SessionLocal()
try:
@@ -268,17 +227,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.order_by(DbChatMessage.timestamp)
.all()
)
db_history = []
for m in db_messages:
db_history.append(_db_history_entry(m))
if db_history:
# Rebuild in-memory history from the full set so hidden
# messages (e.g. compaction summaries) are kept for AI context.
_hydrate_session_history_from_db(session_id, db_messages)
# Response excludes hidden messages, matching the in-memory path.
history_dict = [
m for m in db_history
if not (m.get("metadata") or {}).get("hidden")
entry for entry in (_db_history_entry(m) for m in db_messages)
if not (entry.get("metadata") or {}).get("hidden")
]
except Exception as e:
logger.error(f"DB fallback failed for {session_id}: {e}")
@@ -645,8 +597,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
body = await request.json()
keep_count = body.get("keep_count", 0)
# Get the source session
source = session_manager.sessions.get(session_id)
# Get the source session. keep_count indexes into source.history,
# so this must go through get_session — reading the cache directly
# forks an empty transcript out of a metadata-only session after a
# restart (display pagination no longer hydrates it).
try:
source = session_manager.get_session(session_id)
except KeyError:
raise HTTPException(404, "Session not found")
if not source:
raise HTTPException(404, "Session not found")
+5
View File
@@ -0,0 +1,5 @@
"""MCP route domain package (slice 2o, #4082/#4071).
Contains mcp_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/mcp_routes.py re-exports from here.
"""
+697
View File
@@ -0,0 +1,697 @@
# routes/mcp_routes.py
"""MCP (Model Context Protocol) server management routes."""
import json
import os
import uuid
import urllib.parse
import html
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
import logging
import httpx
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _mcp_oauth_base_dir() -> Path:
"""Directory that may contain OAuth files managed by Odysseus."""
return Path(MCP_OAUTH_DIR).resolve(strict=False)
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
raw = str(raw_path or "").strip()
if not raw:
return ""
base = _mcp_oauth_base_dir()
path = Path(os.path.expanduser(raw))
if not path.is_absolute():
path = base / path
resolved = path.resolve(strict=False)
try:
resolved.relative_to(base)
except ValueError as exc:
raise HTTPException(
400,
f"Invalid OAuth {field_name}: path must stay under {base}",
) from exc
return str(resolved)
def _sanitize_mcp_oauth_config(oauth_cfg):
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
if not oauth_cfg:
return oauth_cfg
if not isinstance(oauth_cfg, dict):
return {}
sanitized = dict(oauth_cfg)
for field_name in ("keys_file", "token_file"):
if sanitized.get(field_name):
sanitized[field_name] = _resolve_mcp_oauth_path(
sanitized[field_name],
field_name,
)
return sanitized
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
"""Check token existence without letting legacy bad paths break listing."""
if not isinstance(oauth_cfg, dict):
return False
try:
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
except HTTPException:
if strict:
raise
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
return True
return bool(token_file and not os.path.exists(token_file))
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
if not oauth_cfg or not isinstance(env, dict):
return
keys_file = oauth_cfg.get("keys_file")
token_file = oauth_cfg.get("token_file")
if keys_file:
env["GMAIL_OAUTH_PATH"] = keys_file
if token_file:
env["GMAIL_CREDENTIALS_PATH"] = token_file
def _load_disabled_map():
"""Load per-server disabled tool sets from DB."""
db = SessionLocal()
try:
disabled_map = {}
for srv in db.query(McpServer).all():
if srv.disabled_tools:
try:
names = json.loads(srv.disabled_tools)
if names:
disabled_map[srv.id] = set(names)
except (json.JSONDecodeError, TypeError):
pass
return disabled_map
finally:
db.close()
def _mcp_oauth_redirect_uri() -> str:
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
from src.mcp_oauth import REDIRECT_URI
return REDIRECT_URI
def setup_mcp_routes(mcp_manager: McpManager):
"""Setup MCP routes with the provided manager."""
@router.get("/servers")
def list_servers(request: Request):
"""List all configured MCP servers with connection status."""
require_admin(request)
db = SessionLocal()
try:
servers = db.query(McpServer).all()
result = []
for srv in servers:
status = mcp_manager.get_server_status(srv.id)
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
needs_oauth = False
if oauth_cfg:
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
total_tools = status.get("tool_count", 0)
result.append({
"id": srv.id,
"name": srv.name,
"transport": srv.transport,
"command": srv.command,
"args": json.loads(srv.args) if srv.args else [],
"env": json.loads(srv.env) if srv.env else {},
"url": srv.url,
"is_enabled": srv.is_enabled,
"status": status.get("status", "disconnected"),
"tool_count": total_tools,
"disabled_tool_count": len(disabled_list),
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"has_oauth": oauth_cfg is not None,
"needs_oauth": needs_oauth,
})
return result
finally:
db.close()
@router.post("/servers")
async def add_server(
request: Request,
name: str = Form(...),
transport: str = Form("stdio"),
command: str = Form(None),
args: str = Form("[]"),
env: str = Form("{}"),
url: str = Form(None),
oauth_file: str = Form(None),
oauth_config: str = Form(None),
):
"""Add a new MCP server config and attempt connection. Admin-only:
registering a stdio server is equivalent to executing arbitrary
binaries on the host."""
require_admin(request)
server_id = str(uuid.uuid4())[:8]
# Validate
if transport == "stdio" and not command:
raise HTTPException(400, "command is required for stdio transport")
if transport == "sse" and not url:
raise HTTPException(400, "url is required for SSE transport")
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
# Parse JSON fields
try:
parsed_args = json.loads(args) if args else []
except json.JSONDecodeError:
parsed_args = []
try:
parsed_env = json.loads(env) if env else {}
except json.JSONDecodeError:
parsed_env = {}
if not isinstance(parsed_env, dict):
parsed_env = {}
# Parse OAuth config
parsed_oauth_config = None
if oauth_config:
try:
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
except json.JSONDecodeError:
pass
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
# Write OAuth credentials file if provided (for Google MCP servers)
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
if oauth_file:
try:
oauth_data = json.loads(oauth_file)
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
oauth_filename = oauth_data.get("filename", "")
client_id = oauth_data.get("client_id", "")
client_secret = oauth_data.get("client_secret", "")
if oauth_dir and oauth_filename and client_id and client_secret:
filepath = _resolve_mcp_oauth_path(
Path(oauth_dir) / str(oauth_filename),
"filename",
)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
creds = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
}
}
with open(filepath, "w", encoding="utf-8") as f:
json.dump(creds, f, indent=2)
logger.info(f"Wrote OAuth credentials to {filepath}")
parsed_env.pop("GOOGLE_CLIENT_ID", None)
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Failed to write OAuth file: {e}")
# Save to DB
db = SessionLocal()
try:
srv = McpServer(
id=server_id,
name=name,
transport=transport,
command=command,
args=json.dumps(parsed_args),
env=json.dumps(parsed_env),
url=url,
is_enabled=True,
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
)
db.add(srv)
db.commit()
finally:
db.close()
# Check if OAuth token already exists — skip connection attempt if not
needs_oauth = False
if parsed_oauth_config:
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
connected = False
if not needs_oauth:
connected = await mcp_manager.connect_server(
server_id=server_id,
name=name,
transport=transport,
command=command,
args=parsed_args,
env=parsed_env,
url=url,
)
status = mcp_manager.get_server_status(server_id)
needs_auth = status.get("status") == "needs_auth"
return {
"id": server_id,
"name": name,
"connected": connected,
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
"needs_oauth": needs_oauth,
"needs_auth": needs_auth,
"auth_url": status.get("auth_url"),
}
@router.post("/servers/{server_id}/reconnect")
async def reconnect_server(server_id: str, request: Request):
"""Reconnect to an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
status = mcp_manager.get_server_status(server_id)
return {
"connected": connected,
"status": status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"needs_auth": status.get("status") == "needs_auth",
}
finally:
db.close()
@router.patch("/servers/{server_id}")
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
"""Enable or disable an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
enabled = str(is_enabled).lower() == "true"
srv.is_enabled = enabled
db.commit()
if enabled:
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
else:
await mcp_manager.disconnect_server(server_id)
return {"id": server_id, "is_enabled": enabled}
finally:
db.close()
@router.delete("/servers/{server_id}")
async def delete_server(server_id: str, request: Request):
"""Remove an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
db.delete(srv)
db.commit()
return {"status": "deleted"}
finally:
db.close()
@router.get("/tools")
def list_tools(request: Request):
"""List all discovered MCP tools across all connected servers."""
require_admin(request)
disabled_map = _load_disabled_map()
return mcp_manager.get_all_tools(disabled_map)
@router.get("/servers/{server_id}/tools")
def list_server_tools(server_id: str, request: Request):
"""List all tools for a specific MCP server with enabled/disabled state."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
disabled_set = set(disabled_list)
finally:
db.close()
all_tools = mcp_manager.get_all_tools()
server_tools = [t for t in all_tools if t["server_id"] == server_id]
for t in server_tools:
t["is_disabled"] = t["name"] in disabled_set
return server_tools
@router.patch("/servers/{server_id}/tools")
async def update_disabled_tools(server_id: str, request: Request):
"""Bulk update disabled tools list for a server.
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
body = await request.json()
disabled = body.get("disabled", [])
if not isinstance(disabled, list):
raise HTTPException(400, "disabled must be a list of tool names")
srv.disabled_tools = json.dumps(disabled) if disabled else None
db.commit()
return {"id": server_id, "disabled_count": len(disabled)}
finally:
db.close()
# ── OAuth flow for Google MCP servers ──────────────────────────
@router.get("/oauth/authorize/{server_id}")
def oauth_authorize(server_id: str, request: Request):
"""Show OAuth authorization page with Google sign-in link."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
if not srv.oauth_config:
raise HTTPException(400, "Server has no OAuth config")
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
if not keys_file or not os.path.exists(keys_file):
raise HTTPException(400, "OAuth keys file not found")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
if not keys:
raise HTTPException(400, "Invalid OAuth keys file format")
client_id = keys["client_id"]
scopes = oauth_cfg.get("scopes", [])
# For Desktop App creds, default to localhost — the user will
# paste the resulting URL back if they're on a different device.
redirect_uri = _mcp_oauth_redirect_uri()
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(scopes),
"access_type": "offline",
"prompt": "consent",
"state": server_id,
}
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
# Determine if user is accessing from the same machine
host = request.headers.get("host", "")
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
if is_local:
# Same machine — just redirect, callback will work directly
return RedirectResponse(auth_url)
else:
# Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
finally:
db.close()
@router.get("/oauth/callback")
async def oauth_callback(code: str, state: str, request: Request):
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
pending-state registry; Google flows fall through to the legacy path."""
require_admin(request)
from src.mcp_oauth import resolve_pending
if resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
# Legacy Google path: state is the server_id
return await _exchange_and_connect(state, code, request)
@router.post("/oauth/exchange/{server_id}")
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
"""Manual code exchange — user pastes the callback URL from their browser."""
require_admin(request)
try:
parsed = urllib.parse.urlparse(callback_url)
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
if not code:
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
except Exception:
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
# resolve it directly (the background connect finishes the handshake).
state = params.get("state", [None])[0]
from src.mcp_oauth import resolve_pending
if state and resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
return await _exchange_and_connect(server_id, code, request)
async def _exchange_and_connect(server_id: str, code: str, request: Request):
"""Exchange auth code for tokens and connect the MCP server."""
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
if not srv.oauth_config:
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
token_file = oauth_cfg.get("token_file", "")
if not keys_file or not token_file:
raise HTTPException(400, "OAuth keys/token file not configured")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
client_id = keys["client_id"]
client_secret = keys["client_secret"]
redirect_uri = _mcp_oauth_redirect_uri()
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
err = resp.text
logger.error(f"OAuth token exchange failed: {err}")
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
tokens = resp.json()
logger.info(f"OAuth tokens received for server {server_id}")
# Save tokens to the file the MCP package expects
os.makedirs(os.path.dirname(token_file), exist_ok=True)
with open(token_file, "w", encoding="utf-8") as f:
json.dump(tokens, f, indent=2)
logger.info(f"Saved OAuth tokens to {token_file}")
# Attempt to connect the MCP server now
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
if connected:
status = mcp_manager.get_server_status(server_id)
tool_count = status.get("tool_count", 0)
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
f"{srv.name} connected with {tool_count} tools. You can close this window.",
success=True,
))
else:
status = mcp_manager.get_server_status(server_id)
return HTMLResponse(_oauth_result_page(
"Authorized but Connection Failed",
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
))
except HTTPException as e:
logger.warning(f"OAuth callback rejected: {e.detail}")
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
finally:
db.close()
return router
def _oauth_authorize_page(
auth_url: str,
server_id: str,
host: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `host` comes from the request
# Host header and `server_id` from the OAuth state — neither is trusted.
auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>Authorize Odysseus</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 480px; text-align: center; }}
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
.step b {{ color: #e06c75; }}
a.auth-link {{
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
font-weight: 600; font-size: 0.9rem;
}}
a.auth-link:hover {{ background: #c55; }}
input[type=text] {{
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
}}
input:focus {{ outline: none; border-color: #e06c75; }}
button {{
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
}}
button:hover {{ background: #c55; }}
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
</style></head>
<body><div class="card">
<h2>Authorize Google Account</h2>
<div class="step">
<b>1.</b> Click the button below to sign in with Google<br>
<b>2.</b> After approving, your browser will show an error page that's normal<br>
<b>3.</b> Copy the full URL from your browser's address bar<br>
<b>4.</b> Paste it below and click Connect
</div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div>
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button>
</form>
</div></body></html>"""
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
"""Generate a simple HTML page for the OAuth result."""
safe_title = html.escape(title)
safe_message = html.escape(message)
color = "#00661a" if success else "#e06c75"
icon = "&#10003;" if success else "&#10007;"
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>{safe_title}</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 420px; text-align: center; }}
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
</style></head>
<body><div class="card">
<div class="icon">{icon}</div>
<h2>{safe_title}</h2>
<p>{safe_message}</p>
</div></body></html>"""
+14 -693
View File
@@ -1,697 +1,18 @@
# routes/mcp_routes.py
"""MCP (Model Context Protocol) server management routes."""
import json
import os
import uuid
import urllib.parse
import html
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
import logging
import httpx
"""Backward-compat shim — canonical location is routes/mcp/mcp_routes.py.
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.mcp_routes``, ``from routes.mcp_routes import X``,
``importlib.import_module("routes.mcp_routes")``, the
``sys.modules.pop("routes.mcp_routes")`` + re-import pattern in
test_security_regressions.py, and the ``monkeypatch.setattr(mcp_routes,
"MCP_OAUTH_DIR", ...)`` pattern all operate on the *same* object. This also
makes ``mcp_routes.__file__`` resolve to the canonical file (which the
source-introspection at line 839 reads). Keeps existing import paths working
after slice 2o (#4082/#4071).
"""
logger = logging.getLogger(__name__)
import sys as _sys
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
from routes.mcp import mcp_routes as _canonical # noqa: F401
def _mcp_oauth_base_dir() -> Path:
"""Directory that may contain OAuth files managed by Odysseus."""
return Path(MCP_OAUTH_DIR).resolve(strict=False)
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
raw = str(raw_path or "").strip()
if not raw:
return ""
base = _mcp_oauth_base_dir()
path = Path(os.path.expanduser(raw))
if not path.is_absolute():
path = base / path
resolved = path.resolve(strict=False)
try:
resolved.relative_to(base)
except ValueError as exc:
raise HTTPException(
400,
f"Invalid OAuth {field_name}: path must stay under {base}",
) from exc
return str(resolved)
def _sanitize_mcp_oauth_config(oauth_cfg):
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
if not oauth_cfg:
return oauth_cfg
if not isinstance(oauth_cfg, dict):
return {}
sanitized = dict(oauth_cfg)
for field_name in ("keys_file", "token_file"):
if sanitized.get(field_name):
sanitized[field_name] = _resolve_mcp_oauth_path(
sanitized[field_name],
field_name,
)
return sanitized
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
"""Check token existence without letting legacy bad paths break listing."""
if not isinstance(oauth_cfg, dict):
return False
try:
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
except HTTPException:
if strict:
raise
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
return True
return bool(token_file and not os.path.exists(token_file))
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
if not oauth_cfg or not isinstance(env, dict):
return
keys_file = oauth_cfg.get("keys_file")
token_file = oauth_cfg.get("token_file")
if keys_file:
env["GMAIL_OAUTH_PATH"] = keys_file
if token_file:
env["GMAIL_CREDENTIALS_PATH"] = token_file
def _load_disabled_map():
"""Load per-server disabled tool sets from DB."""
db = SessionLocal()
try:
disabled_map = {}
for srv in db.query(McpServer).all():
if srv.disabled_tools:
try:
names = json.loads(srv.disabled_tools)
if names:
disabled_map[srv.id] = set(names)
except (json.JSONDecodeError, TypeError):
pass
return disabled_map
finally:
db.close()
def _mcp_oauth_redirect_uri() -> str:
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
from src.mcp_oauth import REDIRECT_URI
return REDIRECT_URI
def setup_mcp_routes(mcp_manager: McpManager):
"""Setup MCP routes with the provided manager."""
@router.get("/servers")
def list_servers(request: Request):
"""List all configured MCP servers with connection status."""
require_admin(request)
db = SessionLocal()
try:
servers = db.query(McpServer).all()
result = []
for srv in servers:
status = mcp_manager.get_server_status(srv.id)
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
needs_oauth = False
if oauth_cfg:
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
total_tools = status.get("tool_count", 0)
result.append({
"id": srv.id,
"name": srv.name,
"transport": srv.transport,
"command": srv.command,
"args": json.loads(srv.args) if srv.args else [],
"env": json.loads(srv.env) if srv.env else {},
"url": srv.url,
"is_enabled": srv.is_enabled,
"status": status.get("status", "disconnected"),
"tool_count": total_tools,
"disabled_tool_count": len(disabled_list),
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"has_oauth": oauth_cfg is not None,
"needs_oauth": needs_oauth,
})
return result
finally:
db.close()
@router.post("/servers")
async def add_server(
request: Request,
name: str = Form(...),
transport: str = Form("stdio"),
command: str = Form(None),
args: str = Form("[]"),
env: str = Form("{}"),
url: str = Form(None),
oauth_file: str = Form(None),
oauth_config: str = Form(None),
):
"""Add a new MCP server config and attempt connection. Admin-only:
registering a stdio server is equivalent to executing arbitrary
binaries on the host."""
require_admin(request)
server_id = str(uuid.uuid4())[:8]
# Validate
if transport == "stdio" and not command:
raise HTTPException(400, "command is required for stdio transport")
if transport == "sse" and not url:
raise HTTPException(400, "url is required for SSE transport")
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
# Parse JSON fields
try:
parsed_args = json.loads(args) if args else []
except json.JSONDecodeError:
parsed_args = []
try:
parsed_env = json.loads(env) if env else {}
except json.JSONDecodeError:
parsed_env = {}
if not isinstance(parsed_env, dict):
parsed_env = {}
# Parse OAuth config
parsed_oauth_config = None
if oauth_config:
try:
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
except json.JSONDecodeError:
pass
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
# Write OAuth credentials file if provided (for Google MCP servers)
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
if oauth_file:
try:
oauth_data = json.loads(oauth_file)
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
oauth_filename = oauth_data.get("filename", "")
client_id = oauth_data.get("client_id", "")
client_secret = oauth_data.get("client_secret", "")
if oauth_dir and oauth_filename and client_id and client_secret:
filepath = _resolve_mcp_oauth_path(
Path(oauth_dir) / str(oauth_filename),
"filename",
)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
creds = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
}
}
with open(filepath, "w", encoding="utf-8") as f:
json.dump(creds, f, indent=2)
logger.info(f"Wrote OAuth credentials to {filepath}")
parsed_env.pop("GOOGLE_CLIENT_ID", None)
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Failed to write OAuth file: {e}")
# Save to DB
db = SessionLocal()
try:
srv = McpServer(
id=server_id,
name=name,
transport=transport,
command=command,
args=json.dumps(parsed_args),
env=json.dumps(parsed_env),
url=url,
is_enabled=True,
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
)
db.add(srv)
db.commit()
finally:
db.close()
# Check if OAuth token already exists — skip connection attempt if not
needs_oauth = False
if parsed_oauth_config:
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
connected = False
if not needs_oauth:
connected = await mcp_manager.connect_server(
server_id=server_id,
name=name,
transport=transport,
command=command,
args=parsed_args,
env=parsed_env,
url=url,
)
status = mcp_manager.get_server_status(server_id)
needs_auth = status.get("status") == "needs_auth"
return {
"id": server_id,
"name": name,
"connected": connected,
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
"needs_oauth": needs_oauth,
"needs_auth": needs_auth,
"auth_url": status.get("auth_url"),
}
@router.post("/servers/{server_id}/reconnect")
async def reconnect_server(server_id: str, request: Request):
"""Reconnect to an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
status = mcp_manager.get_server_status(server_id)
return {
"connected": connected,
"status": status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"needs_auth": status.get("status") == "needs_auth",
}
finally:
db.close()
@router.patch("/servers/{server_id}")
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
"""Enable or disable an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
enabled = str(is_enabled).lower() == "true"
srv.is_enabled = enabled
db.commit()
if enabled:
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
else:
await mcp_manager.disconnect_server(server_id)
return {"id": server_id, "is_enabled": enabled}
finally:
db.close()
@router.delete("/servers/{server_id}")
async def delete_server(server_id: str, request: Request):
"""Remove an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
db.delete(srv)
db.commit()
return {"status": "deleted"}
finally:
db.close()
@router.get("/tools")
def list_tools(request: Request):
"""List all discovered MCP tools across all connected servers."""
require_admin(request)
disabled_map = _load_disabled_map()
return mcp_manager.get_all_tools(disabled_map)
@router.get("/servers/{server_id}/tools")
def list_server_tools(server_id: str, request: Request):
"""List all tools for a specific MCP server with enabled/disabled state."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
disabled_set = set(disabled_list)
finally:
db.close()
all_tools = mcp_manager.get_all_tools()
server_tools = [t for t in all_tools if t["server_id"] == server_id]
for t in server_tools:
t["is_disabled"] = t["name"] in disabled_set
return server_tools
@router.patch("/servers/{server_id}/tools")
async def update_disabled_tools(server_id: str, request: Request):
"""Bulk update disabled tools list for a server.
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
body = await request.json()
disabled = body.get("disabled", [])
if not isinstance(disabled, list):
raise HTTPException(400, "disabled must be a list of tool names")
srv.disabled_tools = json.dumps(disabled) if disabled else None
db.commit()
return {"id": server_id, "disabled_count": len(disabled)}
finally:
db.close()
# ── OAuth flow for Google MCP servers ──────────────────────────
@router.get("/oauth/authorize/{server_id}")
def oauth_authorize(server_id: str, request: Request):
"""Show OAuth authorization page with Google sign-in link."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
if not srv.oauth_config:
raise HTTPException(400, "Server has no OAuth config")
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
if not keys_file or not os.path.exists(keys_file):
raise HTTPException(400, "OAuth keys file not found")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
if not keys:
raise HTTPException(400, "Invalid OAuth keys file format")
client_id = keys["client_id"]
scopes = oauth_cfg.get("scopes", [])
# For Desktop App creds, default to localhost — the user will
# paste the resulting URL back if they're on a different device.
redirect_uri = _mcp_oauth_redirect_uri()
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(scopes),
"access_type": "offline",
"prompt": "consent",
"state": server_id,
}
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
# Determine if user is accessing from the same machine
host = request.headers.get("host", "")
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
if is_local:
# Same machine — just redirect, callback will work directly
return RedirectResponse(auth_url)
else:
# Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
finally:
db.close()
@router.get("/oauth/callback")
async def oauth_callback(code: str, state: str, request: Request):
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
pending-state registry; Google flows fall through to the legacy path."""
require_admin(request)
from src.mcp_oauth import resolve_pending
if resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
# Legacy Google path: state is the server_id
return await _exchange_and_connect(state, code, request)
@router.post("/oauth/exchange/{server_id}")
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
"""Manual code exchange — user pastes the callback URL from their browser."""
require_admin(request)
try:
parsed = urllib.parse.urlparse(callback_url)
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
if not code:
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
except Exception:
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
# resolve it directly (the background connect finishes the handshake).
state = params.get("state", [None])[0]
from src.mcp_oauth import resolve_pending
if state and resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
return await _exchange_and_connect(server_id, code, request)
async def _exchange_and_connect(server_id: str, code: str, request: Request):
"""Exchange auth code for tokens and connect the MCP server."""
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
if not srv.oauth_config:
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
token_file = oauth_cfg.get("token_file", "")
if not keys_file or not token_file:
raise HTTPException(400, "OAuth keys/token file not configured")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
client_id = keys["client_id"]
client_secret = keys["client_secret"]
redirect_uri = _mcp_oauth_redirect_uri()
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
err = resp.text
logger.error(f"OAuth token exchange failed: {err}")
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
tokens = resp.json()
logger.info(f"OAuth tokens received for server {server_id}")
# Save tokens to the file the MCP package expects
os.makedirs(os.path.dirname(token_file), exist_ok=True)
with open(token_file, "w", encoding="utf-8") as f:
json.dump(tokens, f, indent=2)
logger.info(f"Saved OAuth tokens to {token_file}")
# Attempt to connect the MCP server now
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
if connected:
status = mcp_manager.get_server_status(server_id)
tool_count = status.get("tool_count", 0)
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
f"{srv.name} connected with {tool_count} tools. You can close this window.",
success=True,
))
else:
status = mcp_manager.get_server_status(server_id)
return HTMLResponse(_oauth_result_page(
"Authorized but Connection Failed",
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
))
except HTTPException as e:
logger.warning(f"OAuth callback rejected: {e.detail}")
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
finally:
db.close()
return router
def _oauth_authorize_page(
auth_url: str,
server_id: str,
host: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `host` comes from the request
# Host header and `server_id` from the OAuth state — neither is trusted.
auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>Authorize Odysseus</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 480px; text-align: center; }}
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
.step b {{ color: #e06c75; }}
a.auth-link {{
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
font-weight: 600; font-size: 0.9rem;
}}
a.auth-link:hover {{ background: #c55; }}
input[type=text] {{
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
}}
input:focus {{ outline: none; border-color: #e06c75; }}
button {{
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
}}
button:hover {{ background: #c55; }}
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
</style></head>
<body><div class="card">
<h2>Authorize Google Account</h2>
<div class="step">
<b>1.</b> Click the button below to sign in with Google<br>
<b>2.</b> After approving, your browser will show an error page that's normal<br>
<b>3.</b> Copy the full URL from your browser's address bar<br>
<b>4.</b> Paste it below and click Connect
</div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div>
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button>
</form>
</div></body></html>"""
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
"""Generate a simple HTML page for the OAuth result."""
safe_title = html.escape(title)
safe_message = html.escape(message)
color = "#00661a" if success else "#e06c75"
icon = "&#10003;" if success else "&#10007;"
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>{safe_title}</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 420px; text-align: center; }}
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
</style></head>
<body><div class="card">
<div class="icon">{icon}</div>
<h2>{safe_title}</h2>
<p>{safe_message}</p>
</div></body></html>"""
_sys.modules[__name__] = _canonical
+21 -5
View File
@@ -21,7 +21,7 @@ def _strip_list_prefix(text: str) -> str:
return text
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
from services.memory import MemoryManager
from services.memory import MemoryManager, MemoryStoreUnreadable
from core.session_manager import SessionManager
from src.request_models import MemoryAddRequest
from core.database import SessionLocal
@@ -35,6 +35,22 @@ from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
logger = logging.getLogger(__name__)
def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
"""Load the whole store for a read-modify-write cycle.
A transient read failure must not look like an empty store: the caller
would append to ``[]`` and save that back, atomically destroying every
existing memory (issue #5673). Surface it as a 503 and change nothing.
"""
try:
return memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Refusing to rewrite the memory store: %s", e)
raise HTTPException(
503, "Memory store is temporarily unreadable — no changes were made."
)
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
"""Set up memory-related routes."""
router = APIRouter(prefix="/api/memory", tags=["memory"])
@@ -116,7 +132,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
if memory_data.session_id:
new_entry["session_id"] = memory_data.session_id
all_mem = memory_manager.load_all()
all_mem = _load_for_update(memory_manager)
all_mem.append(new_entry)
memory_manager.save(all_mem)
# Sync vector index
@@ -487,7 +503,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
"""Pin or unpin a memory. Pinned memories are always included in context."""
user = _owner(request)
all_mem = memory_manager.load_all()
all_mem = _load_for_update(memory_manager)
for i, memory in enumerate(all_mem):
if memory["id"] == memory_id:
_verify_memory_owner(memory, user)
@@ -512,7 +528,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
"""Update an existing memory item with new text and optional category."""
user = _owner(request)
all_mem = memory_manager.load_all()
all_mem = _load_for_update(memory_manager)
for i, memory in enumerate(all_mem):
if memory["id"] == memory_id:
_verify_memory_owner(memory, user)
@@ -534,7 +550,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
def delete_memory(request: Request, memory_id: str):
"""Delete a memory item by its ID."""
user = _owner(request)
all_mem = memory_manager.load_all()
all_mem = _load_for_update(memory_manager)
# Find and verify ownership before deleting
target = next((m for m in all_mem if m["id"] == memory_id), None)
+2 -32
View File
@@ -46,10 +46,11 @@ _ENDPOINT_SETTING_FIELDS = {
}
_ENDPOINT_FALLBACK_FIELDS = {
"default_model_fallbacks": "Default Model Fallbacks",
"utility_model_fallbacks": "Utility Model Fallbacks",
"vision_model_fallbacks": "Vision Model Fallbacks",
}
# `default_model_fallbacks` is intentionally absent. The legacy data remains
# stored as-is even when an endpoint is removed, but no longer affects routing.
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
@@ -2437,7 +2438,6 @@ def setup_model_routes(model_discovery):
_user_prefs = _load_for_user(_user) or {}
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
model = (_user_prefs.get("default_model") or "").strip()
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
# If user has no personal default, fall back to global default
# But only based on the "share_defaults_with_users" flag
# (only if share_defaults_with_users is enabled)
@@ -2446,12 +2446,9 @@ def setup_model_routes(model_discovery):
ep_id = settings.get("default_endpoint_id", "")
if not model:
model = settings.get("default_model", "")
if not _fallbacks:
_fallbacks = settings.get("default_model_fallbacks") or []
else:
ep_id = settings.get("default_endpoint_id", "")
model = settings.get("default_model", "")
_fallbacks = settings.get("default_model_fallbacks") or []
db = SessionLocal()
try:
ep = None
@@ -2466,33 +2463,6 @@ def setup_model_routes(model_discovery):
if _user and not _is_admin:
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
ep = ep_q.first()
# Configured fallback chain — when the chosen default endpoint is
# gone/disabled, honor the user's configured `default_model_fallbacks`
# in order BEFORE arbitrarily grabbing the first enabled endpoint.
# (Previously this jumped straight to "first enabled", which is why
# deleting/changing the main endpoint silently reassigned the default
# chat to some unrelated endpoint instead of the fallback.)
if not ep:
for entry in _fallbacks:
if not isinstance(entry, dict):
continue
fid = (entry.get("endpoint_id") or "").strip()
if not fid:
continue
cand_q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True
)
if _user and not _is_admin:
cand_q = owner_filter(cand_q, ModelEndpoint, _user)
cand = cand_q.first()
if cand:
ep = cand
# Use the fallback entry's model. Reset even when empty
# so we don't carry the prior endpoint's stale model onto
# this fallback — the cached-models lookup below then
# fills it from the fallback endpoint.
model = (entry.get("model") or "").strip()
break
# Last resort: first enabled endpoint owned by THIS user. Do not
# include null-owner/shared endpoints here: a brand-new user with
# no explicit default should not auto-open a pending chat using an
+163 -92
View File
@@ -1,11 +1,13 @@
# routes/personal_routes.py
"""Routes for personal documents management."""
import asyncio
import os
import logging
import shutil
import uuid
from typing import Any, Dict, List, Tuple
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
from fastapi.concurrency import run_in_threadpool
from src.request_models import DirectoryRequest
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
from src.rag_singleton import get_rag_manager
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
logger = logging.getLogger(__name__)
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
"""Return the per-owner upload directory used for direct RAG uploads."""
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
"""
router = APIRouter(prefix="/api/personal")
# Serializes directory index jobs across requests. Indexing runs in the
# threadpool (#5558), so concurrent requests would otherwise run in parallel
# and race PersonalDocsManager's unsynchronized list mutations and file
# writes; before the threadpool move they serialized on the blocked event
# loop, so one-at-a-time is behavior parity.
#
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
# request parks on the event loop instead of pinning a threadpool worker (an
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
# tokens while blocked, starving every other run_in_threadpool caller).
# add/remove/reload all take this lock, so their mutations never interleave.
# Per-router (not module-global) so each app binds it to its own event loop.
# Scope is the single process: multi-worker deployments would need a shared
# lock (out of scope for #5558).
_index_job_lock = asyncio.Lock()
def _rag():
"""Get the current RAG manager, retrying init if needed."""
return get_rag_manager()
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
return {"files": files, "directories": directories}
@router.post("/reload")
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
personal_docs_manager.refresh_index()
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
# refresh_index() re-extracts text across every tracked directory —
# blocking work. Take the shared job lock (so it cannot race an add /
# remove) and run it off the event loop.
async with _index_job_lock:
await run_in_threadpool(personal_docs_manager.refresh_index)
return {"ok": True, "count": len(personal_docs_manager.index)}
@router.post("/add_directory")
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
# Use the RAGManager to index the directory
rag = _rag()
if rag:
result = rag.index_personal_documents(directory, owner=owner)
def _index_directory():
result = rag.index_personal_documents(directory, owner=owner)
if result["success"]:
# Also update the personal_docs_manager to track this
# directory. Kept inside the offloaded call: it triggers
# refresh_index(), which re-extracts text across tracked
# directories.
personal_docs_manager.add_directory(directory, index=False)
return result
# Indexing walks, embeds, and stores the whole tree — minutes
# on a real directory. The handler is async, so calling it
# inline runs it on the event loop and every other request
# queues behind it until it finishes (#5558). Serialize on the
# async job lock BEFORE offloading so a queued request parks on
# the loop instead of pinning a threadpool worker.
async with _index_job_lock:
result = await run_in_threadpool(_index_directory)
if result["success"]:
# Also update the personal_docs_manager to track this directory
personal_docs_manager.add_directory(directory, index=False)
return {
"success": True,
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
logger.info(f"Removing directory from RAG: {directory}")
# Always remove from personal_docs_manager tracking
if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort)
rag = _rag()
if rag:
try:
rag.remove_directory(directory)
except Exception as e:
logger.warning(f"RAG removal failed for directory {directory}: {e}")
def _remove_directory():
# Always remove from personal_docs_manager tracking. This
# mutates the same unsynchronized list/index an add job touches
# and re-extracts text (refresh_index), so it is blocking work.
if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort).
if rag:
try:
rag.remove_directory(directory)
except Exception as e:
logger.warning(f"RAG removal failed for directory {directory}: {e}")
# Same job lock as add/reload so remove cannot interleave with an
# in-flight add; offloaded off the event loop.
async with _index_job_lock:
await run_in_threadpool(_remove_directory)
return {
"success": True,
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
total_failed = 0
uploaded_files = []
for upload in files:
try:
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1
continue
with open(file_path, "wb") as f:
f.write(content_bytes)
ext = os.path.splitext(safe_name)[1].lower()
if ext == ".pdf":
from src.personal_docs import extract_pdf_text
text = extract_pdf_text(file_path)
else:
text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip():
total_failed += 1
continue
# Chunk and index
chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks):
metadata = {
"source": file_path,
"filename": safe_name,
"stored_filename": stored_name,
"directory": upload_dir,
"type": ext,
"chunk_id": i,
}
if user:
metadata["owner"] = user
if rag.add_document(chunk, metadata):
total_indexed += 1
else:
# Chunking, embedding and the tracking update are blocking work over the
# same vector/tracking state add_directory mutates (#5634). Take the
# shared job lock BEFORE offloading so a queued request parks on the loop
# instead of pinning a threadpool worker, matching add_directory.
# Read and process one capped payload at a time so a multi-file request
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
async with _index_job_lock:
for upload in files:
try:
file_path, stored_name, safe_name = _unique_personal_upload_path(
upload_dir, upload.filename
)
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1
continue
uploaded_files.append(safe_name)
except Exception as e:
logger.error(f"Failed to upload/index {upload.filename}: {e}")
total_failed += 1
def _index_upload():
with open(file_path, "wb") as f:
f.write(content_bytes)
# Track uploads directory
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
personal_docs_manager.add_directory(upload_dir, index=False)
ext = os.path.splitext(safe_name)[1].lower()
if ext == ".pdf":
from src.personal_docs import extract_pdf_text
text = extract_pdf_text(file_path)
else:
text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip():
return 0, 1, None
indexed = 0
failed = 0
chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks):
metadata = {
"source": file_path,
"filename": safe_name,
"stored_filename": stored_name,
"directory": upload_dir,
"type": ext,
"chunk_id": i,
}
if user:
metadata["owner"] = user
if rag.add_document(chunk, metadata):
indexed += 1
else:
failed += 1
return indexed, failed, safe_name
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
total_indexed += indexed
total_failed += failed
if uploaded_name:
uploaded_files.append(uploaded_name)
except Exception as e:
logger.error(f"Failed to upload/index {upload.filename}: {e}")
total_failed += 1
# Same transition, same lock: the tracking update must not land
# while another job is mid-write over the same state.
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
await run_in_threadpool(
personal_docs_manager.add_directory, upload_dir, index=False
)
return {
"success": True,
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
"""Delete a specific file from RAG index and optionally from disk."""
try:
# Remove chunks from RAG vector store (best-effort)
removed = 0
rag = _rag()
if rag:
try:
removed = rag.delete_by_source(filepath)
except Exception as e:
logger.warning(f"RAG removal failed for {filepath}: {e}")
def _delete_file():
# Remove chunks from RAG vector store (best-effort)
removed = 0
rag = _rag()
if rag:
try:
removed = rag.delete_by_source(filepath)
except Exception as e:
logger.warning(f"RAG removal failed for {filepath}: {e}")
# Delete file from disk if it's in the caller's own uploads dir.
# Scope to the per-owner subdir, not the shared uploads root, so one
# admin can't delete another user's personal files by path.
deleted_from_disk = False
try:
abs_target = os.path.realpath(filepath)
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
in_uploads = (
abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
)
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs:
# Delete file from disk if it's in the caller's own uploads dir.
# Scope to the per-owner subdir, not the shared uploads root, so one
# admin can't delete another user's personal files by path.
deleted_from_disk = False
try:
os.remove(abs_target)
deleted_from_disk = True
except FileNotFoundError:
pass # already gone — race with another request or cleanup
abs_target = os.path.realpath(filepath)
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
in_uploads = (
abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
)
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs:
try:
os.remove(abs_target)
deleted_from_disk = True
except FileNotFoundError:
pass # already gone — race with another request or cleanup
# Exclude the file from the listing (persists across restarts)
personal_docs_manager.exclude_file(filepath)
# Exclude the file from the listing (persists across restarts)
personal_docs_manager.exclude_file(filepath)
return removed, deleted_from_disk
# Vector removal, the disk unlink and the exclusion write are one
# transition over the same state add_directory mutates (#5634), and
# all three block. Take the shared job lock BEFORE offloading, as
# add_directory does.
async with _index_job_lock:
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
return {
"success": True,
+2 -8
View File
@@ -1,8 +1,8 @@
"""User preferences API — per-user key/value store backed by a JSON file."""
import json
import os
from typing import Optional
from fastapi import APIRouter, Request
from core.atomic_io import atomic_write_json
from src.auth_helpers import get_current_user
from src.constants import USER_PREFS_FILE
@@ -20,13 +20,7 @@ def _load():
def _save(prefs):
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True)
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(prefs, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, PREFS_FILE)
atomic_write_json(PREFS_FILE, prefs, indent=2)
def _load_for_user(user: Optional[str] = None) -> dict:
+5
View File
@@ -0,0 +1,5 @@
"""Search route domain package (slice 2j, #4082/#4071).
Contains search_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/search_routes.py re-exports from here.
"""
+111
View File
@@ -0,0 +1,111 @@
"""Search routes — /api/search/config GET, /api/search POST."""
import logging
from typing import Dict, Any
from fastapi import APIRouter, Request
import time
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
logger = logging.getLogger(__name__)
async def _request_values(request: Request) -> Dict[str, Any]:
"""Accept JSON, form data, or query params for search endpoints.
The browser UI posts FormData, while the agent's generic app_api tool
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
runs, which made the model think SearXNG was broken.
"""
values: Dict[str, Any] = dict(request.query_params)
content_type = (request.headers.get("content-type") or "").lower()
try:
if "application/json" in content_type:
body = await request.json()
if isinstance(body, dict):
values.update(body)
else:
form = await request.form()
values.update(dict(form))
except Exception:
pass
return values
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
return get_search_config()
@router.post("/api/search")
async def do_web_search(request: Request) -> Dict[str, Any]:
"""Standalone web search — returns context string + source list.
Used by Compare mode to pre-search once and share results across panes.
"""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
return {"context": "", "sources": [], "error": "query is required"}
time_filter = values.get("time_filter") or values.get("freshness")
if time_filter is not None:
time_filter = str(time_filter).strip() or None
try:
context, sources = comprehensive_web_search(
query, return_sources=True, time_filter=time_filter,
)
return {"context": context, "sources": sources}
except Exception as e:
logger.error(f"Standalone web search failed: {e}")
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
"""Return available search providers with config status."""
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
continue
available = True
if needs_key and not _get_provider_key(pid):
available = False
if needs_url and pid == "searxng" and not _get_search_instance():
available = False
providers.append({
"id": pid,
"label": label,
"available": available,
})
return providers
@router.post("/api/search/query")
async def search_with_provider(request: Request) -> Dict[str, Any]:
"""Search using a specific provider. Used by compare search mode."""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
try:
count = int(values.get("count") or values.get("limit") or 10)
except Exception:
count = 10
if not query:
return {"results": [], "provider": provider, "error": "query is required"}
if provider not in PROVIDER_INFO or provider == "disabled":
return {"results": [], "provider": provider, "error": "Unknown provider"}
t0 = time.time()
try:
results = _call_provider(provider, query, min(count, 20))
elapsed = round(time.time() - t0, 2)
return {"results": results, "provider": provider, "time": elapsed}
except Exception as e:
elapsed = round(time.time() - t0, 2)
logger.error(f"Search provider {provider} failed: {e}")
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
return router
+9 -107
View File
@@ -1,111 +1,13 @@
"""Search routes — /api/search/config GET, /api/search POST."""
"""Backward-compat shim — canonical location is routes/search/search_routes.py.
import logging
from typing import Dict, Any
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.search_routes`` and ``from routes.search_routes import X``
keep resolving to the canonical module. Keeps existing import paths working
after slice 2j (#4082/#4071).
"""
from fastapi import APIRouter, Request
import sys as _sys
import time
from routes.search import search_routes as _canonical # noqa: F401
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
logger = logging.getLogger(__name__)
async def _request_values(request: Request) -> Dict[str, Any]:
"""Accept JSON, form data, or query params for search endpoints.
The browser UI posts FormData, while the agent's generic app_api tool
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
runs, which made the model think SearXNG was broken.
"""
values: Dict[str, Any] = dict(request.query_params)
content_type = (request.headers.get("content-type") or "").lower()
try:
if "application/json" in content_type:
body = await request.json()
if isinstance(body, dict):
values.update(body)
else:
form = await request.form()
values.update(dict(form))
except Exception:
pass
return values
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
return get_search_config()
@router.post("/api/search")
async def do_web_search(request: Request) -> Dict[str, Any]:
"""Standalone web search — returns context string + source list.
Used by Compare mode to pre-search once and share results across panes.
"""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
return {"context": "", "sources": [], "error": "query is required"}
time_filter = values.get("time_filter") or values.get("freshness")
if time_filter is not None:
time_filter = str(time_filter).strip() or None
try:
context, sources = comprehensive_web_search(
query, return_sources=True, time_filter=time_filter,
)
return {"context": context, "sources": sources}
except Exception as e:
logger.error(f"Standalone web search failed: {e}")
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
"""Return available search providers with config status."""
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
continue
available = True
if needs_key and not _get_provider_key(pid):
available = False
if needs_url and pid == "searxng" and not _get_search_instance():
available = False
providers.append({
"id": pid,
"label": label,
"available": available,
})
return providers
@router.post("/api/search/query")
async def search_with_provider(request: Request) -> Dict[str, Any]:
"""Search using a specific provider. Used by compare search mode."""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
try:
count = int(values.get("count") or values.get("limit") or 10)
except Exception:
count = 10
if not query:
return {"results": [], "provider": provider, "error": "query is required"}
if provider not in PROVIDER_INFO or provider == "disabled":
return {"results": [], "provider": provider, "error": "Unknown provider"}
t0 = time.time()
try:
results = _call_provider(provider, query, min(count, 20))
elapsed = round(time.time() - t0, 2)
return {"results": results, "provider": provider, "time": elapsed}
except Exception as e:
elapsed = round(time.time() - t0, 2)
logger.error(f"Search provider {provider} failed: {e}")
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
return router
_sys.modules[__name__] = _canonical
-9
View File
@@ -801,15 +801,6 @@ def setup_session_routes(
finally:
db.close()
@router.get("/history/{sid}")
def get_history(request: Request, sid: str):
_verify_session_owner(request, sid)
try:
session = session_manager.get_session(sid)
except KeyError:
raise HTTPException(404, f"Session {sid} not found")
return {"history": [msg.to_dict() for msg in session.history]}
@router.get("/session/{sid}/export")
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
"""Export conversation history as a downloadable file.
+1 -1
View File
@@ -1409,7 +1409,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
# session's model. Fall back to the caller's session model only if unset.
url, model, headers = resolve_endpoint("default", owner=user)
url, model, headers = resolve_endpoint("utility", owner=user)
if not url or not model:
url = url or ((body.get("endpoint_url") or "").strip() or None)
model = model or ((body.get("model") or "").strip() or None)
+5
View File
@@ -0,0 +1,5 @@
"""Vault route domain package (slice 2k, #4082/#4071).
Contains vault_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/vault_routes.py re-exports from here.
"""
+242
View File
@@ -0,0 +1,242 @@
"""
vault_routes.py
Vaultwarden / Bitwarden CLI integration config and unlock endpoints.
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
"""
import json
import logging
import os
import shutil
import asyncio
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Request
from pydantic import BaseModel
from core.middleware import require_admin
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
from src.constants import VAULT_FILE as _VAULT_FILE
logger = logging.getLogger(__name__)
VAULT_FILE = Path(_VAULT_FILE)
def _find_bw() -> str:
"""Locate the bw binary, checking PATH and common npm-global locations.
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
which_tool via PATHEXT.
"""
p = which_tool("bw")
if p:
return p
if IS_WINDOWS:
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
for candidate in (
os.path.join(appdata, "npm", "bw.cmd"),
os.path.join(appdata, "npm", "bw.exe"),
):
if os.path.isfile(candidate):
return candidate
return "bw"
home = os.path.expanduser("~")
for candidate in (
f"{home}/.npm-global/bin/bw",
f"{home}/.nvm/versions/node/*/bin/bw",
"/usr/local/bin/bw",
"/opt/homebrew/bin/bw",
):
if "*" in candidate:
import glob
for m in glob.glob(candidate):
if os.path.isfile(m) and os.access(m, os.X_OK):
return m
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
def _load_config() -> dict:
if VAULT_FILE.exists():
try:
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
pass
return {}
def _save_config(cfg: dict):
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
# is ACL-restricted already).
safe_chmod(str(VAULT_FILE), 0o600)
async def _run_bw(args: list, session: str = None, input_text: str = None,
bw_password: str = None) -> tuple:
env = {}
env.update(os.environ)
if session:
env["BW_SESSION"] = session
# Secrets must never be passed as argv — process arguments are world-readable
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
# support for bw commands that need it; unlock/login callers should prefer
# stdin so the master password is not left in the child environment either.
if bw_password is not None:
env["BW_PASSWORD"] = bw_password
bw_path = _find_bw()
try:
proc = await asyncio.create_subprocess_exec(
bw_path, *args,
stdin=asyncio.subprocess.PIPE if input_text else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
except FileNotFoundError:
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
except Exception as e:
return "", f"Failed to launch bw: {e}", 1
try:
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
except Exception as e:
return "", f"bw subprocess error: {e}", 1
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
class VaultConfig(BaseModel):
server_url: str = ""
email: str = ""
class VaultUnlockRequest(BaseModel):
master_password: str
class VaultLoginRequest(BaseModel):
email: str
master_password: str
def setup_vault_routes():
router = APIRouter(prefix="/api/vault", tags=["vault"])
@router.get("/config")
async def get_config(request: Request):
"""Return vault config (no sensitive fields)."""
require_admin(request)
cfg = _load_config()
return {
"server_url": cfg.get("server_url", ""),
"email": cfg.get("email", ""),
"unlocked": bool(cfg.get("session")),
"unlocked_at": cfg.get("unlocked_at", ""),
"bw_installed": await _check_bw_installed(),
}
@router.post("/config")
async def save_config(req: VaultConfig, request: Request):
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
require_admin(request)
cfg = _load_config()
cfg["server_url"] = req.server_url.strip().rstrip("/")
cfg["email"] = req.email.strip()
if cfg["server_url"]:
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
if rc != 0:
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
_save_config(cfg)
return {"ok": True}
@router.post("/login")
async def login(req: VaultLoginRequest, request: Request):
"""Log in to Vaultwarden (required once per account)."""
require_admin(request)
cfg = _load_config()
# Update email
cfg["email"] = req.email
_save_config(cfg)
stdout, stderr, rc = await _run_bw(
["login", req.email, "--raw"],
input_text=req.master_password + "\n",
)
if rc != 0:
# Already logged in is OK
if "already logged in" in stderr.lower():
return {"ok": True, "already": True}
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
# bw login --raw prints session key on success (when 2FA disabled)
if stdout:
cfg["session"] = stdout
cfg["unlocked_at"] = datetime.utcnow().isoformat()
_save_config(cfg)
return {"ok": True}
@router.post("/unlock")
async def unlock(req: VaultUnlockRequest, request: Request):
"""Unlock the vault and save the session key."""
require_admin(request)
# Pass the master password on stdin, not argv. argv is visible through
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
# the child process environment.
stdout, stderr, rc = await _run_bw(
["unlock", "--raw"],
input_text=req.master_password + "\n",
)
if rc != 0:
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
session = stdout.strip()
if not session:
return {"ok": False, "error": "bw returned empty session"}
cfg = _load_config()
cfg["session"] = session
cfg["unlocked_at"] = datetime.utcnow().isoformat()
_save_config(cfg)
return {"ok": True, "message": "Vault unlocked"}
@router.post("/lock")
async def lock(request: Request):
"""Lock the vault (clear session from config)."""
require_admin(request)
cfg = _load_config()
cfg.pop("session", None)
cfg.pop("unlocked_at", None)
_save_config(cfg)
# Also tell bw to lock
await _run_bw(["lock"])
return {"ok": True, "message": "Vault locked"}
@router.post("/logout")
async def logout(request: Request):
"""Log out of the Bitwarden CLI completely."""
require_admin(request)
await _run_bw(["logout"])
cfg = _load_config()
cfg.pop("session", None)
cfg.pop("email", None)
cfg.pop("unlocked_at", None)
_save_config(cfg)
return {"ok": True}
return router
async def _check_bw_installed() -> bool:
try:
proc = await asyncio.create_subprocess_exec(
_find_bw(), "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
return proc.returncode == 0
except Exception:
return False
+9 -237
View File
@@ -1,242 +1,14 @@
"""
vault_routes.py
"""Backward-compat shim — canonical location is routes/vault/vault_routes.py.
Vaultwarden / Bitwarden CLI integration config and unlock endpoints.
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.vault_routes``, ``from routes.vault_routes import X``,
and the ``import ... as vr`` + ``monkeypatch.setattr(vr, ...)`` pattern used
by test_vault_password_not_in_argv.py all operate on the *same* object.
Keeps existing import paths working after slice 2k (#4082/#4071).
"""
import json
import logging
import os
import shutil
import asyncio
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Request
from pydantic import BaseModel
import sys as _sys
from core.middleware import require_admin
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
from src.constants import VAULT_FILE as _VAULT_FILE
from routes.vault import vault_routes as _canonical # noqa: F401
logger = logging.getLogger(__name__)
VAULT_FILE = Path(_VAULT_FILE)
def _find_bw() -> str:
"""Locate the bw binary, checking PATH and common npm-global locations.
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
which_tool via PATHEXT.
"""
p = which_tool("bw")
if p:
return p
if IS_WINDOWS:
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
for candidate in (
os.path.join(appdata, "npm", "bw.cmd"),
os.path.join(appdata, "npm", "bw.exe"),
):
if os.path.isfile(candidate):
return candidate
return "bw"
home = os.path.expanduser("~")
for candidate in (
f"{home}/.npm-global/bin/bw",
f"{home}/.nvm/versions/node/*/bin/bw",
"/usr/local/bin/bw",
"/opt/homebrew/bin/bw",
):
if "*" in candidate:
import glob
for m in glob.glob(candidate):
if os.path.isfile(m) and os.access(m, os.X_OK):
return m
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
def _load_config() -> dict:
if VAULT_FILE.exists():
try:
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
pass
return {}
def _save_config(cfg: dict):
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
# is ACL-restricted already).
safe_chmod(str(VAULT_FILE), 0o600)
async def _run_bw(args: list, session: str = None, input_text: str = None,
bw_password: str = None) -> tuple:
env = {}
env.update(os.environ)
if session:
env["BW_SESSION"] = session
# Secrets must never be passed as argv — process arguments are world-readable
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
# support for bw commands that need it; unlock/login callers should prefer
# stdin so the master password is not left in the child environment either.
if bw_password is not None:
env["BW_PASSWORD"] = bw_password
bw_path = _find_bw()
try:
proc = await asyncio.create_subprocess_exec(
bw_path, *args,
stdin=asyncio.subprocess.PIPE if input_text else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
except FileNotFoundError:
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
except Exception as e:
return "", f"Failed to launch bw: {e}", 1
try:
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
except Exception as e:
return "", f"bw subprocess error: {e}", 1
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
class VaultConfig(BaseModel):
server_url: str = ""
email: str = ""
class VaultUnlockRequest(BaseModel):
master_password: str
class VaultLoginRequest(BaseModel):
email: str
master_password: str
def setup_vault_routes():
router = APIRouter(prefix="/api/vault", tags=["vault"])
@router.get("/config")
async def get_config(request: Request):
"""Return vault config (no sensitive fields)."""
require_admin(request)
cfg = _load_config()
return {
"server_url": cfg.get("server_url", ""),
"email": cfg.get("email", ""),
"unlocked": bool(cfg.get("session")),
"unlocked_at": cfg.get("unlocked_at", ""),
"bw_installed": await _check_bw_installed(),
}
@router.post("/config")
async def save_config(req: VaultConfig, request: Request):
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
require_admin(request)
cfg = _load_config()
cfg["server_url"] = req.server_url.strip().rstrip("/")
cfg["email"] = req.email.strip()
if cfg["server_url"]:
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
if rc != 0:
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
_save_config(cfg)
return {"ok": True}
@router.post("/login")
async def login(req: VaultLoginRequest, request: Request):
"""Log in to Vaultwarden (required once per account)."""
require_admin(request)
cfg = _load_config()
# Update email
cfg["email"] = req.email
_save_config(cfg)
stdout, stderr, rc = await _run_bw(
["login", req.email, "--raw"],
input_text=req.master_password + "\n",
)
if rc != 0:
# Already logged in is OK
if "already logged in" in stderr.lower():
return {"ok": True, "already": True}
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
# bw login --raw prints session key on success (when 2FA disabled)
if stdout:
cfg["session"] = stdout
cfg["unlocked_at"] = datetime.utcnow().isoformat()
_save_config(cfg)
return {"ok": True}
@router.post("/unlock")
async def unlock(req: VaultUnlockRequest, request: Request):
"""Unlock the vault and save the session key."""
require_admin(request)
# Pass the master password on stdin, not argv. argv is visible through
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
# the child process environment.
stdout, stderr, rc = await _run_bw(
["unlock", "--raw"],
input_text=req.master_password + "\n",
)
if rc != 0:
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
session = stdout.strip()
if not session:
return {"ok": False, "error": "bw returned empty session"}
cfg = _load_config()
cfg["session"] = session
cfg["unlocked_at"] = datetime.utcnow().isoformat()
_save_config(cfg)
return {"ok": True, "message": "Vault unlocked"}
@router.post("/lock")
async def lock(request: Request):
"""Lock the vault (clear session from config)."""
require_admin(request)
cfg = _load_config()
cfg.pop("session", None)
cfg.pop("unlocked_at", None)
_save_config(cfg)
# Also tell bw to lock
await _run_bw(["lock"])
return {"ok": True, "message": "Vault locked"}
@router.post("/logout")
async def logout(request: Request):
"""Log out of the Bitwarden CLI completely."""
require_admin(request)
await _run_bw(["logout"])
cfg = _load_config()
cfg.pop("session", None)
cfg.pop("email", None)
cfg.pop("unlocked_at", None)
_save_config(cfg)
return {"ok": True}
return router
async def _check_bw_installed() -> bool:
try:
proc = await asyncio.create_subprocess_exec(
_find_bw(), "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
return proc.returncode == 0
except Exception:
return False
_sys.modules[__name__] = _canonical
+5
View File
@@ -0,0 +1,5 @@
"""Webhook route domain package (slice 2l, #4082/#4071).
Contains webhook_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/webhook_routes.py re-exports from here.
"""
+395
View File
@@ -0,0 +1,395 @@
"""Webhook, API Token, and sync chat routes."""
import uuid
import logging
from typing import Optional
import httpx
from fastapi import APIRouter, HTTPException, Request, Form
from pydantic import BaseModel, Field
from core.database import SessionLocal, Webhook, ModelEndpoint
from src.auth_helpers import owner_filter
from src.url_security import validate_public_http_url
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["webhooks"])
# Input limits
MAX_NAME_LEN = 100
MAX_URL_LEN = 2048
MAX_SECRET_LEN = 256
MAX_MESSAGE_LEN = 32_000
from core.middleware import require_admin as _require_admin
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
let a chat-scoped token fall back onto another user's private endpoint and
silently spend that owner's API key/quota. Prefer owner rows before shared
rows. Fails closed to null-owner rows only when token_owner is absent.
Does not validate base_url admin-configured local/LAN endpoints remain allowed.
"""
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
if token_owner:
query = owner_filter(query, ModelEndpoint, token_owner)
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
def _caller_owns_session(sess_owner, caller) -> bool:
"""Strict session-ownership gate for the token-authenticated sync-chat
endpoint (`POST /api/v1/chat`).
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
gates in notes/calendar/gallery: a caller may resume a session ONLY when
its owner matches them exactly. A null/empty session owner (legacy or
migrated rows) is deliberately NOT resumable by an arbitrary token the
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
device) could resume such a session, inject a message, and read back its
history and reuse the owner's endpoint credentials. Fail closed: an
unresolvable caller also returns False.
"""
if not caller:
return False
return sess_owner == caller
def setup_webhook_routes(
webhook_manager: WebhookManager,
auth_manager,
session_manager=None,
api_key_manager=None,
) -> APIRouter:
@router.get("/webhooks")
def list_webhooks(request: Request):
_require_admin(request)
db = SessionLocal()
try:
hooks = db.query(Webhook).all()
return [
{
"id": w.id,
"name": w.name,
"url": w.url,
"has_secret": bool(w.secret),
"events": w.events.split(",") if w.events else [],
"is_active": w.is_active,
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
"last_status_code": w.last_status_code,
"last_error": w.last_error,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in hooks
]
finally:
db.close()
@router.post("/webhooks")
def create_webhook(
request: Request,
name: str = Form(""),
url: str = Form(""),
secret: str = Form(""),
events: str = Form(""),
):
_require_admin(request)
name = name.strip()[:MAX_NAME_LEN]
if not name:
raise HTTPException(400, "Webhook name is required")
try:
url = validate_webhook_url(url)
except ValueError as e:
raise HTTPException(400, str(e))
try:
events = validate_events(events)
except ValueError as e:
raise HTTPException(400, str(e))
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
# Encrypt the secret at rest using the same Fernet key as API keys
encrypted_secret = None
if secret_val and api_key_manager:
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
elif secret_val:
encrypted_secret = secret_val # Fallback if no encryption available
webhook_id = str(uuid.uuid4())[:8]
db = SessionLocal()
try:
db.add(Webhook(
id=webhook_id,
name=name,
url=url,
secret=encrypted_secret,
events=events,
is_active=True,
))
db.commit()
finally:
db.close()
return {"id": webhook_id, "name": name}
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook(request: Request, webhook_id: str):
_require_admin(request)
db = SessionLocal()
try:
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(404, "Webhook not found")
url, secret = wh.url, wh.secret
finally:
db.close()
await webhook_manager.deliver_test(webhook_id, url, secret)
return {"status": "sent"}
@router.patch("/webhooks/{webhook_id}")
def toggle_webhook(request: Request, webhook_id: str):
_require_admin(request)
db = SessionLocal()
try:
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(404, "Webhook not found")
wh.is_active = not wh.is_active
db.commit()
return {"id": webhook_id, "is_active": wh.is_active}
finally:
db.close()
@router.delete("/webhooks/{webhook_id}")
def delete_webhook(request: Request, webhook_id: str):
_require_admin(request)
db = SessionLocal()
try:
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
db.commit()
if not deleted:
raise HTTPException(404, "Webhook not found")
finally:
db.close()
return {"status": "deleted"}
# ================================================================
# Sync Chat Endpoint (for n8n / Make / Activepieces)
# ================================================================
# Known provider base URLs — auto-resolved from api_key prefix or model name
KNOWN_PROVIDERS = {
"deepseek": "https://api.deepseek.com/v1",
"openai": "https://api.openai.com/v1",
"mistral": "https://api.mistral.ai/v1",
"groq": "https://api.groq.com/openai/v1",
"together": "https://api.together.xyz/v1",
"openrouter": "https://openrouter.ai/api/v1",
"ollama": "https://ollama.com/api",
"opencode-zen": "https://opencode.ai/zen/v1",
"opencode-go": "https://opencode.ai/zen/go/v1",
"fireworks": "https://api.fireworks.ai/inference/v1",
"venice": "https://api.venice.ai/api/v1",
"kimi-code": "https://api.kimi.com/coding/v1",
"kimicode": "https://api.kimi.com/coding/v1",
}
# Model prefix → provider mapping for auto-detection
MODEL_PROVIDER_MAP = {
"deepseek": "deepseek",
"gpt-": "openai",
"o1": "openai",
"o3": "openai",
"o4": "openai",
"mistral": "mistral",
"llama": "groq",
"mixtral": "groq",
"kimi-for-coding": "kimi-code",
"kimi": "kimi-code",
}
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
"""Try to auto-resolve a base URL from provider name or model prefix."""
if provider and provider.lower() in KNOWN_PROVIDERS:
return KNOWN_PROVIDERS[provider.lower()]
if model:
model_lower = model.lower()
for prefix, prov in MODEL_PROVIDER_MAP.items():
if model_lower.startswith(prefix):
return KNOWN_PROVIDERS[prov]
return None
class SyncChatRequest(BaseModel):
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
model: Optional[str] = Field(None, max_length=200)
session: Optional[str] = Field(None, max_length=100)
api_key: Optional[str] = Field(None, max_length=256)
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
provider: Optional[str] = Field(None, max_length=50)
@router.post("/v1/chat")
async def sync_chat(request: Request, body: SyncChatRequest):
if not getattr(request.state, "api_token", False):
raise HTTPException(403, "This endpoint requires an API token")
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
token_owner = getattr(request.state, "api_token_owner", None)
from core.models import ChatMessage
from src.llm_core import llm_call_async
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
message = body.message.strip()
if not message:
raise HTTPException(400, "Message is required")
session_id = body.session
sess = None
# --- Case 1: Resume an existing session ---
if session_id and session_manager:
try:
sess = session_manager.get_session(session_id)
except (KeyError, Exception):
raise HTTPException(404, "Session not found")
# SECURITY: verify the API-token's user owns this session — without
# this any token holder could resume any user's chat by passing its
# ID. The token's user is on request.state.user (set by API-token
# middleware); fall back to require_user if not present.
try:
from src.auth_helpers import get_current_user as _gcu
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
except Exception:
_tok_user = None
# Strict ownership (see _caller_owns_session): fail closed so a
# null-owner / cross-owner session can't be resumed by an arbitrary
# chat-scoped token.
_sess_owner = getattr(sess, "owner", None)
if not _caller_owns_session(_sess_owner, _tok_user):
raise HTTPException(404, "Session not found")
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
if not sess and body.api_key:
api_key = body.api_key.strip()
model = body.model or "deepseek-chat"
# Validate only token-supplied direct base_url; auto-resolved known-provider
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
if direct_base_url:
try:
base_url = validate_public_http_url(direct_base_url)
except ValueError as e:
detail = str(e).replace("URL", "base_url", 1)
raise HTTPException(400, detail)
else:
base_url = _resolve_base_url(model, body.provider)
if not base_url:
raise HTTPException(400,
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
"or provider ('deepseek', 'openai', 'groq', etc.)")
base_url = normalize_base(base_url)
endpoint_url = build_chat_url(base_url)
if not session_manager:
raise HTTPException(500, "Session manager not available")
sid = str(uuid.uuid4())
sess = session_manager.create_session(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
# --- Case 3: Fall back to first configured ModelEndpoint ---
if not sess:
db = SessionLocal()
try:
ep = _select_api_chat_fallback_endpoint(db, token_owner)
finally:
db.close()
if not ep:
raise HTTPException(400,
"No session, api_key, or configured endpoints. "
"Pass api_key + model, or configure an endpoint in Admin.")
base_url = normalize_base(ep.base_url)
endpoint_url = build_chat_url(base_url)
model = body.model or "auto"
api_key = ep.api_key
if getattr(ep, "provider_auth_id", None):
try:
from src.endpoint_resolver import resolve_endpoint_runtime
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
endpoint_url = build_chat_url(base_url)
except Exception:
raise HTTPException(500, "Could not resolve endpoint credentials")
if model == "auto":
try:
async with httpx.AsyncClient(timeout=5) as client:
models_url = build_models_url(base_url)
hdrs = build_headers(api_key, base_url)
if models_url:
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
data = resp.json()
items = data if isinstance(data, list) else (data.get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
if not ids and isinstance(data, dict):
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
import json as _json
ids = _json.loads(ep.cached_models or "[]")
model = ids[0] if ids else "auto"
except Exception:
raise HTTPException(500, "Could not discover models from endpoint")
if not session_manager:
raise HTTPException(500, "Session manager not available")
sid = str(uuid.uuid4())
sess = session_manager.create_session(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
if api_key:
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
# --- Send message and get response ---
sess.add_message(ChatMessage("user", message))
messages = [{"role": m.role, "content": m.content} for m in sess.history]
reply = await llm_call_async(
sess.endpoint_url, sess.model, messages,
headers=sess.headers, timeout=120,
)
sess.add_message(ChatMessage("assistant", reply))
session_manager.save_sessions()
webhook_manager.fire_and_forget("chat.completed", {
"session_id": session_id, "model": sess.model,
"user_message": message[:2000], "response": reply[:2000],
})
return {"response": reply, "session_id": session_id, "model": sess.model}
return router
+12 -391
View File
@@ -1,395 +1,16 @@
"""Webhook, API Token, and sync chat routes."""
"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
import uuid
import logging
from typing import Optional
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
``importlib.import_module("routes.webhook_routes")``, and the
``__import__("routes.webhook_routes", fromlist=[...])`` + ``setattr(wh_mod,
...)`` pattern used by test_null_owner_gates.py all operate on the *same*
object. Keeps existing import paths working after slice 2l (#4082/#4071).
Source-introspection tests read the canonical file by path.
"""
import httpx
from fastapi import APIRouter, HTTPException, Request, Form
from pydantic import BaseModel, Field
import sys as _sys
from core.database import SessionLocal, Webhook, ModelEndpoint
from src.auth_helpers import owner_filter
from src.url_security import validate_public_http_url
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
from routes.webhook import webhook_routes as _canonical # noqa: F401
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["webhooks"])
# Input limits
MAX_NAME_LEN = 100
MAX_URL_LEN = 2048
MAX_SECRET_LEN = 256
MAX_MESSAGE_LEN = 32_000
from core.middleware import require_admin as _require_admin
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
let a chat-scoped token fall back onto another user's private endpoint and
silently spend that owner's API key/quota. Prefer owner rows before shared
rows. Fails closed to null-owner rows only when token_owner is absent.
Does not validate base_url admin-configured local/LAN endpoints remain allowed.
"""
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
if token_owner:
query = owner_filter(query, ModelEndpoint, token_owner)
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
def _caller_owns_session(sess_owner, caller) -> bool:
"""Strict session-ownership gate for the token-authenticated sync-chat
endpoint (`POST /api/v1/chat`).
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
gates in notes/calendar/gallery: a caller may resume a session ONLY when
its owner matches them exactly. A null/empty session owner (legacy or
migrated rows) is deliberately NOT resumable by an arbitrary token the
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
device) could resume such a session, inject a message, and read back its
history and reuse the owner's endpoint credentials. Fail closed: an
unresolvable caller also returns False.
"""
if not caller:
return False
return sess_owner == caller
def setup_webhook_routes(
webhook_manager: WebhookManager,
auth_manager,
session_manager=None,
api_key_manager=None,
) -> APIRouter:
@router.get("/webhooks")
def list_webhooks(request: Request):
_require_admin(request)
db = SessionLocal()
try:
hooks = db.query(Webhook).all()
return [
{
"id": w.id,
"name": w.name,
"url": w.url,
"has_secret": bool(w.secret),
"events": w.events.split(",") if w.events else [],
"is_active": w.is_active,
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
"last_status_code": w.last_status_code,
"last_error": w.last_error,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in hooks
]
finally:
db.close()
@router.post("/webhooks")
def create_webhook(
request: Request,
name: str = Form(""),
url: str = Form(""),
secret: str = Form(""),
events: str = Form(""),
):
_require_admin(request)
name = name.strip()[:MAX_NAME_LEN]
if not name:
raise HTTPException(400, "Webhook name is required")
try:
url = validate_webhook_url(url)
except ValueError as e:
raise HTTPException(400, str(e))
try:
events = validate_events(events)
except ValueError as e:
raise HTTPException(400, str(e))
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
# Encrypt the secret at rest using the same Fernet key as API keys
encrypted_secret = None
if secret_val and api_key_manager:
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
elif secret_val:
encrypted_secret = secret_val # Fallback if no encryption available
webhook_id = str(uuid.uuid4())[:8]
db = SessionLocal()
try:
db.add(Webhook(
id=webhook_id,
name=name,
url=url,
secret=encrypted_secret,
events=events,
is_active=True,
))
db.commit()
finally:
db.close()
return {"id": webhook_id, "name": name}
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook(request: Request, webhook_id: str):
_require_admin(request)
db = SessionLocal()
try:
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(404, "Webhook not found")
url, secret = wh.url, wh.secret
finally:
db.close()
await webhook_manager.deliver_test(webhook_id, url, secret)
return {"status": "sent"}
@router.patch("/webhooks/{webhook_id}")
def toggle_webhook(request: Request, webhook_id: str):
_require_admin(request)
db = SessionLocal()
try:
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(404, "Webhook not found")
wh.is_active = not wh.is_active
db.commit()
return {"id": webhook_id, "is_active": wh.is_active}
finally:
db.close()
@router.delete("/webhooks/{webhook_id}")
def delete_webhook(request: Request, webhook_id: str):
_require_admin(request)
db = SessionLocal()
try:
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
db.commit()
if not deleted:
raise HTTPException(404, "Webhook not found")
finally:
db.close()
return {"status": "deleted"}
# ================================================================
# Sync Chat Endpoint (for n8n / Make / Activepieces)
# ================================================================
# Known provider base URLs — auto-resolved from api_key prefix or model name
KNOWN_PROVIDERS = {
"deepseek": "https://api.deepseek.com/v1",
"openai": "https://api.openai.com/v1",
"mistral": "https://api.mistral.ai/v1",
"groq": "https://api.groq.com/openai/v1",
"together": "https://api.together.xyz/v1",
"openrouter": "https://openrouter.ai/api/v1",
"ollama": "https://ollama.com/api",
"opencode-zen": "https://opencode.ai/zen/v1",
"opencode-go": "https://opencode.ai/zen/go/v1",
"fireworks": "https://api.fireworks.ai/inference/v1",
"venice": "https://api.venice.ai/api/v1",
"kimi-code": "https://api.kimi.com/coding/v1",
"kimicode": "https://api.kimi.com/coding/v1",
}
# Model prefix → provider mapping for auto-detection
MODEL_PROVIDER_MAP = {
"deepseek": "deepseek",
"gpt-": "openai",
"o1": "openai",
"o3": "openai",
"o4": "openai",
"mistral": "mistral",
"llama": "groq",
"mixtral": "groq",
"kimi-for-coding": "kimi-code",
"kimi": "kimi-code",
}
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
"""Try to auto-resolve a base URL from provider name or model prefix."""
if provider and provider.lower() in KNOWN_PROVIDERS:
return KNOWN_PROVIDERS[provider.lower()]
if model:
model_lower = model.lower()
for prefix, prov in MODEL_PROVIDER_MAP.items():
if model_lower.startswith(prefix):
return KNOWN_PROVIDERS[prov]
return None
class SyncChatRequest(BaseModel):
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
model: Optional[str] = Field(None, max_length=200)
session: Optional[str] = Field(None, max_length=100)
api_key: Optional[str] = Field(None, max_length=256)
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
provider: Optional[str] = Field(None, max_length=50)
@router.post("/v1/chat")
async def sync_chat(request: Request, body: SyncChatRequest):
if not getattr(request.state, "api_token", False):
raise HTTPException(403, "This endpoint requires an API token")
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
token_owner = getattr(request.state, "api_token_owner", None)
from core.models import ChatMessage
from src.llm_core import llm_call_async
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
message = body.message.strip()
if not message:
raise HTTPException(400, "Message is required")
session_id = body.session
sess = None
# --- Case 1: Resume an existing session ---
if session_id and session_manager:
try:
sess = session_manager.get_session(session_id)
except (KeyError, Exception):
raise HTTPException(404, "Session not found")
# SECURITY: verify the API-token's user owns this session — without
# this any token holder could resume any user's chat by passing its
# ID. The token's user is on request.state.user (set by API-token
# middleware); fall back to require_user if not present.
try:
from src.auth_helpers import get_current_user as _gcu
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
except Exception:
_tok_user = None
# Strict ownership (see _caller_owns_session): fail closed so a
# null-owner / cross-owner session can't be resumed by an arbitrary
# chat-scoped token.
_sess_owner = getattr(sess, "owner", None)
if not _caller_owns_session(_sess_owner, _tok_user):
raise HTTPException(404, "Session not found")
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
if not sess and body.api_key:
api_key = body.api_key.strip()
model = body.model or "deepseek-chat"
# Validate only token-supplied direct base_url; auto-resolved known-provider
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
if direct_base_url:
try:
base_url = validate_public_http_url(direct_base_url)
except ValueError as e:
detail = str(e).replace("URL", "base_url", 1)
raise HTTPException(400, detail)
else:
base_url = _resolve_base_url(model, body.provider)
if not base_url:
raise HTTPException(400,
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
"or provider ('deepseek', 'openai', 'groq', etc.)")
base_url = normalize_base(base_url)
endpoint_url = build_chat_url(base_url)
if not session_manager:
raise HTTPException(500, "Session manager not available")
sid = str(uuid.uuid4())
sess = session_manager.create_session(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
# --- Case 3: Fall back to first configured ModelEndpoint ---
if not sess:
db = SessionLocal()
try:
ep = _select_api_chat_fallback_endpoint(db, token_owner)
finally:
db.close()
if not ep:
raise HTTPException(400,
"No session, api_key, or configured endpoints. "
"Pass api_key + model, or configure an endpoint in Admin.")
base_url = normalize_base(ep.base_url)
endpoint_url = build_chat_url(base_url)
model = body.model or "auto"
api_key = ep.api_key
if getattr(ep, "provider_auth_id", None):
try:
from src.endpoint_resolver import resolve_endpoint_runtime
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
endpoint_url = build_chat_url(base_url)
except Exception:
raise HTTPException(500, "Could not resolve endpoint credentials")
if model == "auto":
try:
async with httpx.AsyncClient(timeout=5) as client:
models_url = build_models_url(base_url)
hdrs = build_headers(api_key, base_url)
if models_url:
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
data = resp.json()
items = data if isinstance(data, list) else (data.get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
if not ids and isinstance(data, dict):
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
import json as _json
ids = _json.loads(ep.cached_models or "[]")
model = ids[0] if ids else "auto"
except Exception:
raise HTTPException(500, "Could not discover models from endpoint")
if not session_manager:
raise HTTPException(500, "Session manager not available")
sid = str(uuid.uuid4())
sess = session_manager.create_session(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
if api_key:
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
# --- Send message and get response ---
sess.add_message(ChatMessage("user", message))
messages = [{"role": m.role, "content": m.content} for m in sess.history]
reply = await llm_call_async(
sess.endpoint_url, sess.model, messages,
headers=sess.headers, timeout=120,
)
sess.add_message(ChatMessage("assistant", reply))
session_manager.save_sessions()
webhook_manager.fire_and_forget("chat.completed", {
"session_id": session_id, "model": sess.model,
"user_message": message[:2000], "response": reply[:2000],
})
return {"response": reply, "session_id": session_id, "model": sess.model}
return router
_sys.modules[__name__] = _canonical
+106 -12
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Create/remove the switchable, non-default 'Demo' EmailAccount in Odysseus.
"""Create/remove the switchable 'Demo' EmailAccount in Odysseus.
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
@@ -20,7 +20,14 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT))
from core.database import SessionLocal, EmailAccount, Base, engine # noqa: E402
from core.database import ( # noqa: E402
Base,
EmailAccount,
SessionLocal,
engine,
lock_email_account_owner_mutations,
)
from sqlalchemy import or_ # noqa: E402
from src.secret_storage import encrypt # noqa: E402
NAME = "Demo"
@@ -31,18 +38,98 @@ IMAP_PASSWORD = "demodemo"
OWNER = ""
def setup() -> int:
Base.metadata.create_all(bind=engine)
def _owner_scope(query, owner: str):
if owner:
return query.filter(EmailAccount.owner == owner)
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
def _discover_demo_scopes() -> set[str]:
db = SessionLocal()
try:
acct = db.query(EmailAccount).filter(
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
).first()
return {
row.owner or ""
for row in db.query(EmailAccount).filter(
EmailAccount.name == NAME,
EmailAccount.imap_user == IMAP_USER,
).all()
}
finally:
db.close()
def _lock_and_load_demo_rows(db, scopes: set[str]):
"""Reload Demo rows under every observed owner lock."""
scopes = set(scopes) or {OWNER}
while True:
lock_email_account_owner_mutations(db, *scopes)
rows = (
db.query(EmailAccount)
.filter(
EmailAccount.name == NAME,
EmailAccount.imap_user == IMAP_USER,
)
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
.all()
)
current_scopes = {row.owner or "" for row in rows}
if current_scopes.issubset(scopes) or db.get_bind().dialect.name == "sqlite":
return rows
db.rollback()
scopes.update(current_scopes)
def _promote_oldest_enabled(db, owner: str, excluded_ids: list[str]) -> None:
remaining = _owner_scope(
db.query(EmailAccount).filter(
EmailAccount.enabled == True, # noqa: E712
~EmailAccount.id.in_(excluded_ids),
),
owner,
)
if remaining.filter(EmailAccount.is_default == True).first() is not None: # noqa: E712
return
promote = remaining.order_by(
EmailAccount.created_at.asc(), EmailAccount.id.asc()
).first()
if promote is not None:
promote.is_default = True
def setup() -> int:
Base.metadata.create_all(bind=engine)
scopes = _discover_demo_scopes() | {OWNER}
db = SessionLocal()
try:
rows = _lock_and_load_demo_rows(db, scopes)
acct = rows[0] if rows else None
if acct is None:
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
db.add(acct)
old_scope = acct.owner or ""
was_default = bool(acct.is_default)
if old_scope != OWNER:
# Move a non-default row first so the unique index cannot see two
# defaults transiently while SQLAlchemy flushes the owner move and
# old-scope promotion in separate UPDATE statements.
acct.is_default = False
acct.owner = OWNER
db.flush()
if was_default:
_promote_oldest_enabled(db, old_scope, [acct.id])
target_default = _owner_scope(
db.query(EmailAccount).filter(
EmailAccount.id != acct.id,
EmailAccount.is_default == True, # noqa: E712
),
OWNER,
).first()
acct.owner = OWNER
acct.is_default = False # never default — user switches to it
# Keep Demo non-default when a real default exists. If it is the only
# enabled account, it must be default to preserve normal create
# semantics and avoid leaving the owner partition without one.
acct.is_default = target_default is None
acct.enabled = True
acct.imap_host = "localhost"
acct.imap_port = 31143
@@ -57,20 +144,27 @@ def setup() -> int:
acct.smtp_password = encrypt(IMAP_PASSWORD)
acct.from_address = IMAP_USER
db.commit()
print(f"'{NAME}' account ready (id={acct.id}, non-default, switchable).")
state = "default" if acct.is_default else "non-default"
print(f"'{NAME}' account ready (id={acct.id}, {state}, switchable).")
return 0
finally:
db.close()
def teardown() -> int:
scopes = _discover_demo_scopes()
db = SessionLocal()
try:
rows = db.query(EmailAccount).filter(
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
).all()
rows = _lock_and_load_demo_rows(db, scopes)
deleted_ids = [row.id for row in rows]
default_scopes = {row.owner or "" for row in rows if row.is_default}
for r in rows:
db.delete(r)
# Ensure the old default DELETE reaches the database before a
# replacement UPDATE; the unique index is enforced per statement.
db.flush()
for owner in default_scopes:
_promote_oldest_enabled(db, owner, deleted_ids)
db.commit()
print(f"removed {len(rows)} '{NAME}' account row(s).")
return 0
+2 -1
View File
@@ -2,7 +2,7 @@
"""Memory service — persistent memory storage and retrieval."""
from .service import MemoryService, Memory, MemorySearchResult
from .memory import MemoryManager
from .memory import MemoryManager, MemoryStoreUnreadable
from .memory_vector import MemoryVectorStore
__all__ = [
@@ -10,5 +10,6 @@ __all__ = [
"Memory",
"MemorySearchResult",
"MemoryManager",
"MemoryStoreUnreadable",
"MemoryVectorStore",
]
+12 -2
View File
@@ -5,6 +5,16 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
parallel implementation here risks silent drift between import paths.
"""
from src.memory import MemoryManager, get_text_similarity, tokenize
from src.memory import (
MemoryManager,
MemoryStoreUnreadable,
get_text_similarity,
tokenize,
)
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
__all__ = [
"MemoryManager",
"MemoryStoreUnreadable",
"get_text_similarity",
"tokenize",
]
+21 -2
View File
@@ -17,6 +17,8 @@ import os
import re
from typing import Optional
from src.memory import MemoryStoreUnreadable
logger = logging.getLogger(__name__)
@@ -387,7 +389,13 @@ async def extract_and_store(
# Get owner from session
_owner = getattr(session, 'owner', None)
existing = memory_manager.load_all()
# Strict load: this is a read-modify-write. Degrading to [] here would
# save only the newly extracted facts and drop the entire store.
try:
existing = memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Skipping auto memory extraction, store unreadable: %s", e)
return
added = 0
for fact in facts:
@@ -626,7 +634,18 @@ async def audit_memories(
# Merge audited entries back with other users' entries
if owner:
all_entries = memory_manager.load_all()
# Strict load: the merge below reconstructs the whole file. If this
# degraded to [] we would save only this owner's audited slice and
# destroy every other tenant's memories.
try:
all_entries = memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Aborting memory audit save, store unreadable: %s", e)
return {
"before": before_count,
"after": before_count,
"error": "store_unreadable",
}
audited_ids = {e["id"] for e in final_entries}
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
# Also keep legacy entries that weren't part of this audit
+43 -4
View File
@@ -50,7 +50,7 @@ import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -100,6 +100,18 @@ def _parse_scalar(raw: str) -> Any:
if raw.lower() in ("null", "none", "~"):
return None
if (raw[0] == raw[-1]) and raw[0] in ("'", '"'):
if raw[0] == '"':
# _emit_scalar writes double-quoted scalars with json.dumps, so
# decode the escapes instead of only stripping the quotes. Without
# this, `\"` / `\\` / `\uXXXX` stayed verbatim in the value and the
# next save escaped their backslashes again, doubling them on every
# load/save cycle (issue #5210).
try:
return json.loads(raw)
except ValueError:
# Hand-written file using escapes JSON rejects (e.g. a bare
# Windows path). Keep the previous literal reading.
pass
return raw[1:-1]
# Try number
try:
@@ -171,6 +183,26 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
return fm, body
# Characters that force a quoted scalar. The punctuation would otherwise change
# how the value reads back; the second row is every character str.splitlines()
# treats as a line break, and parse_frontmatter() reads one scalar per line, so
# emitting one of those bare would split the value across lines.
_FM_MUST_QUOTE = (
":", "#", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@",
"\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029",
)
# json.dumps escapes every C0 control character, but with ensure_ascii=False it
# passes NEL / LINE SEPARATOR / PARAGRAPH SEPARATOR through literally, and
# str.splitlines() still breaks on all three. Re-escape exactly those, which
# json.loads decodes again on the way in, so the pair stays symmetric.
_FM_POST_DUMPS_ESCAPES = (
("\x85", "\\u0085"),
("\u2028", "\\u2028"),
("\u2029", "\\u2029"),
)
def _emit_scalar(v: Any) -> str:
if v is None:
return "null"
@@ -181,8 +213,15 @@ def _emit_scalar(v: Any) -> str:
if isinstance(v, list):
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
s = str(v)
if any(c in s for c in (":", "#", "\n", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@")):
return json.dumps(s)
if any(c in s for c in _FM_MUST_QUOTE):
# ensure_ascii=False keeps non-ASCII text as itself. SKILL.md is UTF-8 at
# both ends (skills.py reads it, atomic_write_text writes it), so the
# \uXXXX form bought nothing and leaked into the parsed value (#5210).
out = json.dumps(s, ensure_ascii=False)
for ch, esc in _FM_POST_DUMPS_ESCAPES:
if ch in out:
out = out.replace(ch, esc)
return out
return s
@@ -441,4 +480,4 @@ class Skill:
def _now_iso() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+53
View File
@@ -2,6 +2,7 @@
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
import io
import os
import wave
import logging
import hashlib
@@ -41,6 +42,11 @@ class TTSService:
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._kokoro = None # lazy-init
try:
self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
except ValueError:
self.max_cache_bytes = 500 * 1024 * 1024
# ── Settings ──
@@ -89,6 +95,53 @@ class TTSService:
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
(self.cache_dir / f"{key}{ext}").write_bytes(data)
self._enforce_cache_limit()
def _enforce_cache_limit(self):
"""Evicts oldest files if the cache exceeds the configured byte limit."""
if self.max_cache_bytes <= 0:
return
try:
files = []
total_size = 0
# Safely scan files and sum sizes, ignoring files deleted mid-scan
for f in self.cache_dir.iterdir():
try:
if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
files.append(f)
total_size += f.stat().st_size
except OSError:
continue
if total_size > self.max_cache_bytes:
logger.info(
f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
)
# Sort files by modification time (oldest first)
try:
files.sort(key=lambda f: f.stat().st_mtime)
except OSError as e:
logger.warning(f"Failed to sort cache files by mtime: {e}")
# Trim down to 80% of max capacity
target_size = self.max_cache_bytes * 0.8
while files and total_size > target_size:
f = files.pop(0)
try:
size = f.stat().st_size
f.unlink()
total_size -= size
except OSError as e:
logger.warning(f"Failed to evict cache file {f}: {e}")
continue
except Exception as e:
logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
def clear_cache(self):
count = 0
for f in self.cache_dir.glob("*.*"):
+36 -8
View File
@@ -6,6 +6,7 @@ import sys
import time
import collections
from typing import Optional, Callable, Awaitable, Tuple, Dict
from core.platform_compat import IS_WINDOWS, find_bash
from src.constants import MAX_OUTPUT_CHARS
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
@@ -16,6 +17,27 @@ PROGRESS_TAIL_LINES = 12
TMUX_CAPTURE_LINES = 2000
async def _create_bash_subprocess(command: str, **kwargs):
"""Start the agent shell with Bash semantics on every supported OS.
``asyncio.create_subprocess_shell`` delegates to ``cmd.exe`` on native
Windows. That contradicts the Bash tool contract and makes POSIX commands
such as ``pwd``, ``ls -la``, and ``cat`` unreliable even when the launcher
has found Git Bash. Pass the selected workspace as a structural ``cwd``
argument; Git Bash inherits that native Windows directory and exposes it
using its normal ``/c/...`` representation.
"""
if IS_WINDOWS:
bash = find_bash()
if not bash:
raise RuntimeError(
"Git Bash is required for the Bash tool on Windows; "
"install Git for Windows and restart Odysseus"
)
return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs)
return await asyncio.create_subprocess_shell(command, **kwargs)
def _tmux_session_name(session_id: Optional[str]) -> str:
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
return f"ody-agent-{raw[:80] or 'default'}"
@@ -280,7 +302,10 @@ class BashTool:
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
session_id = ctx.get("session_id")
if session_id and shutil.which("tmux"):
# tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on
# native Windows must not bypass the Git Bash launcher below: the tmux
# setup hard-codes /bin/bash and cannot safely consume a native cwd.
if session_id and not IS_WINDOWS and shutil.which("tmux"):
stdout, stderr, rc, timed_out = await _run_tmux_bash(
content,
session_id=str(session_id),
@@ -307,13 +332,16 @@ class BashTool:
"tmux_session": _tmux_session_name(str(session_id)),
}
proc = await asyncio.create_subprocess_shell(
content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
try:
proc = await _create_bash_subprocess(
content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
except RuntimeError as e:
return {"error": f"bash: {e}", "exit_code": 1}
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
timeout=DEFAULT_BASH_TIMEOUT,
+10 -1
View File
@@ -22,6 +22,7 @@ import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
from src.memory import MemoryStoreUnreadable
logger = logging.getLogger(__name__)
@@ -384,7 +385,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Memory text cannot be empty"}
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
memories = _memory_manager.load_all()
# Strict load: this is a read-modify-write, and it is the path an
# ordinary "remember that I prefer X" takes. Degrading to [] here would
# save just this one entry over a store we only failed to read,
# atomically destroying every memory in it (issue #5673).
try:
memories = _memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Refusing to add memory, store unreadable: %s", e)
return {"error": "Memory store is temporarily unreadable — nothing was saved."}
memories.append(entry)
_memory_manager.save(memories)
+682 -105
View File
@@ -20,6 +20,395 @@ from src.interactive_gate import wait_for_interactive_quiet
logger = logging.getLogger(__name__)
def _read_email_urgency_state(state_path):
"""Read one atomic urgency checkpoint, tolerating the legacy shape."""
from pathlib import Path
state_path = Path(state_path)
try:
state = (
json.loads(state_path.read_text(encoding="utf-8"))
if state_path.exists()
else {}
)
except Exception:
return {}
return state if isinstance(state, dict) else {}
def _email_urgency_account_generations(state):
"""Return normalized per-account checkpoint/complete generations.
Checkpoint generations fence every accepted state mutation. Complete
generations advance only for a non-stale complete scan. Missing metadata
is the legacy generation zero.
"""
raw = state.get("account_generations", {}) if isinstance(state, dict) else {}
if not isinstance(raw, dict):
return {}
generations = {}
for account_id, value in raw.items():
if isinstance(value, dict):
checkpoint = value.get("checkpoint", 0)
complete = value.get("complete", 0)
else:
# Tolerate an intermediate scalar representation as one completed
# checkpoint generation instead of discarding its fence.
checkpoint = value
complete = value
try:
checkpoint = max(0, int(checkpoint))
except (TypeError, ValueError):
checkpoint = 0
try:
complete = max(0, int(complete))
except (TypeError, ValueError):
complete = 0
generations[str(account_id)] = {
"checkpoint": checkpoint,
"complete": complete,
}
return generations
def _email_urgency_string_set(value):
if not isinstance(value, (list, tuple, set, frozenset)):
return set()
return {str(item) for item in value if isinstance(item, (str, int))}
def _acquire_email_urgency_state_lock(
state_path,
lock_db_path,
cancel_event,
timeout_seconds=120,
):
"""Acquire the cross-process urgency lock without blocking the app loop."""
import sqlite3
import time
from pathlib import Path
state_path = Path(state_path)
state_path.parent.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + timeout_seconds
while not cancel_event.is_set():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise sqlite3.OperationalError("timed out waiting for urgency state lock")
conn = sqlite3.connect(
str(lock_db_path),
timeout=min(0.25, max(0.01, remaining)),
check_same_thread=False,
)
try:
conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
conn.close()
if "locked" not in str(exc).lower():
raise
cancel_event.wait(min(0.05, max(0.0, remaining)))
continue
except BaseException:
conn.close()
raise
if cancel_event.is_set():
conn.rollback()
conn.close()
return None, None
return conn, _read_email_urgency_state(state_path)
return None, None
def _close_email_urgency_state_lock(conn):
if conn is None:
return
try:
try:
conn.rollback()
except Exception:
pass
finally:
conn.close()
def _commit_email_urgency_state(conn, state_path, next_state):
"""Atomically publish JSON before releasing the SQLite write lock."""
import uuid
from pathlib import Path
state_path = Path(state_path)
temp_path = state_path.with_name(
f".{state_path.name}.{uuid.uuid4().hex}.tmp"
)
try:
temp_path.write_text(json.dumps(next_state), encoding="utf-8")
temp_path.replace(state_path)
conn.commit()
except BaseException:
conn.rollback()
raise
finally:
temp_path.unlink(missing_ok=True)
conn.close()
async def _run_email_urgency_state_transaction(
state_path,
lock_db_path,
operation,
):
"""Serialize one urgency decision while keeping async work on this loop.
Only lock acquisition waits in a worker thread. ``operation`` is awaited
on the caller's long-lived event loop, where shared async clients, locks,
and the browser-notification queue belong. Cancellation rolls back the
SQLite transaction and never publishes a checkpoint.
"""
import asyncio
import threading
loop = asyncio.get_running_loop()
cancel_event = threading.Event()
acquire_future = loop.run_in_executor(
None,
_acquire_email_urgency_state_lock,
state_path,
lock_db_path,
cancel_event,
)
try:
conn, prior = await asyncio.shield(acquire_future)
except asyncio.CancelledError as cancelled:
cancel_event.set()
# The acquisition worker owns any connection until it returns. Wait
# for its short busy-poll to observe cancellation, then close a lock it
# may have won concurrently with the cancellation request.
while True:
try:
conn, _prior = await asyncio.shield(acquire_future)
break
except asyncio.CancelledError:
continue
except Exception:
conn = None
break
_close_email_urgency_state_lock(conn)
raise cancelled
if conn is None:
raise asyncio.CancelledError
try:
result, next_state = await operation(prior)
# Keep this small atomic publish synchronous. There is no await between
# the successful operation and commit, so cancellation cannot be
# observed and then followed by a checkpoint.
try:
_commit_email_urgency_state(conn, state_path, next_state)
finally:
conn = None
return result
except BaseException:
_close_email_urgency_state_lock(conn)
raise
def _email_urgency_account_key(message_key):
return str(message_key).split(":", 1)[0]
def _email_urgency_payload_account_ids(state):
"""Return account IDs that still own user-visible urgency payload."""
if not isinstance(state, dict):
return set()
per_uid = state.get("per_uid", {})
per_uid_keys = per_uid if isinstance(per_uid, dict) else {}
return {
_email_urgency_account_key(key) for key in per_uid_keys
} | {
_email_urgency_account_key(key)
for key in _email_urgency_string_set(state.get("notified_uids", []))
}
def _email_urgency_known_account_ids(state):
"""Return payload owners plus generation-only active/retired markers."""
return _email_urgency_payload_account_ids(state) | set(
_email_urgency_account_generations(state)
)
def _email_urgency_stale_accounts(
prior,
base_account_generations,
account_ids,
):
prior_generations = _email_urgency_account_generations(prior)
base_generations = _email_urgency_account_generations(
{"account_generations": base_account_generations}
)
return {
str(account_id)
for account_id in account_ids
if prior_generations.get(str(account_id), {}).get("checkpoint", 0)
!= base_generations.get(str(account_id), {}).get("checkpoint", 0)
}
def _merge_email_urgency_state(
prior,
*,
owner,
per_uid_scores,
notified_uids,
all_unread_keys,
fully_scanned_account_ids,
base_account_generations,
timestamp,
retired_account_ids=(),
base_payload_account_ids=(),
known_account_ids=(),
):
"""Merge a scan without letting an older snapshot erase newer facts."""
prior_per_uid = prior.get("per_uid", {})
if not isinstance(prior_per_uid, dict):
prior_per_uid = {}
complete = {str(account_id) for account_id in fully_scanned_account_ids}
prior_generations = _email_urgency_account_generations(prior)
retire_requested = {str(account_id) for account_id in retired_account_ids}
observed_accounts = {
_email_urgency_account_key(key) for key in per_uid_scores
} | complete | retire_requested
stale_accounts = _email_urgency_stale_accounts(
prior,
base_account_generations,
observed_accounts,
)
prior_payload_accounts = _email_urgency_payload_account_ids(prior)
base_payload_accounts = {
str(account_id) for account_id in base_payload_account_ids
}
# A selected account can be absent from the base snapshot. If another
# worker creates its first payload before this transaction wins the lock,
# membership itself is a fence even when both snapshots normalize to the
# legacy generation zero.
retired_accounts = {
account_id
for account_id in retire_requested - stale_accounts
if not (
account_id in prior_payload_accounts
and account_id not in base_payload_accounts
)
}
fresh_complete = complete - stale_accounts - retired_accounts
changed_accounts = set(fresh_complete)
merged_per_uid = {
key: value
for key, value in prior_per_uid.items()
if _email_urgency_account_key(key) not in retired_accounts
}
for key in list(merged_per_uid):
account_id = _email_urgency_account_key(key)
if account_id in fresh_complete:
merged_per_uid.pop(key, None)
changed_accounts.add(account_id)
# Partial scans may add or refresh facts, but absence from a partial scan
# is not evidence that another checkpoint or UI row is stale. When another
# worker committed after this scan captured its base generation, discard
# this account's whole stale snapshot. A key absent from the newer state
# may have been removed/read, so even a stale-only key is not safely
# additive without another fresh scan.
for key, value in per_uid_scores.items():
account_id = _email_urgency_account_key(key)
if account_id in stale_accounts or account_id in retired_accounts:
continue
if merged_per_uid.get(key) != value:
changed_accounts.add(account_id)
merged_per_uid[key] = value
prior_notified = _email_urgency_string_set(prior.get("notified_uids", []))
merged_notified = {
key
for key in prior_notified
if _email_urgency_account_key(key) not in retired_accounts
}
for key in _email_urgency_string_set(notified_uids) - prior_notified:
account_id = _email_urgency_account_key(key)
if account_id in stale_accounts or account_id in retired_accounts:
continue
merged_notified.add(key)
changed_accounts.add(account_id)
for key in list(merged_notified):
if (
_email_urgency_account_key(key) in fresh_complete
and key not in all_unread_keys
):
merged_notified.discard(key)
changed_accounts.add(_email_urgency_account_key(key))
next_generations = {
account_id: dict(value)
for account_id, value in prior_generations.items()
}
for account_id in changed_accounts:
generation = next_generations.setdefault(
account_id,
{"checkpoint": 0, "complete": 0},
)
generation["checkpoint"] += 1
if account_id in fresh_complete:
generation["complete"] += 1
for account_id in {str(value) for value in known_account_ids}:
next_generations.setdefault(
account_id,
{"checkpoint": 0, "complete": 0},
)
for account_id in retired_accounts:
# Every authoritative absence advances its generation, even when the
# prior state is already a payload-empty tombstone. A re-enabled scan
# may have captured that previous tombstone immediately before the
# account was disabled/deleted again; monotonic advancement is what
# makes that in-flight scan stale.
generation = next_generations.setdefault(
account_id,
{"checkpoint": 0, "complete": 0},
)
generation["checkpoint"] += 1
total_unread = 0
total_urgent = 0
max_score = 0
for value in merged_per_uid.values():
if not isinstance(value, dict):
continue
try:
score = max(0, min(3, int(value.get("score", 0))))
except (TypeError, ValueError):
score = 0
max_score = max(max_score, score)
if value.get("unread"):
total_unread += 1
if score >= 2:
total_urgent += 1
return {
"ts": timestamp,
"owner": owner or "",
"total_unread": total_unread,
"total_urgent": total_urgent,
"max_score": max_score,
"per_uid": merged_per_uid,
"notified_uids": sorted(merged_notified),
"account_generations": next_generations,
}
class TaskNoop(BaseException):
"""Raised by an action when it determined there's nothing to do.
@@ -1878,6 +2267,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
# filename for single-user installs (matches prior behaviour).
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
STATE_PATH = _P(DATA_DIR) / f"email_urgency_state_{_owner_slug}.json"
STATE_LOCK_DB = STATE_PATH.with_suffix(".lock.sqlite3")
CACHE_DIR = _P(EMAIL_URGENCY_CACHE_DIR)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -1892,35 +2282,144 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
"shopping", "social", "work", "personal", "legal", "support", "promo",
}
# ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall
# through to default chat as a last resort).
# Resolve with the task owner as before, but defer the availability
# gate until after authoritative account cleanup. State retirement must
# still run when no model is configured.
from src.task_endpoint import resolve_task_candidates
candidates = resolve_task_candidates(owner=owner)
if not candidates:
return "No LLM endpoint available", False
target_account_id = _email_task_account_id(kwargs)
# ── 2. Enumerate enabled accounts. Match this task's owner AND fall
# ── 1. Enumerate enabled accounts. Match this task's owner AND fall
# back to the legacy "unowned account whose imap_user / from_address
# == this owner" pattern — same rule `_get_email_config` uses, so a
# pre-multi-user account row still gets picked up for the seeded task.
db = _SL()
try:
from sqlalchemy import and_ as _and, or_ as _or
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
if owner:
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
if target_account_id:
q = q.filter(_EA.id == target_account_id)
accounts = q.all()
finally:
db.close()
def _enumerate_enabled_accounts():
db = _SL()
try:
from sqlalchemy import and_ as _and, or_ as _or
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
if owner:
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
same_mailbox = _or(
_EA.imap_user == owner,
_EA.from_address == owner,
)
q = q.filter(
_or(_EA.owner == owner, _and(unowned, same_mailbox))
)
if target_account_id:
q = q.filter(_EA.id == target_account_id)
return q.all()
finally:
db.close()
initial_accounts = _enumerate_enabled_accounts()
initial_account_ids = {
str(account.id) for account in initial_accounts
}
# Register every account before IMAP work, including its first-ever
# scan. A concurrent zero-account cleanup can then advance this marker
# and fence delivery even before the scan has produced payload.
registered_state = None
if initial_account_ids:
async def _register_accounts(prior):
next_state = _merge_email_urgency_state(
prior,
owner=owner,
per_uid_scores={},
notified_uids=prior.get("notified_uids", []),
all_unread_keys=set(),
fully_scanned_account_ids=set(),
base_account_generations=(
_email_urgency_account_generations(prior)
),
timestamp=_time.time(),
known_account_ids=initial_account_ids,
)
# Return the exact state committed by registration. This is
# the scan's generation token: adopting a later checkpoint
# after account cleanup would let the stale scan appear fresh.
return next_state, next_state
registered_state = await _run_email_urgency_state_transaction(
STATE_PATH,
STATE_LOCK_DB,
_register_accounts,
)
# Revalidate after registration. If deletion/disable and its cleanup
# completed before the marker was published, this second enumeration
# observes the absence and this action retires its own marker instead
# of starting IMAP. Accounts newly appearing between the two reads are
# left for the next pass rather than scanned without prior registration.
verified_accounts = _enumerate_enabled_accounts()
enabled_account_ids = {
str(account.id) for account in verified_accounts
}
accounts = [
account
for account in verified_accounts
if str(account.id) in initial_account_ids
]
# Capture the checkpoint basis before cleanup or IMAP. A full
# owner-wide enumeration authoritatively retires all known state IDs
# absent from the current enabled/visible set. A scoped task may retire
# only its selected missing/disabled account. Existing accounts remain
# present even if their later network scan fails, so transient IMAP
# failure never erases their last known state.
base_state = (
registered_state
if registered_state is not None
else _read_email_urgency_state(STATE_PATH)
)
base_account_generations = _email_urgency_account_generations(
base_state
)
base_payload_account_ids = _email_urgency_payload_account_ids(base_state)
known_state_account_ids = _email_urgency_known_account_ids(base_state)
if target_account_id:
retired_account_ids = (
{str(target_account_id)}
if str(target_account_id) not in enabled_account_ids
else set()
)
else:
retired_account_ids = (
known_state_account_ids - enabled_account_ids
)
if retired_account_ids:
async def _retire_accounts(prior):
next_state = _merge_email_urgency_state(
prior,
owner=owner,
per_uid_scores={},
notified_uids=prior.get("notified_uids", []),
all_unread_keys=set(),
fully_scanned_account_ids=set(),
base_account_generations=base_account_generations,
timestamp=_time.time(),
retired_account_ids=retired_account_ids,
base_payload_account_ids=base_payload_account_ids,
)
return None, next_state
await _run_email_urgency_state_transaction(
STATE_PATH,
STATE_LOCK_DB,
_retire_accounts,
)
if not accounts:
raise TaskNoop("no email accounts configured")
# ── 2. Account retirement above is state maintenance and does not
# depend on model availability. Scanning still requires the utility
# primary/fallback candidates resolved for this task owner.
if not candidates:
return "No LLM endpoint available", False
urgency_prompt = settings.get("urgent_email_prompt", "")
per_uid_scores = {} # key = "<acc_id>:<uid>" → {"score": 0-3, "reason": "..."}
all_unread_keys = set()
@@ -1929,6 +2428,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
failed_classifications = []
tag_write_details = []
scanned = 0
fully_scanned_account_ids = set()
def _heuristic_email_verdict(item: dict) -> dict:
blob = (
@@ -2024,16 +2524,27 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
def _scan_one(account=acc, cache_uids=cache.get("uids", {})):
"""Sync IMAP work runs in a thread."""
results = []
scan_complete = True
conn = _imap_connect(account.id)
try:
conn.select("INBOX", readonly=True)
select_status, _select_data = conn.select("INBOX", readonly=True)
if select_status != "OK":
return results, False
# Tag recent inbox mail, not only unread mail. Urgency
# reminders below still only notify for unread messages.
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
status, data = conn.uid("SEARCH", None, f'(SINCE {since_str})')
if status != "OK" or not data or not data[0]:
return results
uids = data[0].split()[-30:]
if status != "OK":
return results, False
if not data or not data[0]:
return results, True
matching_uids = data[0].split()
if len(matching_uids) > 30:
# The scale guard deliberately processes only the most
# recent 30. That is a partial account snapshot, so it
# cannot justify pruning older checkpoint facts.
scan_complete = False
uids = matching_uids[-30:]
for uid_b in uids:
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
key = f"{account.id}:{uid}"
@@ -2041,12 +2552,41 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
cached_ok = isinstance(cached, dict) and cached.get("triage_version") == TRIAGE_VERSION
results.append({"key": key, "uid": uid, "cached": cached if cached_ok else None})
if cached_ok:
# Already classified — skip the fetch.
# Cached verdicts still need a lightweight FLAGS
# refresh. Without it a cached unread message looks
# read and its successful notification checkpoint
# is pruned on the next pass.
try:
st, flag_data = conn.uid("FETCH", uid_b, "(UID FLAGS)")
if st != "OK" or not flag_data:
scan_complete = False
results.pop()
continue
flag_parts = []
for part in flag_data:
if isinstance(part, (bytes, bytearray)):
flag_parts.append(bytes(part))
elif (
isinstance(part, tuple)
and part
and isinstance(part[0], (bytes, bytearray))
):
flag_parts.append(bytes(part[0]))
flags_blob = b" ".join(flag_parts)
results[-1]["unread"] = b"\\Seen" not in flags_blob
except Exception as _fe:
scan_complete = False
results.pop()
logger.debug(
f"urgency: flag fetch for uid {uid} failed: {_fe}"
)
continue
# Pull headers + first ~800 chars of plaintext body.
try:
st, msg_data = conn.uid("FETCH", uid_b, "(UID FLAGS RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
if st != "OK" or not msg_data:
scan_complete = False
results.pop()
continue
flags_blob = b" ".join(
part[0] for part in msg_data
@@ -2060,6 +2600,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
if isinstance(part, tuple) and part[1]:
raw += part[1] + b"\n\n"
if not raw:
scan_complete = False
results.pop()
continue
msg = _email_mod.message_from_bytes(raw)
# Skip Odysseus-generated reminders so the scanner
@@ -2115,17 +2657,21 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
"unread": is_unread,
})
except Exception as _fe:
scan_complete = False
results.pop()
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
finally:
try: conn.logout()
except Exception: pass
return results
return results, scan_complete
try:
items = await _aio.to_thread(_scan_one)
items, scan_complete = await _aio.to_thread(_scan_one)
except Exception as e:
logger.warning(f"urgency: IMAP scan failed for account {acc.id}: {e}")
continue
if scan_complete:
fully_scanned_account_ids.add(str(acc.id))
for item in items:
scanned += 1
@@ -2262,13 +2808,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
continue
# ── Prune cache entries for UIDs that are no longer in the recent
# scan window. Read messages remain cached because tags are useful
# on read mail too; unread state is refreshed per scan above.
seen_uids = {it["uid"] for it in items}
cache_uids = cache.get("uids", {})
for stale in [u for u in cache_uids if u not in seen_uids]:
cache_uids.pop(stale, None)
if scan_complete:
# Only a complete account scan proves a cached UID left the
# recent window. Partial/failing scans preserve prior facts.
seen_uids = {it["uid"] for it in items}
cache_uids = cache.get("uids", {})
for stale in [u for u in cache_uids if u not in seen_uids]:
cache_uids.pop(stale, None)
try:
cache_file.write_text(_json.dumps(cache), encoding="utf-8")
@@ -2372,40 +2918,34 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
# ── 4. Aggregate state. urgent = score ≥ 2.
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2 and v.get("unread")]
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
total_urgent = len(urgent_keys)
# Load prior state to know which urgent UIDs we've already notified.
try:
prior = _json.loads(STATE_PATH.read_text(encoding="utf-8")) if STATE_PATH.exists() else {}
except Exception:
prior = {}
notified_uids = set(prior.get("notified_uids", []))
# ── 5. Fire reminder ONLY when a previously-unnotified UID scores urgent.
new_urgent = [k for k in urgent_keys if k not in notified_uids]
# ── 5. Fire a reminder only when a previously-unnotified UID scores
# urgent. The read, decision, delivery, and checkpoint are serialized
# below so two scheduler workers cannot both act on the same stale
# state or overwrite each other's successful checkpoint.
newly_notified = set()
notify_failed = set()
if new_urgent:
title = "Urgent email" if total_urgent == 1 else f"{total_urgent} urgent emails"
# Build a real listing — subject · sender · reason for each urgent
# one — so the reminder email tells you which messages to act on,
# not just "4 needing reply". Optional deep-link when the user has
# `app_public_url` configured in Settings (so the email row links
# straight into the Odysseus Email tab).
# Sort: highest-scored UIDs first; cap at 10 to keep the email tidy.
def _urgency_reminder_payload(reminder_keys):
total = len(reminder_keys)
title = "Urgent email" if total == 1 else f"{total} urgent emails"
sorted_urgent = sorted(
((k, per_uid_scores[k]) for k in urgent_keys),
key=lambda kv: kv[1].get("score", 0), reverse=True,
((key, per_uid_scores[key]) for key in reminder_keys),
key=lambda item: item[1].get("score", 0),
reverse=True,
)[:10]
_pub = (settings.get("app_public_url") or "").strip().rstrip("/")
from urllib.parse import quote as _quote
lines = [f"{total_urgent} email" + ("" if total_urgent == 1 else "s") + " need an urgent reply:", ""]
for i, (k, v) in enumerate(sorted_urgent, 1):
subj = (v.get("subject") or "(no subject)")[:160]
frm = v.get("from") or ""
why = v.get("reason") or ""
uid_for_link = str(k).split(":", 1)[-1]
lines = [
f"{total} email" + ("" if total == 1 else "s")
+ " need an urgent reply:",
"",
]
for i, (key, value) in enumerate(sorted_urgent, 1):
subj = (value.get("subject") or "(no subject)")[:160]
frm = value.get("from") or ""
why = value.get("reason") or ""
uid_for_link = str(key).split(":", 1)[-1]
hash_link = f"#email={_quote('INBOX', safe='')}:{uid_for_link}"
open_link = f"{_pub}/{hash_link}" if _pub else hash_link
line = f"{i}. {subj}"
@@ -2415,57 +2955,94 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
line += f" · {why}"
lines.append(line)
lines.append(f" Open email: {open_link}")
if total_urgent > len(sorted_urgent):
if total > len(sorted_urgent):
lines.append("")
lines.append(f"…and {total_urgent - len(sorted_urgent)} more.")
body = "\n".join(lines)
try:
# Call dispatch_reminder DIRECTLY (no HTTP/auth roundtrip — the
# endpoint version 401's the background scheduler because it
# has no session cookie).
from routes.note_routes import dispatch_reminder
dispatch_result = await dispatch_reminder(
title=title, note_body=body, note_id="urgent-email",
owner=owner or "",
)
channel = (settings.get("reminder_channel") or "browser").strip().lower()
delivered = bool(dispatch_result.get("browser_sent"))
if channel == "email":
delivered = bool(dispatch_result.get("email_sent"))
elif channel == "ntfy":
delivered = bool(dispatch_result.get("ntfy_sent"))
elif channel == "webhook":
delivered = bool(dispatch_result.get("webhook_sent"))
if delivered:
newly_notified.update(new_urgent)
else:
lines.append(f"…and {total - len(sorted_urgent)} more.")
return title, "\n".join(lines)
async def _dispatch_urgency_reminder(reminder_keys):
# Call dispatch_reminder directly: a scheduler has no browser
# session cookie with which to call the HTTP endpoint.
from routes.note_routes import dispatch_reminder
title, body = _urgency_reminder_payload(reminder_keys)
return await dispatch_reminder(
title=title,
note_body=body,
note_id="urgent-email",
owner=owner or "",
)
async def _dispatch_and_checkpoint(prior):
notified_uids = _email_urgency_string_set(
prior.get("notified_uids", [])
)
observed_accounts = {
_email_urgency_account_key(key) for key in per_uid_scores
} | fully_scanned_account_ids
stale_accounts = _email_urgency_stale_accounts(
prior,
base_account_generations,
observed_accounts,
)
# Generation fencing must happen before delivery, not only during
# merge. A stale-only unread UID may have been removed, read, or
# downgraded by the newer completed scan.
deliverable_urgent = [
key
for key in urgent_keys
if _email_urgency_account_key(key) not in stale_accounts
]
new_urgent = [
key
for key in deliverable_urgent
if key not in notified_uids
]
if new_urgent:
try:
dispatch_result = await _dispatch_urgency_reminder(
deliverable_urgent
)
channel = (settings.get("reminder_channel") or "browser").strip().lower()
delivered = bool(dispatch_result.get("browser_sent"))
if channel == "email":
delivered = bool(dispatch_result.get("email_sent"))
elif channel == "ntfy":
delivered = bool(dispatch_result.get("ntfy_sent"))
elif channel == "webhook":
delivered = bool(dispatch_result.get("webhook_sent"))
if delivered:
newly_notified.update(new_urgent)
notified_uids.update(new_urgent)
else:
notify_failed.update(new_urgent)
logger.warning(
"urgency: reminder dispatch returned no successful "
f"delivery path: {dispatch_result}"
)
except Exception as e:
logger.warning(f"urgency: reminder dispatch failed: {e}")
notify_failed.update(new_urgent)
logger.warning(f"urgency: reminder dispatch returned no successful delivery path: {dispatch_result}")
except Exception as e:
logger.warning(f"urgency: reminder dispatch failed: {e}")
notify_failed.update(new_urgent)
# Mark only successfully delivered UIDs as notified so a transient
# SMTP/ntfy/browser failure retries instead of lying forever.
notified_uids.update(newly_notified)
# Prune notified_uids that aren't unread anymore (so a future re-urgent
# message with the same UID — rare but possible after archive→unarchive
# — can re-notify). Keep only UIDs still in `all_unread_keys`.
notified_uids = {u for u in notified_uids if u in all_unread_keys}
next_state = _merge_email_urgency_state(
prior,
owner=owner,
per_uid_scores=per_uid_scores,
notified_uids=notified_uids,
all_unread_keys=all_unread_keys,
fully_scanned_account_ids=fully_scanned_account_ids,
base_account_generations=base_account_generations,
timestamp=_time.time(),
)
return notified_uids, next_state
state = {
"ts": _time.time(),
"owner": owner or "",
"total_unread": len(all_unread_keys),
"total_urgent": total_urgent,
"max_score": max_score,
"per_uid": per_uid_scores,
"notified_uids": sorted(notified_uids),
}
try:
STATE_PATH.write_text(_json.dumps(state), encoding="utf-8")
await _run_email_urgency_state_transaction(
STATE_PATH,
STATE_LOCK_DB,
_dispatch_and_checkpoint,
)
except Exception as e:
logger.warning(f"urgency: state write failed: {e}")
logger.warning(f"urgency: state transaction failed: {e}")
# ── 6. Activity-log summary — counts line on top, then per-tier
# bulleted breakdown so the user can see WHICH emails ranked where
+3 -17
View File
@@ -443,28 +443,14 @@ def resolve_endpoint_by_id(
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
"""Build the configured default-chat fallback chain as a list of
(chat_url, model, headers) tuples, skipping any that can't resolve.
"""Compatibility shim for the retired default-chat fallback chain."""
The primary model is NOT included callers prepend their session's
current (url, model, headers) so per-session model overrides are honored.
"""
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
del owner
return []
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
try:
from src.settings import get_user_setting, load_settings
settings = load_settings()
utility_ep = (get_user_setting("utility_endpoint_id", owner or "", settings.get("utility_endpoint_id", "")) or "").strip()
if not utility_ep:
utility_chain = get_user_setting("utility_model_fallbacks", owner or "", settings.get("utility_model_fallbacks") or []) or []
if utility_chain:
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
except Exception:
pass
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
+31
View File
@@ -0,0 +1,31 @@
"""Foreground Chat and Agent model-routing policy.
The selected session model is strict by default. Historical
``default_model_fallbacks`` values remain stored for compatibility, but this
policy intentionally does not read or migrate them.
"""
from typing import Any, Dict, Optional
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
"""Return fallback candidates for a foreground Chat or Agent request.
Foreground routing is strict, so no alternate endpoint/model is eligible.
``owner`` is accepted to keep this policy boundary owner-aware.
"""
del owner
return []
def build_foreground_model_candidates(
endpoint_url: str,
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
) -> list:
"""Build the ordered candidate list for a foreground request."""
primary = (endpoint_url, model, headers or {})
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
+172 -3
View File
@@ -1,11 +1,14 @@
import ipaddress
import json
import os
import time
import uuid
import logging
import re
from typing import Dict, List, Optional, Any
from urllib.parse import urljoin, urlparse, urlunparse
import httpcore
import httpx
from fastapi import HTTPException
@@ -354,6 +357,152 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
return None
# httpcore raises its own exception hierarchy; map the ones a simple request can
# surface back to their httpx equivalents so the caller's `except httpx.*` blocks
# below behave exactly as they did with the default transport.
_HTTPCORE_TO_HTTPX_EXC = {
httpcore.ConnectError: httpx.ConnectError,
httpcore.ConnectTimeout: httpx.ConnectTimeout,
httpcore.NetworkError: httpx.NetworkError,
httpcore.PoolTimeout: httpx.PoolTimeout,
httpcore.ProtocolError: httpx.ProtocolError,
httpcore.ReadError: httpx.ReadError,
httpcore.ReadTimeout: httpx.ReadTimeout,
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
httpcore.TimeoutException: httpx.TimeoutException,
httpcore.WriteError: httpx.WriteError,
httpcore.WriteTimeout: httpx.WriteTimeout,
}
class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
"""Network backend that connects only to the pre-validated IPs, in order.
Every address here came out of the single SSRF resolution, so moving to the
next one after a connect failure is not re-resolution it's ordinary
multi-address fallback restricted to the set the guard already approved.
httpcore takes TLS SNI and the ``Host`` header from the request URL rather
than the connect host, so pinning the socket destination leaves certificate
validation and vhost routing pointed at the original hostname.
"""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._ips = [str(ip) for ip in ips]
self._real = httpcore.AnyIOBackend()
async def connect_tcp(self, host, port, timeout=None, local_address=None,
socket_options=None):
# One shared connect budget: each attempt gets the time left until the
# original deadline, so N dead addresses can't stretch the connect
# phase to N * timeout.
deadline = None if timeout is None else time.monotonic() + timeout
last_exc: Optional[Exception] = None
for ip in self._ips:
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
return await self._real.connect_tcp(
ip, port, remaining, local_address, socket_options
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
last_exc = exc
if deadline is not None and time.monotonic() >= deadline:
break
raise last_exc
async def connect_unix_socket(self, path, timeout=None, socket_options=None):
return await self._real.connect_unix_socket(path, timeout, socket_options)
async def sleep(self, seconds: float) -> None:
return await self._real.sleep(seconds)
class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
"""httpx transport that pins the TCP connect to the pre-resolved IP(s).
Kept local, mirroring the per-module pinned transports web fetch and
webhook delivery already carry, rather than coupling api_call to the
webhook subsystem. The request URL passes through unchanged, so SNI and the
``Host`` header stay the original hostname; only the socket destination is
pinned, which is what closes the rebinding window.
"""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._pinned_ips = list(ips)
self._pool = httpcore.AsyncConnectionPool(
# Reuse the CA trust the default httpx client would build (certifi
# plus SSL_CERT_FILE / SSL_CERT_DIR when trust_env is set) so
# swapping in this transport doesn't quietly change which chains
# verify. ssl.create_default_context() would use system roots.
ssl_context=httpx.create_ssl_context(),
http1=True,
http2=False,
network_backend=_PinnedAsyncBackend(ips),
)
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
core_req = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
try:
core_resp = await self._pool.handle_async_request(core_req)
content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
await core_resp.aclose()
except Exception as exc:
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
if mapped is not None:
raise mapped(str(exc)) from exc
raise
return httpx.Response(
status_code=core_resp.status,
headers=core_resp.headers,
content=content,
extensions=core_resp.extensions,
)
async def aclose(self) -> None:
await self._pool.aclose()
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
"""Return every entry that parses as an IP address, de-duplicated, order
preserved.
check_outbound_url only reports ok when *all* of these classify as safe, so
the whole list is guard-approved and any of them is a legitimate connect
target. Skipping unparseable entries mirrors how the guard walks the same
resolver output.
De-duplication matters because the resolver is getaddrinfo(host, None) with
no socktype filter, so glibc reports the same address once per socktype
(SOCK_STREAM/SOCK_DGRAM/SOCK_RAW) a single-homed host comes back three
times. Without this, the connect fallback would spend the shared deadline
retrying one dead address instead of moving on to a genuinely different one.
"""
ips: List[ipaddress._BaseAddress] = []
seen = set()
for raw in raw_ips:
if not isinstance(raw, str):
continue
try:
ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
except ValueError:
continue
if ip in seen:
continue
seen.add(ip)
ips.append(ip)
return ips
async def execute_api_call(
integration_id: str,
method: str,
@@ -409,13 +558,31 @@ async def execute_api_call(
# loopback for locked-down deployments. Private stays allowed by default
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
# primary use case.
from src.url_safety import check_outbound_url
from src.url_safety import check_outbound_url, _default_resolver
block_private = os.getenv(
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
).lower() == "true"
ok, reason = check_outbound_url(url, block_private=block_private)
# Resolve the host exactly once and remember the IPs the guard validated so
# the request below can be pinned to them. check_outbound_url only reports
# (ok, reason); a plain httpx client re-resolves the host at connect time,
# which reopens a DNS-rebinding TOCTOU — a base_url host that answers with a
# public IP for the guard and then flips to 169.254.169.254 for the connect
# would reach cloud metadata with the integration's auth headers attached.
resolved_ips: List[str] = []
def _recording_resolver(host: str) -> List[str]:
ips = _default_resolver(host)
resolved_ips[:] = ips
return ips
ok, reason = check_outbound_url(
url, block_private=block_private, resolver=_recording_resolver
)
if not ok:
return {"error": f"URL rejected: {reason}", "exit_code": 1}
pinned_ips = _validated_ips(resolved_ips)
if not pinned_ips:
return {"error": "URL rejected: host did not resolve to a usable address", "exit_code": 1}
method = method.upper()
@@ -455,7 +622,9 @@ async def execute_api_call(
auth = httpx.BasicAuth(parts[0], parts[1])
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(
timeout=30.0, transport=_PinnedAsyncTransport(pinned_ips)
) as client:
response = await client.request(
method,
url,
+95 -19
View File
@@ -1,6 +1,7 @@
# src/llm_core.py
import httpx
import asyncio
import copy
import time
import json
import logging
@@ -644,7 +645,7 @@ def _build_ollama_payload(
if options:
payload["options"] = options
if tools:
payload["tools"] = tools
payload["tools"] = _alias_harmony_tools(tools, model)
return payload
@@ -1055,6 +1056,57 @@ def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
# gpt-oss (harmony) ships BUILT-IN tools named `python` and `browser`, invoked
# with the raw body as the argument (`to=python` + bare source), while custom
# functions use `to=functions.NAME` + JSON. A tool we expose under a built-in's
# name therefore gets called with the built-in convention: the model emits raw
# code, the server tries to parse it as JSON, and the whole request dies
# ("error parsing tool call: raw='import sys, ...'"). In streaming mode Ollama
# does not even report it — it truncates the stream, so the turn looks like an
# empty response. `bash` collides the same way in practice.
#
# Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
# tools named python+bash ............ 2/6 succeeded (4 parse failures)
# python renamed ..................... 5/6
# python and bash renamed ............ 6/6
#
# So rename the colliding tools on the way out and map the names back on the
# way in. Confined to the transport layer: callers keep using the real names.
_HARMONY_TOOL_ALIASES = {
"python": "run_python_code",
"bash": "run_shell_command",
"browser": "web_browser_tool",
}
_HARMONY_TOOL_ALIASES_REVERSE = {v: k for k, v in _HARMONY_TOOL_ALIASES.items()}
def _is_harmony_model(model: str) -> bool:
"""True for gpt-oss / harmony-format models, which have built-in tool names."""
return "gpt-oss" in (model or "").lower()
def _alias_harmony_tools(tools: Optional[List[Dict]], model: str) -> Optional[List[Dict]]:
"""Rename tools that collide with harmony built-ins. Returns a copy."""
if not tools or not _is_harmony_model(model):
return tools
out = []
for t in tools:
fn = t.get("function") or {}
alias = _HARMONY_TOOL_ALIASES.get(fn.get("name"))
if alias:
t = copy.deepcopy(t)
t["function"]["name"] = alias
out.append(t)
return out
def _unalias_harmony_tool_name(name: str, model: str) -> str:
"""Map an aliased tool name in a model response back to the real name."""
if not _is_harmony_model(model):
return name
return _HARMONY_TOOL_ALIASES_REVERSE.get(name, name)
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
if not payload.get("tools"):
return
@@ -1237,15 +1289,27 @@ def _anthropic_rejects_temperature(model: str) -> bool:
return False
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
# minor match, kept) instead of reading the date `20250514` as a giant minor
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
# 20260201`) keep their explicit minor and are still matched.
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
# temperature). Both version components are capped at 1-2 digits and forbid a
# trailing digit, so an 8-digit date can never be read as a version number:
# `claude-opus-4-20250514` (Opus 4.0) parses as major-only rather than reading
# `20250514` as a giant minor, and `claude-3-opus-20240229` (legacy Claude 3
# Opus, date directly after "opus-") fails to match at all rather than reading
# the date as a giant major. Dated 4.7+ snapshots (`claude-opus-4-7-20260201`)
# keep their explicit minor and are still matched.
#
# The minor is optional and a missing minor reads as `.0`, so major-only ids
# like `claude-opus-5` are correctly treated as >= 4.7 (issue #5753). Without
# this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
# only on paths that pass a temperature, e.g. scheduled tasks inheriting
# `stream_agent_loop`'s 0.3 default, which returned empty responses.
match = re.search(
r"(?<![a-z])opus[-_]?(\d{1,2})(?!\d)(?:[-_.](\d{1,2})(?!\d))?", model.lower()
)
if not match:
return False
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
return (major, minor) >= (4, 7)
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
# API accepts "high", "medium", "low", "none" — see
@@ -1255,8 +1319,8 @@ _MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high
# Models that support structured thinking — may output </think> without opening tag
_THINKING_MODEL_PATTERNS = (
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
"m2-reap", "gemma", "stepfun", "step-3", "step3",
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4",
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
"magistral", "mistral-small", "mistral-medium",
)
@@ -1885,11 +1949,10 @@ def _dedupe_candidates(candidates):
"""Filter malformed entries and drop a later repeat of an already-seen
``(url, model)`` route, preserving order (first occurrence wins).
The chain is the primary target followed by the configured fallbacks, so a
fallback that repeats the session's current model — a common misconfiguration,
since callers prepend the live ``(url, model)`` to ``default_model_fallbacks``
would otherwise make the chain re-attempt the very route that just failed:
a wasted round-trip plus a spurious ``fallback`` notice for a switch that did
The chain is the primary target followed by any caller-authorized
fallbacks. A fallback that repeats the session's current model would
otherwise make the chain re-attempt the very route that just failed: a
wasted round-trip plus a spurious ``fallback`` notice for a switch that did
not happen. Headers are not part of the key; the first tuple (with its
headers) is the one kept.
"""
@@ -2097,7 +2160,17 @@ async def llm_call_async(
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
response = msg.get("content") or msg.get("reasoning_content") or ""
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
# (same contract as llm_call / stream_llm; see #5435).
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
_set_cached_response(cache_key, response)
return response
except Exception:
@@ -2214,7 +2287,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = max_tokens
if tools:
payload["tools"] = tools
payload["tools"] = _alias_harmony_tools(tools, model)
elif tool_choice_none:
payload["tool_choice"] = "none"
# Mistral thinking-capable models — send reasoning_effort so Mistral
@@ -2348,7 +2421,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
if fn.get("name"):
_ollama_tool_calls.append({
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
"name": fn.get("name") or "",
"name": _unalias_harmony_tool_name(fn.get("name") or "", model),
"arguments": json.dumps(fn.get("arguments") or {}),
})
if j.get("done"):
@@ -2728,7 +2801,10 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
if tc.get("extra_content"):
_tc_acc[idx]["extra_content"] = tc["extra_content"]
if func.get("name"):
_tc_acc[idx]["name"] = func["name"]
# Map harmony aliases back to real
# tool names before anything
# downstream sees them.
_tc_acc[idx]["name"] = _unalias_harmony_tool_name(func["name"], model)
if "arguments" in func:
# Guard against a null arguments delta: `func` can be
# {"arguments": None} (JSON null), and a raw `+= None`
+80 -10
View File
@@ -10,6 +10,18 @@ from datetime import datetime
logger = logging.getLogger(__name__)
class MemoryStoreUnreadable(RuntimeError):
"""memory.json exists on disk but could not be read or parsed.
"The contents are unknown" is categorically different from "there are no
memories". A read-modify-write caller that conflates the two appends to an
empty view and then persists it, destroying the whole store the writes
are atomic, so the loss is durable. Raised by
:meth:`MemoryManager.load_all_for_update` so those callers fail closed.
"""
def tokenize(text: str) -> List[str]:
"""Simple tokenizer that splits on whitespace and removes punctuation."""
return [word.strip('.,!?";') for word in text.split()]
@@ -110,21 +122,69 @@ class MemoryManager:
with open(self.memory_file, 'w', encoding='utf-8') as f:
json.dump([], f, ensure_ascii=False, indent=2)
def load_all(self) -> List[Dict]:
"""Load all memory entries from JSON file (unfiltered)."""
def _read_entries(self) -> List[Dict]:
"""Parse the store, or raise :class:`MemoryStoreUnreadable`.
Returns ``[]`` only when the file genuinely does not exist. Every other
failure mode raises, so callers can tell "no memories" apart from
"couldn't read the memories".
"""
if not os.path.exists(self.memory_file):
return []
try:
with open(self.memory_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
return self._validate_entries(data)
except (json.JSONDecodeError, PermissionError) as e:
logger.error("Error loading memory.json: %s", e)
return self._migrate_from_legacy()
except OSError as e:
# PermissionError is an OSError (a scanner holding the file, a
# permissions problem, bad media).
raise MemoryStoreUnreadable(
f"cannot read {self.memory_file}: {e}"
) from e
except json.JSONDecodeError as e:
# This is the branch that actually destroyed stores: the file reads
# back fine, so nothing stops the save that follows. A truncated
# memory.json is reachable because core/database.py rewrites it with
# a plain open(..,"w") + json.dump during migration.
#
# Preserved behaviour: a corrupt store still gets one shot at the
# pre-JSON memory.txt migration. Only raise when that finds nothing,
# so we never report "empty" for a store we simply failed to parse.
legacy = self._migrate_from_legacy()
if legacy:
return legacy
raise MemoryStoreUnreadable(
f"{self.memory_file} is not valid JSON: {e}"
) from e
return []
if not isinstance(data, list):
raise MemoryStoreUnreadable(
f"{self.memory_file} is not a JSON array (got {type(data).__name__})"
)
return self._validate_entries(data)
def load_all(self) -> List[Dict]:
"""Load all memory entries from JSON file (unfiltered).
Lenient by design: this feeds display, search, and context-injection
paths, so an unreadable store degrades to an empty list rather than
breaking chat. Never build a value from this that you intend to save
back use :meth:`load_all_for_update` for that.
"""
try:
return self._read_entries()
except MemoryStoreUnreadable as e:
logger.error("Error loading memory.json: %s", e)
return []
def load_all_for_update(self) -> List[Dict]:
"""Load for a read-modify-write cycle.
Propagates :class:`MemoryStoreUnreadable` instead of degrading to ``[]``
so a caller can never append to an empty view and persist it over a
store that was only temporarily unreadable (issue #5673).
"""
return self._read_entries()
def load(self, owner: str = None) -> List[Dict]:
"""Load memory entries, optionally filtered by owner."""
@@ -135,7 +195,12 @@ class MemoryManager:
def claim_ownerless(self, owner: str):
"""Assign all ownerless memory entries to the given owner."""
entries = self.load_all()
try:
entries = self.load_all_for_update()
except MemoryStoreUnreadable as e:
# Skip the sweep rather than rewrite the store from an unknown view.
logger.error("Skipping ownerless claim, memory store unreadable: %s", e)
return
changed = False
claimed = 0
for entry in entries:
@@ -235,7 +300,12 @@ class MemoryManager:
if not ids:
return
id_set = set(ids)
entries = self.load_all()
try:
entries = self.load_all_for_update()
except MemoryStoreUnreadable as e:
# Best-effort counter; never worth rewriting the store blind.
logger.error("Skipping uses bump, memory store unreadable: %s", e)
return
changed = False
for e in entries:
if e.get("id") in id_set:
+9 -2
View File
@@ -157,7 +157,11 @@ class NativeMemoryProvider(MemoryProvider):
if metadata:
entry["metadata"] = dict(metadata)
memories = self.memory_manager.load_all()
# Strict load: read-modify-write. `load_all` degrades an unreadable
# store to [], which would save this single entry over everything
# already stored (issue #5673). The provider API has no error channel,
# so MemoryStoreUnreadable propagates to the caller.
memories = self.memory_manager.load_all_for_update()
memories.append(entry)
self.memory_manager.save(memories)
@@ -223,7 +227,10 @@ class NativeMemoryProvider(MemoryProvider):
]
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
memories = self.memory_manager.load_all()
# Strict load for the same reason: `remaining` is derived from this
# list and saved back, so it must never be built from a store we
# failed to read.
memories = self.memory_manager.load_all_for_update()
remaining = []
deleted_id = None
+6 -7
View File
@@ -138,14 +138,13 @@ DEFAULT_SETTINGS = {
# Email replies use email_writing_style instead because greetings,
# signatures, and mailbox identity rules are medium-specific.
"document_writing_style": "",
# Ordered fallback chain for the default chat model. Each entry is
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
# before producing output (endpoint offline / errors), the chat
# dispatch retries the next entry in order.
# Legacy ordered fallback chain for the default chat model. Values remain
# stored for compatibility and rollback reference, but model routing no
# longer reads this key.
"default_model_fallbacks": [],
# When True, non-admin users inherit global default model/endpoint/fallbacks
# when they have no personal defaults. When False, users only use their
# personal defaults (no global fallback). Default is False.
# When True, non-admin users inherit the global default model/endpoint when
# they have no personal defaults. When False, users only use their personal
# defaults. Default is False.
"share_defaults_with_users": False,
"utility_endpoint_id": "",
"utility_model": "",
+1 -1
View File
@@ -32,7 +32,7 @@ def resolve_task_candidates(
2. Utility endpoint/model
3. Default endpoint/model
4. Utility fallback chain
5. Default fallback chain
5. Retired default-fallback compatibility hook (currently empty)
"""
candidates = []
+2 -1
View File
@@ -233,7 +233,8 @@ async def _call_teacher(teacher_model_spec: str, prompt: str,
owner: Optional[str] = None) -> Optional[str]:
"""Call the configured teacher endpoint with the escalation prompt."""
from src.llm_core import llm_call_async
from src.ai_interaction import _resolve_model, _TEACHER_SYSTEM_PROMPT
from src.ai_interaction import _resolve_model
from src.agent_tools.model_interaction_tools import _TEACHER_SYSTEM_PROMPT
try:
url, model, headers = await asyncio.to_thread(_resolve_model, teacher_model_spec, owner=owner)
except Exception as e:
+69 -4
View File
@@ -187,9 +187,13 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
# At least one pipe is required around `end`. Both pipes used to be optional
# (`\|?end\|?`), which also matched a bare `end` on its own line and deleted it
# from ordinary prose and from Ruby/Lua/shell snippets that close blocks with
# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
_QWEN_BARE_MARKER_RE = re.compile(
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)",
re.IGNORECASE,
)
@@ -925,6 +929,46 @@ def _parse_xml_direct_tool(name, body) -> Optional[ToolBlock]:
return function_call_to_tool_block(mapped, json.dumps(params))
def _looks_like_json_body(body: str) -> bool:
"""True when a <tool_call> wrapper body is JSON, not XML markup."""
return body.lstrip()[:1] in ("{", "[")
def _parse_json_tool_call_body(body: str) -> Optional[ToolBlock]:
"""Parse a Qwen/Hermes text-mode wrapper body: bare JSON inside <tool_call>.
<tool_call>
{"name": "bash", "arguments": {"command": "mkdir -p agent-test"}}
</tool_call>
Strict by design (issue #5187 / tracker #5333): the body must decode to an
object with a string "name", and "arguments" when present must itself
be an object. Anything else returns None rather than being coerced, so a
malformed call is dropped instead of dispatching with mangled arguments.
raw_decode tolerates trailing chatter after the JSON object; the trailing
text is never scanned for tool markup. Conversion goes through
function_call_to_tool_block so aliases and per-tool argument formatting
stay identical to the XML invoke path.
"""
stripped = body.strip()
if not stripped.startswith("{"):
return None
try:
parsed, _end = json.JSONDecoder().raw_decode(stripped)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
name = parsed.get("name")
if not isinstance(name, str) or not name.strip():
return None
if "arguments" in parsed and not isinstance(parsed["arguments"], dict):
return None
args = parsed.get("arguments", {})
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block(name.strip().lower(), json.dumps(args))
def _iter_stepfun_tool_calls(text: str):
"""Yield StepFun native tool-call token bodies without regex backtracking."""
pos = 0
@@ -1326,10 +1370,21 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if blocks:
return blocks
# Try wrapped: <tool_call><invoke ...>...</invoke></tool_call>
# A wrapper body that is JSON (Qwen/Hermes text mode, issue #5187) is
# parsed as JSON or dropped — never scanned by the XML iterators, so
# XML-like text inside JSON argument values stays data instead of
# selecting a different tool.
json_body_seen = False
for _ms, inner_start, inner_end, _me in _iter_delimited(
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
):
body = text[inner_start:inner_end]
if _looks_like_json_body(body):
json_body_seen = True
block = _parse_json_tool_call_body(body)
if block:
blocks.append(block)
continue
for inv_name, inv_body in _iter_xml_invoke(body):
block = _parse_xml_invoke(inv_name, inv_body)
if block:
@@ -1344,6 +1399,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if not blocks:
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
body = m.group(1)
if _looks_like_json_body(body):
# Same fail-closed rule as above for an unclosed wrapper.
json_body_seen = True
block = _parse_json_tool_call_body(body)
if block:
blocks.append(block)
break
for inv_name, inv_body in _iter_xml_invoke(body):
block = _parse_xml_invoke(inv_name, inv_body)
if block:
@@ -1354,8 +1416,11 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
block = _parse_xml_direct_tool(d_name, d_body)
if block:
blocks.append(block)
# Try bare <invoke> without wrapper
if not blocks:
# Try bare <invoke> without wrapper. Skipped when a JSON wrapper body
# was seen but produced no block: this rescan covers the full text,
# wrapper bodies included, and <invoke> markup inside a (possibly
# malformed) JSON payload must stay data rather than dispatch.
if not blocks and not json_body_seen:
for inv_name, inv_body in _iter_xml_invoke(text):
block = _parse_xml_invoke(inv_name, inv_body)
if block:
+3
View File
@@ -196,6 +196,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
try:
if action == "list_calendars":
_ensure_default_calendar(db, owner)
# This read path intentionally persists the lazily-created default;
# event creation commits it in the event's transaction instead.
db.commit()
cals = _calendar_query().all()
result = [{"name": c.name, "href": c.id} for c in cals]
if result:
+4 -2
View File
@@ -46,7 +46,9 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = (args.get("action") or "").lower()
action = (args.get("action") or "").strip().lower()
if not action:
return {"error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)", "exit_code": 1}
from services.memory.skills import SkillsManager
from services.memory.skill_format import Skill, slugify
from src.constants import DATA_DIR
@@ -55,7 +57,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
# Accept legacy `skill_id` as an alias for `name`.
name = (args.get("name") or args.get("skill_id") or "").strip()
if action in ("list", "index", ""):
if action in ("list", "index"):
all_skills = sm.load(owner=owner)
if not all_skills:
return {"results": "No skills yet. Create one with action='add'."}
+105 -38
View File
@@ -35,6 +35,16 @@ import logging
logger = logging.getLogger(__name__)
UploadIndexFileSignature = tuple[
str,
Optional[int],
Optional[int],
Optional[int],
Optional[int],
Optional[int],
]
UploadIndexSignature = tuple[UploadIndexFileSignature, ...]
class UploadCleanupSafetyError(RuntimeError):
"""Raised when cleanup cannot prove that destructive work is safe."""
@@ -242,7 +252,7 @@ class UploadHandler:
# In-memory index cache to avoid O(N) disk I/O on every request
self._index_cache: Optional[Dict[str, Any]] = None
self._index_mtime: float = 0.0
self._index_signature: Optional[UploadIndexSignature] = None
def inside_base_dir(self, path: str) -> bool:
"""Check if path is inside base directory"""
@@ -727,62 +737,119 @@ class UploadHandler:
# Update cache if this is the main index
if path.endswith("uploads.json"):
self._index_cache = data
self._index_signature = self._upload_index_signature(
(path, path + ".bak")
)
@staticmethod
def _upload_index_signature(
paths: tuple[str, ...],
) -> Optional[UploadIndexSignature]:
"""Return file identities strong enough to validate the index cache.
Modification time alone is insufficient: a torn write can change a
file without receiving a strictly newer timestamp on some filesystems.
Size, inode, and nanosecond change times make those mutations visible
while preserving the cache fast path for unchanged files.
"""
signature: list[UploadIndexFileSignature] = []
for candidate in paths:
try:
self._index_mtime = os.path.getmtime(path)
stat_result = os.stat(candidate)
except FileNotFoundError:
signature.append((candidate, None, None, None, None, None))
continue
except OSError:
self._index_mtime = time.time()
return None
signature.append(
(
candidate,
stat_result.st_dev,
stat_result.st_ino,
stat_result.st_size,
stat_result.st_mtime_ns,
stat_result.st_ctime_ns,
)
)
return tuple(signature)
def _load_upload_index(self, *, fail_on_error: bool = False) -> Dict[str, Any]:
"""Load the upload index from disk/cache. Uses mtime-based validation
to avoid redundant parsing on hot paths. When ``fail_on_error`` is
true, a missing, malformed, or unreadable live index raises so
destructive callers cannot mistake corruption for an empty store.
"""Load the upload index from disk/cache. Uses file-identity validation
to avoid redundant parsing on hot paths without missing same-timestamp
mutations. When ``fail_on_error`` is true, a missing, malformed, or
unreadable live index raises so destructive callers cannot mistake
corruption for an empty store.
"""
uploads_db_path = os.path.join(self.upload_dir, "uploads.json")
candidates = (uploads_db_path, uploads_db_path + ".bak")
if fail_on_error:
# A backup is intentionally the previous snapshot. It is useful for
# non-destructive reads, but cannot authorize deletion when the live
# index is missing or corrupt.
if not os.path.exists(uploads_db_path):
raise ValueError("live uploads database is missing")
existing_candidates = [uploads_db_path]
else:
existing_candidates = [path for path in candidates if os.path.exists(path)]
if not existing_candidates:
self._index_cache = {}
self._index_mtime = 0.0
return {}
for _attempt in range(3):
signature = self._upload_index_signature(candidates)
if fail_on_error:
# A backup is intentionally the previous snapshot. It is useful for
# non-destructive reads, but cannot authorize deletion when the live
# index is missing or corrupt.
if not os.path.exists(uploads_db_path):
raise ValueError("live uploads database is missing")
existing_candidates = [uploads_db_path]
else:
existing_candidates = [
path for path in candidates if os.path.exists(path)
]
if not existing_candidates:
self._index_cache = {}
self._index_signature = signature
return {}
# Check cache validity
try:
mtime = max(os.path.getmtime(path) for path in existing_candidates)
# Check cache validity
if (
not fail_on_error
and signature is not None
and self._index_cache is not None
and mtime <= self._index_mtime
and signature == self._index_signature
):
return self._index_cache
except OSError:
mtime = 0.0
# Try the live file first, fall back to the .bak sibling if the
# live file is truncated/corrupted.
for candidate in existing_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
self._index_cache = data
self._index_mtime = mtime
return data
except Exception as e:
logger.warning(f"Failed to read uploads database ({candidate}): {e}")
# Try the live file first, fall back to the .bak sibling if the
# live file is truncated/corrupted. A candidate parsed from an old
# inode is accepted only when the whole index signature stays
# stable through the read; otherwise retry so the cache cannot pair
# stale data with a fresh replacement signature.
index_changed_during_read = False
for candidate in existing_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
data = json.load(f)
verified_signature = self._upload_index_signature(candidates)
if (
signature is not None
and verified_signature is not None
and verified_signature != signature
):
index_changed_during_read = True
break
if isinstance(data, dict):
self._index_cache = data
self._index_signature = verified_signature
return data
except Exception as e:
logger.warning(f"Failed to read uploads database ({candidate}): {e}")
verified_signature = self._upload_index_signature(candidates)
if (
signature is not None
and verified_signature is not None
and verified_signature != signature
):
index_changed_during_read = True
break
continue
if index_changed_during_read:
continue
break
if fail_on_error:
raise ValueError("live uploads database is unreadable")
self._index_cache = {}
self._index_signature = self._upload_index_signature(candidates)
return {}
def get_upload_info(self, upload_id: str) -> Optional[Dict[str, Any]]:
+50 -147
View File
@@ -10,18 +10,25 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js?v=20260722ctxheader4';
import chatModule from './js/chat.js?v=20260801fix1';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import {
revealApplicationShellAfterPaint,
runDeferredRouteOpener,
deferRouteOpener,
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
import galleryModule from './js/gallery.js';
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js';
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
@@ -1217,12 +1224,13 @@ function initializeEventListeners() {
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
};
const _opener = _routeOpen[urlPath];
// Defer the opener — at this point in init, the modules whose handlers
// we trigger (#rail-new-session click handler, the email-section header
// click handler in emailInbox, sessionModule's loaded session list) are
// still being wired up further down in this same function. Stash the
// opener so it runs from sessionModule.loadSessions().finally() below.
if (_opener) window._odysseusRouteOpener = _opener;
// Defer the opener — at this point in init, the modules whose handlers we
// trigger (#rail-new-session click handler, the email-section header click
// handler in emailInbox, sessionModule) are still being wired up further
// down in this same function. startupShell decides when it can run: as soon
// as wiring completes, or — for the routes that read the session list —
// once /api/sessions has settled.
deferRouteOpener(urlPath, _opener);
// Archive browser tool button
const toolLibraryBtn = el('tool-library-btn');
@@ -1689,12 +1697,20 @@ function initializeEventListeners() {
const newMemoryInput = el('new-memory-input');
if (newMemoryInput) {
newMemoryInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
// keydown, not the deprecated keypress: keypress is not guaranteed to
// fire for Enter everywhere, which left the Add Memory form with no
// working submit path (#5828).
newMemoryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
memoryModule.addNewMemory();
}
});
}
const newMemoryAddBtn = el('new-memory-add-btn');
if (newMemoryAddBtn) {
newMemoryAddBtn.addEventListener('click', () => memoryModule.addNewMemory());
}
// Voice recording is handled by the dual-purpose send/mic button (see below)
@@ -2710,46 +2726,6 @@ function initializeEventListeners() {
// ── UI Visibility (Customize UI modal) ──
const UI_VIS_KEY = 'odysseus-ui-visibility';
// Selector map: key → CSS selector(s) for targets
const UI_VIS_MAP = {
'sidebar-brand': '.sidebar-brand-title',
'sidebar-new-chat': '#sidebar-new-chat-btn',
'sidebar-search': '#sidebar-search-btn',
'sessions-section': '#sessions-section',
'email-section': '#email-section',
'tools-section': '#tools-section',
// Per-tool visibility — fine-grained control over which entries show
// inside the Tools section in the sidebar.
'tool-calendar': '#tool-calendar-btn',
'tool-compare': '#tool-compare-btn',
'tool-cookbook': '#tool-cookbook-btn',
'tool-research': '#tool-research-btn',
'tool-gallery': '#tool-gallery-btn',
'tool-library': '#tool-library-btn',
'tool-memory': '#tool-memory-btn',
'tool-notes': '#tool-notes-btn',
'tool-tasks': '#tool-tasks-btn',
'tool-theme': '#tool-theme-btn',
'user-bar': '#user-bar-profile',
'sidebar-settings-btn':'#user-bar-settings',
'chat-meta': '.chat-meta-overlay',
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
'incognito-btn': '.incognito-btn',
'web-toggle-btn': '#web-toggle-btn',
'doc-toggle-btn': '#overflow-doc-btn',
'rag-toggle-btn': '#overflow-rag-btn',
'bash-toggle-btn': '#bash-toggle-btn',
'overflow-plus-btn': '.overflow-wrapper',
'mode-toggle': '.mode-toggle',
'preset-mini-btn': '#overflow-preset-btn',
'attach-btn': '#overflow-attach-btn',
'research-btn': '#overflow-research-btn',
'rail-new-chat': '#rail-new-session',
};
// Keys hidden by default on first run (no localStorage yet)
const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
// Keys that need admin to toggle off (reserved for future use)
const UI_VIS_ADMIN_ONLY = new Set([]);
@@ -2762,14 +2738,14 @@ function initializeEventListeners() {
}
function applyUIVis(state) {
Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
// section-drag-reorder uses a body class instead of inline styles
if (key === 'section-drag-reorder') return;
const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
// resolveVisibility computes selector→visible (pure; ui_visibility.js),
// including the tools-section parent rule that hides every tool rail
// launcher when Tools is off. Apply the result to the DOM here.
for (const [selector, visible] of Object.entries(resolveVisibility(state))) {
document.querySelectorAll(selector).forEach(el => {
el.style.display = visible ? '' : 'none';
});
});
}
// Drag reorder: use body class so dynamically created handles are covered
const dragEnabled = state['section-drag-reorder'] === true;
document.body.classList.toggle('rearrange-mode', dragEnabled);
@@ -3908,85 +3884,10 @@ function startOdysseusApp() {
const messageInput = el('message');
const modelPickerWrap = document.getElementById('model-picker-wrap');
function _readComposerPromptHistory() {
const chatBox = document.getElementById('chat-history');
if (!chatBox) return [];
return Array.from(chatBox.querySelectorAll('.msg-user'))
.reverse()
.map(msg => {
const body = msg.querySelector('.body');
return msg.dataset?.raw || (body ? body.textContent : '') || '';
})
.filter(Boolean);
}
if (messageInput && !messageInput._odysseusPromptRecallCapture) {
messageInput._odysseusPromptRecallCapture = true;
let recallHistory = [];
let recallIndex = -1;
let lastRecalled = '';
const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
messageInput.addEventListener('input', () => {
if (norm(messageInput.value) === norm(lastRecalled)) return;
recallHistory = [];
recallIndex = -1;
lastRecalled = '';
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
}, true);
messageInput.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
if (window._ghostAutocomplete?.isActive?.()) return;
const fresh = _readComposerPromptHistory();
const history = fresh.length ? fresh : recallHistory;
if (!history.length) return;
const current = norm(messageInput.value);
let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
if (current && currentIndex < 0) {
const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
currentIndex = markedIndex;
}
}
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (e.key === 'ArrowDown') {
if (currentIndex < 0) return;
const nextIndex = currentIndex - 1;
if (nextIndex < 0) {
recallHistory = history;
recallIndex = -1;
lastRecalled = '';
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
messageInput.value = '';
try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
return;
}
const recalled = history[nextIndex];
recallHistory = history;
recallIndex = nextIndex;
lastRecalled = recalled;
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
messageInput.value = recalled;
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
return;
}
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) return;
recallHistory = history;
recallIndex = nextIndex;
lastRecalled = recalled;
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
messageInput.value = recalled;
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
}, true);
}
// ArrowUp/ArrowDown prompt recall on #message lives in
// static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
// copy here: two capture-phase listeners on the same textarea meant the one
// without the draft guard won and ate unsent multi-line prompts (#5862).
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
const _micIcon = '<svg 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 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
@@ -4382,6 +4283,10 @@ function startOdysseusApp() {
// Load initial data
presetsModule.loadPresets(uiModule.showError);
// Core wiring is complete for this turn — reveal the shell independently of
// the session-list request.
revealApplicationShellAfterPaint();
if (sessionModule) {
sessionModule.initDependencies({
API_BASE: API_BASE,
@@ -4393,21 +4298,19 @@ function startOdysseusApp() {
scrollHistory: uiModule.scrollHistoryInstant
});
// Load sessions first (critical path) — remove loader when done
sessionModule.loadSessions()
.catch(e => console.warn('loadSessions error:', e))
.finally(() => {
const loader = document.getElementById('app-loader');
if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
// Fire any URL route opener now that sessions + module wiring are
// ready. Deferred from up top of init for exactly this reason.
if (window._odysseusRouteOpener) {
try { window._odysseusRouteOpener(); } catch (_) {}
window._odysseusRouteOpener = null;
}
});
// sessionModule is now wired, so every route opener has the modules it
// drives. The ones that read no session data open here rather than
// queueing behind /api/sessions.
runDeferredRouteOpener();
// The shell is already usable at this point; session hydration is
// sidebar-local and settles on its own schedule.
settleSessionHydration(() => sessionModule.loadSessions());
} else {
console.error('Session module not loaded!');
// Nothing will hydrate. Settle immediately so the sidebar exposes the
// failure; session-dependent routes must remain unopened without data.
settleSessionHydration(null);
}
const runNonCriticalStartup = (fn, delay = 4000) => {
+33 -11
View File
@@ -248,11 +248,20 @@
}, { once: true });
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
the app font and the weight the sidebar and header text render at. They
are declared in style.css, so without a hint they are only discovered
after the stylesheet parses and then queue behind the module graph.
crossorigin is required even though these are same-origin: fonts are
always fetched in CORS mode, and a preload whose mode does not match the
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
<link rel="modulepreload" href="/static/js/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
<link rel="modulepreload" href="/static/js/sessions.js">
<link rel="modulepreload" href="/static/js/markdown.js">
</head>
<body>
@@ -286,7 +295,13 @@
if(!document.getElementById('app-loader')){clearInterval(iv);return}
render();
},150);
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
// startupShell.js hides the loader as soon as the shell is wired; it calls
// back here to stop the wave because this interval is owned by this script.
window.__odysseusLoaderWaveStop=function(){clearInterval(iv)};
// Last-resort fallback for a boot that never reaches app.js at all. Must
// still REMOVE the node: sessions.js reads its presence as "startup in
// progress" and stops clearing the composer while it is around.
setTimeout(function(){var l=document.getElementById('app-loader');if(l){clearInterval(iv);l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
})();
</script>
<!-- Memory Management Modal -->
@@ -365,6 +380,7 @@
<span class="skill-rich-ph"><span class="k">Add a memory</span> &mdash; e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
</div>
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
<button type="button" id="new-memory-add-btn" class="theme-io-btn" title="Save this memory" style="flex:none;height:28px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Add</button>
</div>
</div>
<div class="admin-card">
@@ -812,7 +828,13 @@
<button class="session-bulk-btn" id="session-bulk-cancel" title="Cancel"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
<div id="session-list" role="listbox"></div>
<div id="session-list" role="listbox">
<!-- Sidebar-local bootstrap state. renderSessionList() replaces the
whole list on first hydration, so this row is transient. -->
<div id="session-list-loading" class="list-item session-list-bootstrap" role="option" aria-disabled="true" aria-live="polite" aria-atomic="true">
<span class="grow muted" data-session-list-status>Loading chats…</span>
</div>
</div>
</div>
<!-- Hidden dropdown for session actions -->
<div id="session-actions-dropdown" class="dropdown hidden">
@@ -1005,7 +1027,7 @@
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
el.textContent = 'Pick a model if you want, or just type.';
el.textContent = tips[Math.floor(Math.random() * tips.length)];
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
@@ -1482,7 +1504,7 @@
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
<select id="set-defaultModelSelect" class="settings-select"></select>
</div>
<div class="settings-row" style="align-items:flex-start;">
<div class="settings-row" style="align-items:flex-start;" hidden>
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
@@ -2504,7 +2526,7 @@
<script type="module" src="/static/js/ui.js"></script>
<script type="module" src="/static/js/markdown.js"></script>
<script type="module" src="/static/js/dragSort.js"></script>
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
<script type="module" src="/static/js/sessions.js"></script>
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
<script type="module" src="/static/js/skills.js"></script>
<script type="module" src="/static/js/tourHints.js"></script>
@@ -2522,7 +2544,7 @@
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
@@ -2530,7 +2552,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
<script type="module" src="/static/js/assistant.js"></script>
<script type="module" src="/static/app.js?v=20260723tasksbulkfeedback1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
+1
View File
@@ -61,6 +61,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| **`chatRenderer.js`** | Message DOM construction: `addMessage`, role labels, model route labels, color coding, footers, metrics, code blocks, sources boxes (`web`/`research`/`RAG`), findings box, images, report links, ask-user cards, welcome screen, and transcript utilities. |
| **`streamingRenderer.js`** | Incremental streaming renderer used by `chat.js`. Freezes finalized DOM blocks and only re-renders the growing tail to avoid flicker and O(N²) re-parsing. |
| **`streamingSegmenter.js`** | Splits a token stream into display units (text vs code fences) for `streamingRenderer.js`. |
| **`liveThinkingThrottle.js`** | Trailing-edge coalescer for the live thinking block in `chat.js`: one DOM commit per 100 ms carrying the latest reasoning text, with `flush`/`cancel` for terminal and session-switch paths. |
| **`slashCommands.js`** | Slash-command registry (`/help`, `/setup`, etc.), parsing, and dispatch handlers. Exported functions are consumed by `chat.js` and `slashAutocomplete.js`. |
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
+522 -233
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -478,7 +478,10 @@ const DSML_STRAY_RE = /<\s*\/?\s*[|]+\s*DSML\s*[|]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[|]+\s*DSML\s*[|]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[|]+\s*DSML\s*[|]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
+3 -3
View File
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
return;
}
// ArrowUp owns prompt history in the chat composer. If the current text
// is not already a recalled prompt, start from newest instead of letting
// the browser move the caret inside the textarea.
// ArrowUp walks older prompts. An unmatched draft already returned above,
// so reaching here means the composer is empty or holds a recalled prompt
// the caret-navigation case is never hijacked.
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) {
+49 -10
View File
@@ -149,6 +149,7 @@ let _loading = false;
let _expanded = false;
let _docModule = null;
let _listSpinner = null;
let _openEmailRequestSeq = 0;
let _senderFilter = null; // email address (lowercased) to filter by, or null
let _senderFilterLabel = null; // display label for the active filter chip
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
@@ -187,7 +188,7 @@ export function init(documentModule) {
} catch (_) {}
if (opts.compose) { _composeNew(); return; }
if (opts.email) {
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '', '', opts.mailboxContext || null);
}
},
});
@@ -751,7 +752,21 @@ function _createEmailItem(em) {
return item;
}
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '', mailboxContext = null) {
const openRequestSeq = ++_openEmailRequestSeq;
const folderAtStart = mailboxContext?.messageFolder || _currentFolder;
const accountAtStart = mailboxContext?.accountId ?? (window.__odysseusActiveEmailAccount || '');
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const mailboxContextIsCurrent = typeof mailboxContext?.isCurrent === 'function'
? mailboxContext.isCurrent
: () => (
folderAtStart === _currentFolder &&
accountAtStart === (window.__odysseusActiveEmailAccount || '')
);
const isCurrentOpen = () => (
openRequestSeq === _openEmailRequestSeq &&
mailboxContextIsCurrent()
);
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
// Body pre-fill from the agent's open_email_reply tool call takes the
@@ -780,9 +795,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
let data = preloadedData;
if (!data) {
const fullQS = mode === 'forward' ? '&full=1' : '';
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true${fullQS}`);
data = await res.json();
}
if (!isCurrentOpen()) return;
if (data.error) {
console.error('Failed to read email:', data.error);
return;
@@ -808,7 +824,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
message_id: _fallback(data.message_id, em.message_id),
};
if (wantsAiReply) {
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
const activeReplyAccount = data.account_id || em.account_id || accountAtStart;
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
} else {
@@ -834,7 +850,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
folder: folderAtStart,
account_id: activeReplyAccount,
fast: true,
user_hint: (noteHint || '').trim() || undefined,
@@ -842,6 +858,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
});
const result = await res.json();
if (draftToastTimer) clearTimeout(draftToastTimer);
if (!isCurrentOpen()) return;
if (result.success && result.reply) {
aiSuggestedBody = _cleanAiReplyText(result.reply);
} else {
@@ -855,6 +872,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
} catch (e) {
if (draftToastTimer) clearTimeout(draftToastTimer);
if (!isCurrentOpen()) return;
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
return;
@@ -862,8 +880,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
}
em.is_read = true;
if (itemEl) itemEl.classList.remove('email-unread');
if (!isCurrentOpen()) return;
// Only claim the message is read when the provider accepted the \Seen
// transition. A failed STORE still opens the message; it just stays unread.
const markedSeen = !data.mark_seen_failed;
em.is_read = markedSeen;
if (itemEl) itemEl.classList.toggle('email-unread', !markedSeen);
// Addresses to exclude from Reply All. Prefer the full set of configured
// accounts (so a multi-account user's other mailboxes are excluded too),
@@ -911,7 +933,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
content += `\nX-Source-UID: ${em.uid}`;
content += `\nX-Source-Folder: ${_currentFolder}`;
content += `\nX-Source-Folder: ${folderAtStart}`;
if (data.attachments && data.attachments.length > 0) {
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
content += `\nX-Attachments: ${attStr}`;
@@ -980,21 +1002,27 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
// and block Send on long threads.
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
? _docModule.findEmailDocId(em.uid, _currentFolder)
? _docModule.findEmailDocId(em.uid, folderAtStart)
: null;
if (existingDocId) {
if (!_docModule.isPanelOpen()) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (!isCurrentOpen()) return;
await _docModule.loadDocument(existingDocId);
if (!isCurrentOpen()) return;
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
if (!isCurrentOpen()) return;
}
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
if (!isCurrentOpen()) return;
}
_bringEmailReplyDraftToFrontOnMobile();
} else {
if (!isCurrentOpen()) return;
let activeSid = await _createEmailChat(data, { forceNew: true });
if (!isCurrentOpen()) return;
if (!activeSid) {
console.error('reply: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
@@ -1012,13 +1040,20 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}),
});
let docRes = await createReplyDoc(activeSid);
if (!isCurrentOpen()) return;
if (docRes.status === 404) {
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
if (!isCurrentOpen()) return;
activeSid = await _createEmailChat(data, { forceNew: true });
if (activeSid) docRes = await createReplyDoc(activeSid);
if (!isCurrentOpen()) return;
if (activeSid) {
docRes = await createReplyDoc(activeSid);
if (!isCurrentOpen()) return;
}
}
if (!docRes.ok) {
const errText = await docRes.text();
if (!isCurrentOpen()) return;
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
// uiModule isn't statically imported here — use the dynamic
// import pattern the rest of this file uses. (Previously this
@@ -1028,10 +1063,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
return;
}
const doc = await docRes.json();
if (!isCurrentOpen()) return;
if (doc.id) {
const wasOpen = _docModule.isPanelOpen();
if (!wasOpen) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (!isCurrentOpen()) return;
// Use the doc dict from the POST directly — avoids a 404 race
// when the GET fires before the new row is visible to the read
// connection (or when caching is interfering). loadDocument's
@@ -1040,12 +1077,14 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
_docModule.injectFreshDoc(doc);
} else {
await _docModule.loadDocument(doc.id);
if (!isCurrentOpen()) return;
}
_bringEmailReplyDraftToFrontOnMobile();
}
}
}
} catch (e) {
if (!isCurrentOpen()) return;
console.error('Failed to open email:', e);
// Surface the failure so a silent throw in the reply flow doesn't
// look like "nothing happened". Dynamic import — uiModule isn't a
+423 -157
View File
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import {
_esc, _escLinkify, _extractName, _parseTurnMeta,
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
_sanitizeHtml,
_sanitizeHtml, _renderEmailSummaryError,
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
} from './emailLibrary/utils.js';
@@ -30,6 +30,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
let _libLoadSeq = 0;
let _emailMailboxGeneration = 0;
let _emailCardOpenSeq = 0;
let _emailReadMutationSeq = 0;
const _emailReadMutations = new Map();
let _libFolderSeq = 0;
let _libSearchSeq = 0;
let _libSearchHadResults = false;
@@ -837,14 +841,41 @@ document.addEventListener('keydown', (e) => {
e.stopImmediatePropagation?.();
}, true);
function _syncEmailReadState(uid, isRead = true) {
function _emailReadContextKey(context) {
return [context.accountId, context.folder, context.uid].map(value => String(value || '')).join('\u0000');
}
function _emailReadContextIsCurrent(context) {
if (!context) return true;
return (
String(state._libAccountId || '') === context.accountId &&
String(state._libFolder || 'INBOX') === context.libraryFolder &&
_emailMailboxGeneration === context.mailboxGeneration
);
}
function _emailMatchesReadContext(email, context) {
if (String(email?.uid || '') !== context.uid) return false;
const accountId = String(email?.account_id || context.accountId);
const folder = String(email?.folder || context.folder);
return accountId === context.accountId && folder === context.folder;
}
function _syncEmailReadState(uid, isRead = true, context = null) {
if (uid == null) return;
const uidStr = String(uid);
const read = !!isRead;
const match = (state._libEmails || []).find(x => String(x.uid) === uidStr);
if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return;
const match = (state._libEmails || []).find(x => (
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
));
if (match) match.is_read = read;
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
if (context && (
String(card.dataset.emailAccount || '') !== context.accountId ||
String(card.dataset.emailFolder || '') !== context.folder
)) return;
card.classList.toggle('email-card-unread', !read);
const titleRow = card.querySelector('.email-card-titlerow');
if (read) {
@@ -1762,11 +1793,18 @@ function _rememberedEmailAccountId() {
// results and __scheduled__ are deliberately not cached.
const _libListCache = new Map();
const _LIB_CACHE_MAX = 24;
const _LIB_INITIAL_PAGE_SIZE = 100;
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
let _libPrewarmTimer = null;
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
let _libPrewarmDelayTimer = null;
let _libPrewarmIdleHandle = null;
let _libPrewarmPromise = null;
let _libPrewarmResolve = null;
let _libPrewarmAbortController = null;
let _libPrewarmDetachPriorityListeners = null;
let _libPrewarmGeneration = 0;
let _libLastPrewarmAt = 0;
let _libUnreadPrewarmKey = '';
let _libUnreadPrewarmAt = 0;
@@ -1908,6 +1946,7 @@ function _resetEmailListForFreshLoad({ useCache = true } = {}) {
_exitEmailReaderModeForList();
_resetBulkSelectionForContextChange();
state._libOffset = 0;
_emailMailboxGeneration += 1;
_libLoadSeq += 1;
const ck = _libCacheKey();
const cached = useCache ? _libCacheGet(ck) : null;
@@ -2076,162 +2115,319 @@ function _isChatInteractionBusy() {
}
}
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
const run = () => {
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
if (_isChatInteractionBusy() && retries > 0) {
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
function _canRunEmailPrewarm() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return !_isChatInteractionBusy();
}
function _isEmailPrewarmTemporarilyBlocked() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return _isChatInteractionBusy();
}
function _isEmailPrewarmCurrent(generation, signal) {
return generation === _libPrewarmGeneration
&& !signal?.aborted
&& _canRunEmailPrewarm();
}
function _settleEmailPrewarm(generation, value = false) {
if (generation !== _libPrewarmGeneration) return;
const resolve = _libPrewarmResolve;
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
_libPrewarmPromise = null;
_libPrewarmResolve = null;
_libPrewarmAbortController = null;
_libPrewarmDetachPriorityListeners = null;
detachPriorityListeners?.();
resolve?.(value);
}
function _cancelEmailPrewarm() {
const resolve = _libPrewarmResolve;
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
_libPrewarmGeneration += 1;
if (_libPrewarmDelayTimer !== null) {
clearTimeout(_libPrewarmDelayTimer);
}
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
}
try { _libPrewarmAbortController?.abort(); } catch (_) {}
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
_libPrewarmPromise = null;
_libPrewarmResolve = null;
_libPrewarmAbortController = null;
_libPrewarmDetachPriorityListeners = null;
detachPriorityListeners?.();
resolve?.(false);
}
function _scheduleEmailPrewarm(task, { delay = 0 } = {}) {
if (_libPrewarmPromise) return _libPrewarmPromise;
// Do not disguise a timer as idle work. Browsers without the genuine idle
// callback simply skip this optional optimization and load on demand.
if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false);
const generation = ++_libPrewarmGeneration;
_libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; });
const promise = _libPrewarmPromise;
let attemptPending = false;
let retryRequested = false;
function clearScheduledAttempt() {
if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer);
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
}
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
}
function scheduleIdleRetry(delay = 500) {
if (generation !== _libPrewarmGeneration) return;
retryRequested = true;
if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return;
if (document.visibilityState && document.visibilityState !== 'visible') return;
_libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500));
}
function handlePriorityChange() {
if (generation !== _libPrewarmGeneration) return;
if (_canRunEmailPrewarm()) {
scheduleIdleRetry(50);
return;
}
_loadEmails(options);
const priorityBlocked = _isChatInteractionBusy()
|| (document.visibilityState && document.visibilityState !== 'visible');
if (!priorityBlocked) return;
retryRequested = true;
clearScheduledAttempt();
const controller = _libPrewarmAbortController;
_libPrewarmAbortController = null;
try { controller?.abort(); } catch (_) {}
// A hidden page waits for visibilitychange. Chat priority also retains the
// timer fallback for busy-until windows whose final transition has no event.
if (!document.visibilityState || document.visibilityState === 'visible') {
scheduleIdleRetry();
}
}
window.addEventListener('odysseus:chat-busy-change', handlePriorityChange);
document.addEventListener('visibilitychange', handlePriorityChange);
_libPrewarmDetachPriorityListeners = () => {
window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange);
document.removeEventListener('visibilitychange', handlePriorityChange);
};
setTimeout(run, Math.max(0, Number(delay) || 0));
function requestIdle() {
if (generation !== _libPrewarmGeneration) return;
_libPrewarmDelayTimer = null;
try {
_libPrewarmIdleHandle = window.requestIdleCallback((deadline) => {
if (generation !== _libPrewarmGeneration) return;
_libPrewarmIdleHandle = null;
const hasIdleBudget = Boolean(
deadline
&& !deadline.didTimeout
&& typeof deadline.timeRemaining === 'function'
&& deadline.timeRemaining() > 0
);
if (!_canRunEmailPrewarm()) {
if (_isEmailPrewarmTemporarilyBlocked()) {
scheduleIdleRetry();
} else {
_settleEmailPrewarm(generation, false);
}
return;
}
if (!hasIdleBudget) {
scheduleIdleRetry();
return;
}
if (generation !== _libPrewarmGeneration) {
_settleEmailPrewarm(generation, false);
return;
}
const controller = new AbortController();
_libPrewarmAbortController = controller;
attemptPending = true;
retryRequested = false;
Promise.resolve()
.then(() => task({ signal: controller.signal, generation }))
.then(value => {
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
_settleEmailPrewarm(generation, Boolean(value));
})
.catch(() => {
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
_settleEmailPrewarm(generation, false);
})
.finally(() => {
attemptPending = false;
if (generation !== _libPrewarmGeneration) return;
if (retryRequested) scheduleIdleRetry();
});
});
} catch (_) {
_settleEmailPrewarm(generation, false);
}
}
const wait = Math.max(0, Number(delay) || 0);
if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait);
else requestIdle();
return promise;
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
if (_libPrewarmPromise) return _libPrewarmPromise;
const elapsed = Date.now() - _libLastPrewarmAt;
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
_libPrewarmTimer = setTimeout(() => {
_libPrewarmTimer = null;
_libPrewarmPromise = _prewarmEmailViews()
.catch(() => {})
.finally(() => { _libPrewarmPromise = null; });
}, Math.max(0, Number(delay) || 0));
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
}
async function _ensureEmailAccountsForPrewarm() {
function _chooseEmailPrewarmAccountId(accounts) {
const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : [];
const remembered = _rememberedEmailAccountId();
const current = String(state._libAccountId || '').trim();
const chosen = enabled.find(a => String(a.id || '') === remembered)
|| enabled.find(a => String(a.id || '') === current)
|| enabled.find(a => a.is_default)
|| enabled[0]
|| null;
return String(chosen?.id || '').trim();
}
async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
if (!state._libAccountId) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
}
return;
}
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!accountsRes.ok) return;
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
if (!state._libAccountId && state._libAccounts.length) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
}
} catch (err) {
if (err?.name === 'AbortError') return null;
}
} catch (_) {}
}
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (!accountId) return null;
if (accountId && state._libAccountId !== accountId) {
state._libAccountId = accountId;
_publishActiveAccount();
}
return accountId;
}
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
if (state._libOpen) return;
await _ensureEmailAccountsForPrewarm();
if (state._libOpen) return;
const accountId = state._libAccountId || '';
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
return _scheduleEmailPrewarm(
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
{ delay: 0 }
);
}
async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const n = Math.max(1, Math.min(20, Number(limit) || 8));
const key = `${accountId}|${maxUid || 0}|${n}`;
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
try {
const folder = 'INBOX';
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), { credentials: 'same-origin' });
if (state._libOpen) return;
if (!res.ok) return;
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return;
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
const sync = data.sync || {};
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
emails: data.emails,
total: data.total || data.emails.length,
sync,
});
} catch (_) {}
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
return true;
} catch (_) {
return false;
}
}
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function _prewarmEmailViews() {
if (state._libOpen) return;
_libLastPrewarmAt = Date.now();
async function _prewarmEmailViews({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
_setEmailSyncStatus({ warming: true });
const folder = 'INBOX';
const filter = 'all';
// The accounts request is cheap and warms the account strip for first open.
// Then folder/list requests warm both the client cache and the backend
// IMAP/read caches. Failure stays silent: no configured mail should not nag.
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) {
_libLastPrewarmAt = Date.now();
return true;
}
} catch (_) {}
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
const preferred = state._libAccountId
|| (accounts.find(a => a.is_default)?.id)
|| (accounts[0]?.id)
|| '';
if (!state._libAccountId && preferred) {
state._libAccountId = preferred;
_publishActiveAccount();
}
const orderedAccountIds = [
preferred,
...accounts.map(a => a.id).filter(id => id && id !== preferred),
].filter((id, idx, arr) => arr.indexOf(id) === idx);
if (!orderedAccountIds.length) orderedAccountIds.push('');
try {
for (const accountId of orderedAccountIds.slice(0, 4)) {
if (state._libOpen) return;
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) continue;
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: 100,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json().catch(() => null);
if (data && !data.error) {
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails || [],
total: data.total || 0,
sync,
});
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
}
}
await _sleep(900);
}
// One optional first-page request only. Folder metadata, unread state, and
// other accounts remain demand-driven so startup cannot fan out into IMAP.
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: _LIB_INITIAL_PAGE_SIZE,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails)) return false;
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails,
total: data.total || 0,
sync,
});
_libLastPrewarmAt = Date.now();
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
return true;
} catch (_) {
return false;
} finally {
_setEmailSyncStatus({ warming: false });
}
@@ -2286,16 +2482,34 @@ function _publishActiveAccount() {
export function initEmailLibrary(config) {
state._docModule = config.documentModule;
state._onEmailClick = config.onEmailClick;
const onEmailClick = config.onEmailClick;
state._onEmailClick = typeof onEmailClick === 'function' ? (options = {}) => {
const accountId = String(state._libAccountId || '');
const libraryFolder = String(state._libFolder || 'INBOX');
const messageFolder = String(options.email?.folder || libraryFolder);
const mailboxGeneration = _emailMailboxGeneration;
const mailboxContext = Object.freeze({
accountId,
libraryFolder,
messageFolder,
mailboxGeneration,
isCurrent: () => (
String(state._libAccountId || '') === accountId &&
String(state._libFolder || 'INBOX') === libraryFolder &&
_emailMailboxGeneration === mailboxGeneration
),
});
return onEmailClick({ ...options, mailboxContext });
} : null;
}
export function isOpen() { return state._libOpen; }
export function openEmailLibrary(opts = {}) {
if (_libPrewarmTimer) {
clearTimeout(_libPrewarmTimer);
_libPrewarmTimer = null;
}
// Foreground email always wins: cancel a delayed/idle callback and abort the
// one optional request if it has already started. Generation checks make a
// non-abortable response harmless if it races this transition.
_cancelEmailPrewarm();
// Force-clean any stale state from previous attempts
const existing = document.getElementById('email-lib-modal');
if (existing) existing.remove();
@@ -2303,6 +2517,7 @@ export function openEmailLibrary(opts = {}) {
document.removeEventListener('keydown', state._libEscHandler, true);
state._libEscHandler = null;
}
_emailMailboxGeneration += 1;
state._libOpen = true;
// On mobile the sidebar overlays content — close it so the email view isn't
// opened behind it (same pattern as session-switch/delete).
@@ -2926,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
}
const fastAccountAtOpen = state._libAccountId || '';
if (fastAccountAtOpen) {
_loadEmailsWhenChatIdle({ delay: 0 });
_loadEmails({ useCache: true });
}
// If we already know the previous/default account, paint that inbox first
// from the durable index and validate accounts in parallel. Cold refreshes
@@ -2936,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
_loadFolders();
_loadEmailReminderBellVisibility();
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
_loadEmailsWhenChatIdle();
_loadEmails({ useCache: true });
}
})();
}
@@ -3121,6 +3336,7 @@ export async function openEmailLibrarySettings() {
}
export function closeEmailLibrary() {
_cancelEmailPrewarm();
const modal = document.getElementById('email-lib-modal');
if (modal) modal.remove();
if (_libSyncTicker) {
@@ -4554,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
signal: ctrl.signal,
});
const fastData = await fastRes.json().catch(() => null);
@@ -4581,7 +4797,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
// opens omit it so rapid close/reopen returns instantly; the
// Refresh button passes `force: true` to add it back.
const buster = force ? `&_=${Date.now()}` : '';
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
const data = await res.json();
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
if (data.error) throw new Error(data.error);
@@ -4836,6 +5052,8 @@ function _createCard(em) {
else if (!em.is_read) cls += ' email-card-unread';
card.className = cls;
card.dataset.uid = String(em.uid);
card.dataset.emailAccount = String(em.account_id || state._libAccountId || '');
card.dataset.emailFolder = String(em.folder || state._libFolder || 'INBOX');
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
// Checkbox in select mode
@@ -5162,6 +5380,25 @@ async function _toggleCardPreview(card, em) {
// currently-selected folder for normal inbox cards.
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
const wasReadAtStart = !!em?.is_read;
const openGeneration = ++_emailCardOpenSeq;
const readContext = Object.freeze({
accountId: String(accountAtStart),
libraryFolder: String(libraryFolderAtStart),
folder: String(folderAtStart),
uid: uidAtStart,
mailboxGeneration: _emailMailboxGeneration,
});
const readContextKey = _emailReadContextKey(readContext);
const isCurrentOpen = () => (
openGeneration === _emailCardOpenSeq &&
_emailReadContextIsCurrent(readContext) &&
accountAtStart === (state._libAccountId || '') &&
libraryFolderAtStart === (state._libFolder || 'INBOX') &&
uidAtStart === String(card?.dataset?.uid || '') &&
card.isConnected &&
card.classList.contains('email-card-expanded')
);
const grid = card.closest('.doclib-grid');
const gridRect = grid?.getBoundingClientRect?.();
const modal = document.getElementById('email-lib-modal');
@@ -5186,6 +5423,30 @@ async function _toggleCardPreview(card, em) {
return;
}
// Every authoritative open supersedes any older optimistic mutation for the
// same immutable mailbox identity. Carry the original unread state forward
// so a close/reopen followed by failure still rolls back exactly once, while
// a late failure from the superseded request cannot undo a newer success.
const previousMutation = _emailReadMutations.get(readContextKey);
const readMutation = {
generation: ++_emailReadMutationSeq,
rollbackUnread: !wasReadAtStart || !!previousMutation?.rollbackUnread,
};
_emailReadMutations.set(readContextKey, readMutation);
const restoreUnreadState = () => {
if (_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation) return;
_emailReadMutations.delete(readContextKey);
if (readMutation.rollbackUnread) _syncEmailReadState(uidAtStart, false, readContext);
};
const commitReadState = () => {
// A successful STORE/mark_seen is authoritative for this immutable
// mailbox identity even when a newer open is still pending. Retire that
// newer rollback token too, otherwise its later failure could restore an
// unread state that no longer exists at the provider.
_emailReadMutations.delete(readContextKey);
_syncEmailReadState(uidAtStart, true, readContext);
};
// Collapse any other expanded card
if (grid) {
grid.querySelectorAll('.email-card-expanded').forEach(c => {
@@ -5207,10 +5468,10 @@ async function _toggleCardPreview(card, em) {
requestAnimationFrame(() => {
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
});
if (!em.is_read) {
_syncEmailReadState(em.uid, true);
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
.catch(err => console.error('Failed to mark email read:', err));
if (!wasReadAtStart) {
// Keep the current optimistic visual update, but let the read request below
// own the provider-side \Seen transition. A failure restores unread state.
_syncEmailReadState(uidAtStart, true, readContext);
}
// Class hook on the modal so the header-hide / padding rules work on
// browsers without :has() support (Firefox mobile) — the :has() versions
@@ -5239,25 +5500,28 @@ async function _toggleCardPreview(card, em) {
} catch (_) {}
};
let authoritativeReadSucceeded = false;
try {
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uidAtStart)}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (
accountAtStart !== (state._libAccountId || '') ||
libraryFolderAtStart !== (state._libFolder || 'INBOX') ||
uidAtStart !== String(card?.dataset?.uid || '') ||
!card.isConnected ||
!card.classList.contains('email-card-expanded')
) {
return;
}
if (data.error) {
showFailedReader(`Failed to load email: ${data.error}`);
restoreUnreadState();
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
return;
}
// Mark as read locally
_syncEmailReadState(em.uid, true);
if (data.mark_seen_failed) {
// The body is authoritative even when the provider refused the \Seen
// transition. Render the message and roll the unread marker back so the
// list keeps telling the truth, rather than refusing to open a message
// we successfully read.
restoreUnreadState();
} else {
authoritativeReadSucceeded = true;
commitReadState();
}
if (!isCurrentOpen()) return;
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
// Build the attachments wrap using the shared helper so the signature-
@@ -5439,7 +5703,10 @@ async function _toggleCardPreview(card, em) {
// Always stop bubbling so the card's click doesn't fire while reading.
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
} catch (e) {
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
if (!authoritativeReadSucceeded) restoreUnreadState();
if (isCurrentOpen()) {
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
}
}
}
@@ -7259,12 +7526,11 @@ async function _generateSummary(reader, data, btn) {
if (label) label.textContent = 'Summary';
}
} else {
content.innerHTML = `<span style="color:var(--red)">${_esc(result.error || 'Failed to summarize')}</span>`;
panel.remove();
_renderEmailSummaryError(content, result);
}
} catch (e) {
sp.destroy();
panel.remove();
_renderEmailSummaryError(content, null);
if (uiModule) uiModule.showError?.('Failed to summarize');
} finally {
if (btn) btn.disabled = false;
+19
View File
@@ -30,6 +30,25 @@ export function _esc(text) {
return div.innerHTML;
}
const _EMAIL_SUMMARY_ERROR_MESSAGES = Object.freeze({
email_summary_missing_body: 'No email body to summarize',
email_summary_not_configured: 'No model configured for email summaries',
email_summary_empty: 'The model returned an empty summary',
email_summary_unavailable: 'Failed to summarize',
});
export function _emailSummaryErrorMessage(result) {
const code = String(result?.error_code || '');
return _EMAIL_SUMMARY_ERROR_MESSAGES[code] || 'Failed to summarize';
}
export function _renderEmailSummaryError(container, result) {
const message = container.ownerDocument.createElement('span');
message.style.color = 'var(--red)';
message.textContent = _emailSummaryErrorMessage(result);
container.replaceChildren(message);
}
function _attrEsc(text) {
return String(text ?? '')
.replace(/"/g, '&quot;')
+206
View File
@@ -0,0 +1,206 @@
// liveThinkingThrottle.js
//
// Pure trailing-edge coalescer for the live "thinking" block in chat.js.
//
// A reasoning stream delivers deltas far faster than a human can read them, and
// the only thing that matters on screen is the LATEST cumulative text. Committing
// every delta to the DOM makes the work grow with the length of the stream. This
// throttle collapses a burst of updates into one commit per `delay` ms, always
// carrying the most recent value.
//
// Timers are injected so the behaviour is testable without a browser or a clock:
//
// const throttle = createLiveThinkingThrottle(commit, { prepare, schedule, cancel });
//
// Lifecycle contract, which the terminal paths in chat.js depend on:
//
// update(value) queue `value`; schedule a commit if one is not already pending
// flush() commit any pending value NOW and drop the timer; returns whether
// a commit happened, so a clean flush cannot duplicate a commit
// cancel() drop the timer AND the pending value — nothing lands later
//
// `cancel()` is what stops a finished (or backgrounded) stream from mutating a
// view the user has since navigated away to.
export function stripLiveThinkingTags(text) {
return String(text ?? '').replace(
/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi,
'',
);
}
const THINKING_BOUNDARY_RE = /<\/?(?:(?:mm:)?think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>(?:thought|response)|<channel\|>/gi;
const REPLY_PREFIX_SOURCE = "(?:Hey|Hi |Hi!|Hello|Sure|Yes|No |No,|Yo|OK|Here|Absolutely|Of course|Great|Alright|Thanks|Welcome|Good |I'm happy|I'd be)";
const REPLY_LINE_RE = new RegExp('(?:^|\\n)\\s*' + REPLY_PREFIX_SOURCE, 'gi');
const REPLY_INLINE_RE = new RegExp('[.!?]\\s*' + REPLY_PREFIX_SOURCE, 'gi');
const REASONING_PREFIX_CANDIDATES = [
'thinking:', 'thinking process:', 'the user ', 'user wants', 'we need ',
'i need ', 'i should ', 'i will ', "i'll ", 'i am going ', 'let me think',
'let me look', 'let me see', 'let me check', 'let me read', 'let me review',
'let me analyze', 'let me parse', 'let me figure', 'let me draft', 'let me write',
'they are ', 'the question ', 'i can ',
];
const DISPLAY_FILTER_BOUNDARY_RE = /\[\/?TOOL_CALL\]|```(?:create_document|documen(?:t)?)(?:\s|$)|```[\w-]+[ \t]*[\[{]|<(?:[\w]+:)?(?:tool_call|function_call)>|<invoke\b|<\s*\/?\s*[|]+\s*DSML\s*[|]+|(?:\[\s*)?\{\s*"function"\s*:|<\/?\|(?:assistant|assistan|user|system|tool|end)\|?>|(?:^|[\r\n])\s*(?:stdout|stderr|exit_code):/i;
function hasFreshMatch(text, regex, cursor, minStart = 0) {
regex.lastIndex = 0;
for (const match of text.matchAll(regex)) {
const end = match.index + match[0].length;
if (end > cursor && match.index >= minStart) return true;
}
return false;
}
// Incrementally decides when chat.js needs its compatibility-heavy cumulative
// thinking analysis. The gate inspects only a short overlap plus the new text;
// ordinary answer/reasoning deltas therefore stay O(delta) while split tags,
// namespaced tags, non-tag reply boundaries, and false-close grace deadlines
// still request the canonical full analysis.
export function createThinkingAnalysisGate({
startsWithReasoningPrefix = () => false,
now = () => Date.now(),
overlap = 512,
} = {}) {
let cursor = 0;
let prefixSettled = false;
let prefixProbe = '';
return {
shouldAnalyze(text, {
isThinking = false,
nonTagThinking = false,
recheckAt = 0,
} = {}) {
const fullText = String(text ?? '');
if (fullText.length < cursor) {
cursor = 0;
prefixSettled = false;
prefixProbe = '';
}
const previousCursor = cursor;
if (!prefixSettled && prefixProbe.length < overlap) {
// Build the initial probe from deltas so arbitrary leading whitespace
// cannot strand the gate in its undecided state. The retained state is
// bounded even if a provider emits a very large whitespace prefix.
prefixProbe = (prefixProbe + fullText.slice(previousCursor))
.trimStart()
.slice(0, overlap);
}
const scanStart = Math.max(0, previousCursor - overlap);
const freshText = fullText.slice(scanStart);
const relativeCursor = previousCursor - scanStart;
const hasBoundary = hasFreshMatch(freshText, THINKING_BOUNDARY_RE, relativeCursor);
const hasReplyBoundary = nonTagThinking && (
hasFreshMatch(freshText, REPLY_LINE_RE, relativeCursor)
|| hasFreshMatch(freshText, REPLY_INLINE_RE, relativeCursor, Math.max(0, 20 - scanStart))
);
cursor = fullText.length;
if (hasBoundary || hasReplyBoundary) return true;
if (isThinking) return recheckAt > 0 && now() >= recheckAt;
if (prefixSettled) return false;
if (!prefixProbe) return false;
if (startsWithReasoningPrefix(prefixProbe)) {
prefixSettled = true;
return true;
}
const lowerProbe = prefixProbe.toLowerCase();
if (REASONING_PREFIX_CANDIDATES.some((candidate) => candidate.startsWith(lowerProbe))) {
return false;
}
prefixSettled = true;
return false;
},
reset() {
cursor = 0;
prefixSettled = false;
prefixProbe = '';
},
};
}
// Keep the common prose path append-only. At the first structured/tool
// boundary, filter only the preceding visible prefix and hide the structured
// tail until the authoritative terminal render.
export function createIncrementalDisplayProjector(filter, { overlap = 512 } = {}) {
let projected = '';
let boundaryTail = '';
let rawLength = 0;
let structuredTailHidden = false;
return {
append(delta, fullText) {
const chunk = String(delta ?? '');
const raw = String(fullText ?? '');
if (raw.length < rawLength) this.reset();
const boundaryProbe = boundaryTail + chunk;
const boundaryMatch = !structuredTailHidden
? DISPLAY_FILTER_BOUNDARY_RE.exec(boundaryProbe)
: null;
if (boundaryMatch) {
// Filter the visible prefix, not the incomplete marker itself: several
// compatibility regexes intentionally match only completed blocks.
const boundaryStart = Math.max(0, raw.length - boundaryProbe.length + boundaryMatch.index);
structuredTailHidden = true;
projected = String(filter(raw.slice(0, boundaryStart)) ?? '');
} else if (!structuredTailHidden) {
projected += chunk;
}
boundaryTail = (boundaryTail + chunk).slice(-overlap);
rawLength = raw.length;
return projected;
},
current() {
return projected;
},
reset() {
projected = '';
boundaryTail = '';
rawLength = 0;
structuredTailHidden = false;
},
};
}
export function createLiveThinkingThrottle(commit, {
delay = 100,
prepare = (value) => String(value ?? ''),
schedule = (callback, ms) => setTimeout(callback, ms),
cancel = (timer) => clearTimeout(timer),
} = {}) {
let timer = null;
let latest = null;
let dirty = false;
const commitLatest = () => {
timer = null;
if (!dirty) return false;
dirty = false;
commit(prepare(latest));
return true;
};
return {
update(value) {
latest = value;
dirty = true;
if (timer === null) timer = schedule(commitLatest, delay);
},
flush() {
if (timer !== null) {
cancel(timer);
timer = null;
}
return commitLatest();
},
cancel() {
if (timer !== null) cancel(timer);
timer = null;
dirty = false;
},
};
}
export default createLiveThinkingThrottle;
+13 -7
View File
@@ -758,30 +758,36 @@ export function mdToHtml(src, opts) {
// Remove empty paragraphs
s = s.replace(/<p><\/p>/g, '');
// Every restore below passes a function replacer rather than the block string
// itself. With a string replacement, `String.replace` reads `$&`, `` $` ``,
// `$'` and `$$` in the *replacement* as substitution patterns, so a restored
// block containing them is corrupted: `$&` re-inserts the placeholder, `` $` ``
// and `$'` splice in the surrounding document, and `$$` collapses to `$`. Those
// sequences are ordinary content in fenced code (`perl -pe 's/x/$& y/'`,
// `echo "$$USD"`). A function replacer inserts its return value verbatim.
// CRITICAL: Restore allowed HTML blocks first
allowedHtmlBlocks.forEach((block, index) => {
s = s.replace(`___ALLOWED_HTML_${index}___`, block);
s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
});
// Restore math blocks
mathBlocks.forEach((block, index) => {
s = s.replace(`___MATH_BLOCK_${index}___`, block);
s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
});
// Restore mermaid diagram blocks
mermaidBlocks.forEach((block, index) => {
s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
s = s.replace(`___MERMAID_BLOCK_${index}___`, () => block);
});
// CRITICAL: Restore code blocks at the end
codeBlocks.forEach((block, index) => {
s = s.replace(`___CODE_BLOCK_${index}___`, block);
s = s.replace(`___CODE_BLOCK_${index}___`, () => block);
});
// Restore inline code spans last, so placeholders carried inside restored
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
// as a regex back-reference.
// <a>/allowed-HTML blocks are resolved too.
inlineCodeBlocks.forEach((block, index) => {
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
});
+25 -1
View File
@@ -1683,8 +1683,21 @@ export async function loadSessions() {
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
}
const res = await fetch(url);
if (!res.ok) {
let detail = '';
try {
const payload = await res.json();
detail = payload?.detail || payload?.error || '';
} catch (_) {}
const error = new Error(detail || `Session request failed (HTTP ${res.status})`);
error.status = res.status;
throw error;
}
fetched = await res.json();
}
if (!Array.isArray(fetched)) {
throw new Error('Session request returned an invalid response');
}
sessions = _normalizeSessionsList(fetched);
renderSessionList();
@@ -1807,9 +1820,15 @@ export async function loadSessions() {
_autoCreateInProgress = false;
}
}
return true;
} catch (error) {
console.error('Error in loadSessions:', error);
uiModule.showError('Failed to load sessions: ' + error.message);
// app.js's global fetch wrapper owns expired-auth navigation. Avoid
// flashing a redundant session error while that 401 redirect is pending.
if (error?.status !== 401) {
uiModule.showError('Failed to load sessions: ' + error.message);
}
return false;
}
}
@@ -1847,6 +1866,10 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
if (!_isTransientChat) {
Storage.set('lastSessionId', id);
// Update URL hash without triggering hashchange handler
if (window.location.hash !== '#' + id) {
history.replaceState(null, '', '#' + id);
}
}
// Restore character preset for persistent chats
try {
@@ -2313,6 +2336,7 @@ export async function materializePendingSession() {
currentSessionId = payload.id;
if (!isIncognito) {
Storage.set('lastSessionId', payload.id);
history.replaceState(null, '', '#' + payload.id);
}
// Reload the sidebar in the background. Awaiting this used to block the first
+27 -31
View File
@@ -448,7 +448,7 @@ async function initDefaultChat() {
var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback');
var _endpoints = [];
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
function enabledEndpoints() {
return _endpoints.filter(function(e) { return e.is_enabled; });
@@ -534,11 +534,6 @@ async function initDefaultChat() {
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
_fallbacks = Array.isArray(settings.default_model_fallbacks)
? settings.default_model_fallbacks.map(function(f) {
return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
})
: [];
renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
@@ -547,13 +542,11 @@ async function initDefaultChat() {
async function saveDefault() {
try {
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_endpoint_id: epSel.value,
default_model: modelSel.value,
default_model_fallbacks: clean
default_model: modelSel.value
})
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
@@ -3031,12 +3024,14 @@ async function initEmailAccountsSettings() {
const body = {
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
from_address: el('eaf-from').value.trim(),
display_name: el('eaf-display-name').value.trim(),
imap_host: el('eaf-imap-host').value.trim(),
imap_port: parseInt(el('eaf-imap-port').value) || 993,
imap_user: el('eaf-imap-user').value.trim(),
imap_starttls: el('eaf-imap-starttls').checked,
smtp_host: el('eaf-smtp-host').value.trim(),
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
smtp_security: el('eaf-smtp-security').value,
smtp_user: el('eaf-imap-user').value.trim(),
};
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
@@ -5788,29 +5783,30 @@ export function close() {
window.history.replaceState(null, '', clean);
const success = sp.has('email_oauth_success');
const errMsg = sp.get('email_oauth_error') || '';
// Open settings → integrations after the app has initialised.
function _tryOpen() {
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
window.settingsModule.open('integrations');
// Brief toast-style banner.
const banner = document.createElement('div');
banner.textContent = success
? 'Google account connected — email is ready'
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
Object.assign(banner.style, {
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
});
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 4000);
} else {
setTimeout(_tryOpen, 100);
}
// Open settings → integrations once the document is ready. This module owns
// the open() API, so it does not need to wait for a window-level alias.
function _showResult() {
open('integrations');
// Brief toast-style banner.
const banner = document.createElement('div');
banner.textContent = success
? 'Google account connected — email is ready'
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
Object.assign(banner.style, {
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
});
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 4000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
} else {
_showResult();
}
_tryOpen();
})();
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
+3 -5
View File
@@ -83,11 +83,9 @@ export async function loadSkills(cascade = false) {
// Play the domino-in entrance on this load (set when the tab is opened,
// not for the silent re-loads after an edit/delete).
if (cascade) _cascadeNext = true;
if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
_cascadeNext = false;
updateCount();
return;
}
// Always re-fetch when the tab is explicitly opened — the cascade
// animation is handled inside renderSkillsList() via _cascadeNext.
// Skipping the fetch here caused stale data on panel close/reopen (#5870).
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
try {
+86 -13
View File
@@ -4,6 +4,13 @@
* ASCII Spinner Module for AI thinking/processing status
*/
// How long a canvas spinner may keep animating before its element has ever
// been inserted into the document. start() runs synchronously, before the
// caller appends the element, so frame 1 is always disconnected. Callers do
// append in the same task, so anything past this window means the element is
// never coming and the frames are drawing for nobody.
const UNATTACHED_GRACE_MS = 2000;
class Spinner {
constructor(message = "AI is processing", style = "right", animation = "spinner") {
// Different animation frames
@@ -21,6 +28,9 @@ class Spinner {
this.intervalId = null;
this.rafId = null;
this.element = null;
this._wpWasConnected = false;
this._wpUnattachedSince = null;
this._visHandler = null;
}
/**
@@ -74,6 +84,7 @@ class Spinner {
}
_drawSineWave() {
if (!this.isRunning) return;
const ctx = this._ctx;
const W = this._canvas.width;
const H = this._canvas.height;
@@ -120,9 +131,7 @@ class Spinner {
ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
ctx.fill();
if (this.isRunning) {
this.rafId = requestAnimationFrame(() => this._drawSineWave());
}
if (this.isRunning) this._requestFrame();
}
_createWhirlpoolElement() {
@@ -158,6 +167,7 @@ class Spinner {
}
_drawWhirlpool() {
if (!this.isRunning) return;
const ctx = this._wpCtx;
const W = this._wpCanvas.width;
const H = this._wpCanvas.height;
@@ -229,18 +239,77 @@ class Spinner {
ctx.fill();
ctx.globalAlpha = 1;
if (!this.isRunning) return;
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
// got removed (e.g. a loading row replaced by results). But keep spinning
// before it's first appended — start() runs synchronously, before the
// caller inserts the element, so it isn't connected on frame 1.
// Leak-safe self-terminate. "Nobody can see this spinner" has two shapes
// and we have to catch both:
// 1. the element WAS in the DOM and then got removed (a loading row
// replaced by results);
// 2. the element was NEVER inserted, and the grace window for inserting
// it has expired. The caller started a spinner and then took an early
// return (aborted request, panel that resolved from cache), so no
// frame we draw will ever be observed.
// Case 2 is why this needs a deadline at all: while the element has never
// been connected, `!this._wpWasConnected` stays true forever, so without
// the grace check the loop re-arms until the tab closes.
const connected = !!(this.element && this.element.isConnected);
if (connected) this._wpWasConnected = true;
if (connected || !this._wpWasConnected) {
this.rafId = requestAnimationFrame(() => this._drawWhirlpool());
} else {
this.isRunning = false;
if (connected) {
this._wpWasConnected = true;
this._wpUnattachedSince = null;
} else if (!this._wpWasConnected) {
if (this._wpUnattachedSince === null) this._wpUnattachedSince = performance.now();
if (performance.now() - this._wpUnattachedSince > UNATTACHED_GRACE_MS) {
this.stop();
return;
}
}
if (connected || !this._wpWasConnected) {
this._requestFrame();
} else {
this.stop();
}
}
/**
* Arm the next animation frame. Clearing rafId as the callback enters keeps
* it a truthful "a frame is pending" flag, which is what stop() and the
* visibility handler cancel against.
*/
_requestFrame() {
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
if (this.animation === 'sinewave') this._drawSineWave();
else this._drawWhirlpool();
});
}
/**
* Stop drawing while the tab is hidden. Browsers throttle background rAF but
* do not reliably stop the canvas work, and a spinner nobody is looking at
* should cost nothing. The listener is owned by start()/stop() so it is never
* left behind on a dead spinner.
*/
_armVisibilityPause() {
if (this._visHandler) return;
this._visHandler = () => {
if (document.hidden) {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
} else if (this.isRunning && !this.rafId) {
// Reset the wave clock so the hidden interval doesn't arrive as one
// huge dt and skip the animation forward.
this._wavePrev = performance.now();
this._requestFrame();
}
};
document.addEventListener('visibilitychange', this._visHandler);
}
_disarmVisibilityPause() {
if (!this._visHandler) return;
document.removeEventListener('visibilitychange', this._visHandler);
this._visHandler = null;
}
/**
@@ -272,12 +341,15 @@ class Spinner {
if (this.animation === 'sinewave') {
this._wavePrev = performance.now();
this._armVisibilityPause();
this._drawSineWave();
return;
}
if (this.animation === 'whirlpool') {
this._wpStartedAt = performance.now();
this._wpUnattachedSince = null;
this._armVisibilityPause();
this._drawWhirlpool();
return;
}
@@ -302,6 +374,7 @@ class Spinner {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this._disarmVisibilityPause();
}
/**
+153
View File
@@ -0,0 +1,153 @@
// Odysseus UI — startup shell sequencing
// ES6 module — no application dependencies, DOM only.
//
// Revealing the application shell, retiring the boot loader, settling the
// sidebar's own loading state, and firing a deferred URL route are separate
// startup concerns that used to sit inline in app.js behind a single promise.
// They live here so each step has one owner and so the whole contract can be
// exercised directly (tests/test_startup_shell_js.py) without booting the app.
const LOADER_ID = 'app-loader';
const SESSION_BOOTSTRAP_ROW_ID = 'session-list-loading';
// Route openers that read the hydrated session list. Everything else only
// needs module wiring and must not wait on /api/sessions. `/email` spawns a
// fresh chat, and that path falls back to the most recent session's model
// (_createDirectChatFromPreferredModel in app.js) when there is no default
// chat configured, so it genuinely needs the list.
const ROUTES_NEEDING_SESSIONS = new Set(['/email']);
let _routeOpener = null;
let _routeOpenerNeedsSessions = false;
function _loader() {
return document.getElementById(LOADER_ID);
}
/** Run `fn` after the next paint has committed (two animation frames). */
export function afterNextPaint(fn) {
requestAnimationFrame(() => requestAnimationFrame(fn));
}
// The loader node stays in the DOM while sessions hydrate — sidebar-layout.js
// and sessions.js both read its presence as a "still starting up" sentinel —
// but it must stop covering, announcing, and animating over a usable shell.
function _makeLoaderInert(loader) {
if (!loader || loader.dataset.shellRevealed === 'true') return;
loader.dataset.shellRevealed = 'true';
loader.setAttribute('aria-hidden', 'true');
loader.style.pointerEvents = 'none';
loader.style.opacity = '0';
// index.html's inline bootstrap animates the wave on a 150ms interval.
// Nothing of it is visible any more, so stop rendering into it.
try { window.__odysseusLoaderWaveStop?.(); } catch (_) {}
}
/**
* Hand the shell to the user once core wiring is done. Deferred by one paint
* so the first frame lands with the app already laid out.
*/
export function revealApplicationShellAfterPaint() {
const loader = _loader();
if (!loader || loader.dataset.shellRevealScheduled === 'true') return;
loader.dataset.shellRevealScheduled = 'true';
afterNextPaint(() => _makeLoaderInert(_loader()));
}
/** Retire the loader node for good. Safe to call after a reveal. */
export function removeApplicationLoader() {
const loader = _loader();
if (!loader) return;
_makeLoaderInert(loader);
setTimeout(() => loader.remove(), 300);
}
/**
* Turn the sidebar's bootstrap row into a failure row. The write is delayed
* until the session renderer's frame has committed so a late success cannot
* leave stale failure text behind.
*/
export function markSessionListUnavailableIfStillBootstrapping() {
afterNextPaint(() => {
const row = document.getElementById(SESSION_BOOTSTRAP_ROW_ID);
if (!row) return;
const status = row.querySelector('[data-session-list-status]') || row;
status.textContent = 'Chats unavailable';
});
}
/** True when `path`'s route opener reads the hydrated session list. */
export function routeNeedsSessionData(path) {
return ROUTES_NEEDING_SESSIONS.has(path);
}
/**
* Stash a URL route opener for later. At the point app.js resolves the route,
* the modules its handlers drive (the rail new-chat handler, the email
* section header handler, sessionModule) are still being wired further down
* the same init pass, so the opener cannot run inline.
*/
export function deferRouteOpener(path, opener) {
if (!opener) return;
_routeOpener = opener;
_routeOpenerNeedsSessions = routeNeedsSessionData(path);
}
/**
* Fire the deferred route opener if its data is ready. Called once when
* wiring completes and again after authoritative session hydration; a route
* that needs no session data takes the first call, one that does takes the
* second.
*
* @returns {boolean} whether an opener ran.
*/
export function runDeferredRouteOpener({ sessionsSettled = false } = {}) {
if (!_routeOpener) return false;
if (_routeOpenerNeedsSessions && !sessionsSettled) return false;
const opener = _routeOpener;
_routeOpener = null;
_routeOpenerNeedsSessions = false;
try { opener(); } catch (e) { console.warn('route opener failed:', e); }
return true;
}
/**
* Drive session hydration and everything that hangs off it settling: the
* sidebar's failure row, the loader node, and any session-dependent route.
*
* @param {(() => Promise<boolean>)|null} loadSessions resolves true only
* after the session list was authoritatively loaded and applied. Null means
* the session module failed to load.
*/
export function settleSessionHydration(loadSessions) {
const settle = (succeeded) => {
if (!succeeded) {
markSessionListUnavailableIfStillBootstrapping();
// A later unrelated caller must not be able to release a stale startup
// opener against unknown session state.
_routeOpener = null;
_routeOpenerNeedsSessions = false;
}
removeApplicationLoader();
if (succeeded) runDeferredRouteOpener({ sessionsSettled: true });
return succeeded;
};
if (!loadSessions) {
return Promise.resolve(settle(false));
}
// Kick the request off synchronously — a microtask hop here would delay the
// fetch this whole change exists to get off the critical path.
let pending;
try {
pending = loadSessions();
} catch (e) {
console.warn('loadSessions error:', e);
return Promise.resolve(settle(false));
}
return Promise.resolve(pending)
.then(result => settle(result === true))
.catch(e => {
console.warn('loadSessions error:', e);
return settle(false);
});
}
+73
View File
@@ -0,0 +1,73 @@
// static/js/ui_visibility.js
//
// Per-item visibility for the sidebar and collapsed icon rail. Drives the
// Settings → Appearance ("Customize UI") checkboxes, persisted in localStorage
// under `odysseus-ui-visibility` (loaded/saved by app.js).
//
// Each key maps to the CSS selector(s) it controls. Tool/section selectors pair
// the full-sidebar element with its #rail-* launcher so a tab hidden in the
// full view also hides when the sidebar is minimized to the icon rail (id
// mapping mirrors _railToolMap in app.js; #tool-library-btn ↔ #rail-archive).
// Selector map: UI customization key → CSS selector(s) for its target(s).
export const UI_VIS_MAP = {
'sidebar-brand': '.sidebar-brand-title',
'sidebar-new-chat': '#sidebar-new-chat-btn',
'sidebar-search': '#sidebar-search-btn',
'sessions-section': '#sessions-section',
'email-section': '#email-section, #rail-email',
'tools-section': '#tools-section',
// Per-tool entries pair the sidebar button with its rail launcher.
'tool-calendar': '#tool-calendar-btn, #rail-calendar',
'tool-compare': '#tool-compare-btn, #rail-compare',
'tool-cookbook': '#tool-cookbook-btn, #rail-cookbook',
'tool-research': '#tool-research-btn, #rail-research',
'tool-gallery': '#tool-gallery-btn, #rail-gallery',
'tool-library': '#tool-library-btn, #rail-archive',
'tool-memory': '#tool-memory-btn, #rail-memory',
'tool-notes': '#tool-notes-btn, #rail-notes',
'tool-tasks': '#tool-tasks-btn, #rail-tasks',
'tool-theme': '#tool-theme-btn, #rail-theme',
'user-bar': '#user-bar-profile',
'sidebar-settings-btn':'#user-bar-settings',
'chat-meta': '.chat-meta-overlay',
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
'incognito-btn': '.incognito-btn',
'web-toggle-btn': '#web-toggle-btn',
'doc-toggle-btn': '#overflow-doc-btn',
'rag-toggle-btn': '#overflow-rag-btn',
'bash-toggle-btn': '#bash-toggle-btn',
'overflow-plus-btn': '.overflow-wrapper',
'mode-toggle': '.mode-toggle',
'preset-mini-btn': '#overflow-preset-btn',
'attach-btn': '#overflow-attach-btn',
'research-btn': '#overflow-research-btn',
'rail-new-chat': '#rail-new-session',
};
// Keys hidden by default on first run (no localStorage yet).
export const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
/**
* Resolve every UI_VIS_MAP selector to visible (true) or hidden (false) for the
* given saved state. Pure: no DOM, no localStorage app.js applies the result.
*
* A key is visible when its stored value is not `false`, defaulting to on
* unless it is in UI_VIS_DEFAULT_OFF. Per-tool entries also require the Tools
* section to be on: hiding Tools hides every tool, mirroring the full sidebar
* where the #tools-section container already hides them (the rail has no
* container, so this rule keeps it in sync).
*
* @param {Record<string, boolean>} state
* @returns {Record<string, boolean>} selector visible
*/
export const resolveVisibility = (state = {}) => {
const toolsOn = state['tools-section'] !== false;
const out = {};
for (const [key, selector] of Object.entries(UI_VIS_MAP)) {
let visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
if (!toolsOn && key.startsWith('tool-')) visible = false;
out[selector] = visible;
}
return out;
};
+6
View File
@@ -38015,6 +38015,12 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
outline-offset: 2px;
border-radius: 5px;
}
/* Bootstrap row shown while the session list hydrates, and on load failure.
Reads as a normal list row but is not selectable. */
.session-list-bootstrap {
cursor: default;
pointer-events: none;
}
#email-lib-grid .date-section-header {
padding: 10px 5px 3px;
}
+277
View File
@@ -0,0 +1,277 @@
// Tests for the live-thinking throttle that bounds DOM work during long
// reasoning streams (see static/js/liveThinkingThrottle.js).
//
// The throttle's contract is what the terminal paths in chat.js lean on:
// a burst of deltas becomes ONE commit carrying the latest text; flush()
// lands trailing text synchronously and cannot double-commit; cancel()
// guarantees nothing lands after a stream is finished or backgrounded.
//
// Timers are injected, so this runs with no DOM and no real clock.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createIncrementalDisplayProjector,
createLiveThinkingThrottle,
createThinkingAnalysisGate,
stripLiveThinkingTags,
} from '../static/js/liveThinkingThrottle.js';
function fakeTimers() {
let nextId = 1;
const callbacks = new Map();
const delays = [];
return {
schedule(callback, delay) {
const id = nextId++;
callbacks.set(id, callback);
delays.push(delay);
return id;
},
cancel(id) {
callbacks.delete(id);
},
run(id) {
const callback = callbacks.get(id);
assert.ok(callback, `missing timer ${id}`);
callbacks.delete(id);
callback();
},
pendingIds() {
return [...callbacks.keys()];
},
delays,
};
}
test('coalesces a burst and commits only the latest text after 100 ms', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('a');
throttle.update('ab');
throttle.update('abc');
assert.deepEqual(commits, []);
assert.deepEqual(timers.delays, [100], 'a burst must schedule exactly one commit');
const [timer] = timers.pendingIds();
timers.run(timer);
assert.deepEqual(commits, ['abc']);
});
test('commit count stays flat as the stream grows', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
// 500 deltas arriving inside one window is the regression this guards:
// the old code committed once per delta, so work grew with stream length.
let text = '';
for (let i = 0; i < 500; i++) {
text += 'token ';
throttle.update(text);
}
assert.deepEqual(commits, []);
assert.equal(timers.pendingIds().length, 1);
timers.run(timers.pendingIds()[0]);
assert.equal(commits.length, 1);
assert.equal(commits[0], text);
});
test('prepares a 200K cumulative stream only at scheduled commit cadence', () => {
const timers = fakeTimers();
const commits = [];
let prepareCalls = 0;
let scannedCharacters = 0;
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
...timers,
prepare(value) {
prepareCalls += 1;
scannedCharacters += value.length;
return stripLiveThinkingTags(value);
},
});
const delta = 'reasoning '.repeat(10); // 100 characters
let cumulative = '';
for (let i = 0; i < 2000; i++) {
cumulative += delta;
throttle.update(cumulative);
}
assert.equal(cumulative.length, 200_000);
assert.equal(prepareCalls, 0, 'cumulative extraction must not run per delta');
assert.equal(timers.pendingIds().length, 1);
timers.run(timers.pendingIds()[0]);
assert.equal(prepareCalls, 1);
assert.equal(scannedCharacters, 200_000);
assert.deepEqual(commits, [cumulative]);
});
test('ordinary answers and reasoning deltas do not request cumulative analysis', () => {
const startsReasoning = (text) => /^\s*thinking(?:\s+process)?\s*:/i.test(text);
const ordinaryGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let ordinary = '';
let ordinaryAnalyses = 0;
for (let i = 0; i < 2000; i++) {
ordinary += i === 0 ? 'Here is the answer. ' : 'answer '.repeat(10);
if (ordinaryGate.shouldAnalyze(ordinary)) ordinaryAnalyses += 1;
}
assert.equal(ordinaryAnalyses, 0);
const thinkingGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let thinking = 'Thin';
assert.equal(thinkingGate.shouldAnalyze(thinking), false);
thinking += 'king: inspect the problem';
assert.equal(thinkingGate.shouldAnalyze(thinking), true);
for (let i = 0; i < 2000; i++) {
thinking += ' reasoning'.repeat(10);
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), false);
}
thinking += '\n\nHere is the answer';
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), true);
const whitespaceGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let whitespaceThinking = ' '.repeat(250);
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), false);
whitespaceThinking += 'Thinking: bounded probe';
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), true);
});
test('split namespaced closes and false-close deadlines request analysis', () => {
let clock = 100;
const gate = createThinkingAnalysisGate({ now: () => clock });
let text = '<mm:think>x</mm:';
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'fresh opening tag is analyzed');
text += 'think>answer';
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'split namespaced close is analyzed');
text += ' still waiting';
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), false);
clock = 500;
text += ' next delta';
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), true);
const attributedGate = createThinkingAnalysisGate();
let attributed = `<think data-provider="${'x'.repeat(400)}"`;
assert.equal(attributedGate.shouldAnalyze(attributed), false);
attributed += '>reasoning';
assert.equal(attributedGate.shouldAnalyze(attributed), true, 'bounded carry preserves split tag attributes');
});
test('display projection is append-only and filters a structured tail once', () => {
let filterCalls = 0;
let filteredCharacters = 0;
const projector = createIncrementalDisplayProjector((text) => {
filterCalls += 1;
filteredCharacters += text.length;
return text.replace(/\[TOOL_CALL\][\s\S]*$/i, '');
});
let text = '';
for (let i = 0; i < 2000; i++) {
const delta = i === 0 ? 'Here is the answer. ' : 'ordinary text ';
text += delta;
assert.equal(projector.append(delta, text), text);
}
assert.equal(filterCalls, 0, 'ordinary deltas never run the cumulative filter');
text += '[TOOL_';
projector.append('[TOOL_', text);
text += 'CALL]{"name":"read"}';
const beforeToolPayload = projector.append('CALL]{"name":"read"}', text);
for (let i = 0; i < 2000; i++) {
const delta = 'payload ';
text += delta;
assert.equal(projector.append(delta, text), beforeToolPayload);
}
assert.equal(filterCalls, 1, 'structured payload filtering happens only at its boundary');
assert.ok(filteredCharacters < text.length, 'filter work is bounded by the first structured boundary');
});
test('literal escaped tags survive and malformed live tags retain trailing text', () => {
assert.equal(
stripLiveThinkingTags('&lt;think&gt;literal&lt;/think&gt;'),
'&lt;think&gt;literal&lt;/think&gt;',
);
assert.equal(
stripLiveThinkingTags('<think>first</think> middle <thinking mode="deep">trailing'),
'first middle trailing',
);
assert.equal(stripLiveThinkingTags('answer with 2 < 3 and 5 > 4'), 'answer with 2 < 3 and 5 > 4');
});
test('terminal flush prepares and commits the complete trailing cumulative text', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
...timers,
prepare: stripLiveThinkingTags,
});
throttle.update('<think>reasoning without a closing tag');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['reasoning without a closing tag']);
assert.deepEqual(timers.pendingIds(), []);
});
test('independent throttles cannot commit cancelled text into another session', () => {
const timers = fakeTimers();
const commits = [];
const first = createLiveThinkingThrottle((value) => commits.push(['first', value]), timers);
const second = createLiveThinkingThrottle((value) => commits.push(['second', value]), timers);
first.update('stale first-session text');
second.update('current second-session text');
first.cancel();
assert.equal(second.flush(), true);
assert.deepEqual(timers.pendingIds(), []);
assert.deepEqual(commits, [['second', 'current second-session text']]);
});
test('flush synchronously preserves trailing text and cancels the pending callback', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('trailing text');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['trailing text']);
assert.deepEqual(timers.pendingIds(), []);
assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
});
test('cancel discards pending work without a late DOM commit', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('stale session text');
throttle.cancel();
assert.deepEqual(timers.pendingIds(), []);
assert.deepEqual(commits, []);
});
test('a cancelled throttle accepts new work again', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('discarded');
throttle.cancel();
throttle.update('fresh');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['fresh']);
});
test('coerces nullish updates instead of committing undefined', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update(null);
throttle.flush();
assert.deepEqual(commits, ['']);
});
+394
View File
@@ -0,0 +1,394 @@
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
the indexing job on the event loop.
The handler is ``async def`` but called ``rag.index_personal_documents``
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
FastAPI ran the whole job on the event loop and every other request queued
behind it: indexing a real directory froze the UI and API for 25+ minutes.
``personal_docs_manager.add_directory`` sits in the same blocking section it
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
These tests build the real router with fake managers and compare the thread
the indexing work runs on against the event loop's thread.
"""
import asyncio
import os
import threading
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
def _serialization_probe():
"""Shared counter proving two critical sections never overlap."""
state = {"active": 0, "max_active": 0}
lock = threading.Lock()
def enter():
with lock:
state["active"] += 1
state["max_active"] = max(state["max_active"], state["active"])
def leave():
with lock:
state["active"] -= 1
return state, enter, leave
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
# requests on the test's own loop.
def _async_client(app):
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
import routes.personal_routes as personal_routes
from core.middleware import require_admin
from src.auth_helpers import require_user
class _FakeRag:
def __init__(self, record):
self._record = record
def index_personal_documents(self, directory, owner=None):
self._record["index_thread"] = threading.get_ident()
return {"success": True, "indexed_count": 3, "failed_count": 0}
def _split_into_chunks(self, text, chunk_size=500):
return [text]
def add_document(self, chunk, metadata):
self._record["add_document_thread"] = threading.get_ident()
return True
def delete_by_source(self, filepath):
self._record["delete_thread"] = threading.get_ident()
return 1
class _FakeDocsManager:
def __init__(self, record):
self._record = record
self.index = []
def add_directory(self, directory, *, index=True, owner=None):
self._record["bookkeeping_thread"] = threading.get_ident()
self._record["bookkeeping_index_flag"] = index
def exclude_file(self, filepath):
self._record["exclude_thread"] = threading.get_ident()
def _build_app(tmp_path, monkeypatch, record):
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
app = FastAPI()
app.include_router(
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
)
app.dependency_overrides[require_user] = lambda: "tester"
app.dependency_overrides[require_admin] = lambda: None
@app.get("/loop-thread")
async def loop_thread_probe():
return {"thread": threading.get_ident()}
return app
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
# Context-manager client: one portal/event loop serves both requests, so
# the probe and the POST are guaranteed to see the same loop thread.
with TestClient(app) as client:
loop_thread = client.get("/loop-thread").json()["thread"]
resp = client.post(
"/api/personal/add_directory", json={"directory": str(target)}
)
assert resp.status_code == 200
assert record["index_thread"] != loop_thread, (
"index_personal_documents ran on the event loop thread — every other "
"request queues behind the indexing job (#5558)"
)
assert record["bookkeeping_thread"] != loop_thread, (
"personal_docs_manager.add_directory (refresh_index) ran on the event "
"loop thread"
)
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
client = TestClient(app)
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["indexed_count"] == 3
assert body["failed_count"] == 0
assert body["directory"] == os.path.realpath(str(target))
assert record["bookkeeping_index_flag"] is False
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
"""Off-loop execution must not mean parallel index jobs: concurrent
requests would race PersonalDocsManager's unsynchronized list mutations
and file writes (save_directories/_save_excluded are plain open('w'))."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.2); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
for name in ("docs_a", "docs_b"):
(tmp_path / name).mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} index jobs ran in parallel — concurrent "
"add_directory requests must serialize"
)
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
def _fail(directory, owner=None):
return {"success": False, "message": "boom"}
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
client = TestClient(app)
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
assert resp.status_code == 500
assert "boom" in resp.json()["detail"]
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
running while an add job is in flight races PersonalDocsManager's
unsynchronized list/index mutations the inconsistent state the PR's
'add/remove are serialized' guarantee claims to prevent."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_remove(self, directory):
enter(); time.sleep(0.25); leave()
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
(tmp_path / "docs_a").mkdir()
(tmp_path / "docs_b").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} add/remove critical sections overlapped — "
"remove must hold the same index job lock as add"
)
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
calls personal_docs_manager.add_directory the same vector/tracking state
add_directory mutates. It must hold the SAME job lock, or an upload landing
mid-add interleaves two writers over unsynchronized state."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_add_document(self, chunk, metadata):
self._record["add_document_thread"] = threading.get_ident()
enter(); time.sleep(0.25); leave()
return True
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
(tmp_path / "docs_a").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
)
assert all(r.status_code == 200 for r in results)
# The test coroutine runs on the event loop, so this IS the loop thread.
assert record["add_document_thread"] != threading.get_ident(), (
"rag.add_document ran on the event loop thread — chunk writes block "
"every other request for the duration of the upload"
)
assert state["max_active"] == 1, (
f"{state['max_active']} add/upload critical sections overlapped — "
"upload must hold the same index job lock as add"
)
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
"""A multi-file upload must retain at most one capped payload at a time."""
from starlette.datastructures import UploadFile as StarletteUploadFile
reads = []
original_read = StarletteUploadFile.read
async def _recording_read(upload, size=-1):
reads.append(upload.filename)
return await original_read(upload, size)
def _record_first_index(self, chunk, metadata):
self._record.setdefault("reads_at_first_index", len(reads))
return True
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
files = [
("files", ("a.txt", b"alpha", "text/plain")),
("files", ("b.txt", b"bravo", "text/plain")),
("files", ("c.txt", b"charlie", "text/plain")),
]
async with _async_client(app) as ac:
response = await ac.post("/api/personal/upload", files=files)
assert response.status_code == 200
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
assert reads == ["a.txt", "b.txt", "c.txt"]
assert record["reads_at_first_index"] == 1, (
"all upload bodies were retained before worker processing began"
)
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
calls personal_docs_manager.exclude_file. Both mutate state add_directory
also touches, so the delete must hold the SAME job lock as add."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_delete(self, filepath):
self._record["delete_thread"] = threading.get_ident()
enter(); time.sleep(0.25); leave()
return 1
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
(tmp_path / "docs_a").mkdir()
doomed = tmp_path / "doomed.txt"
doomed.write_text("bye")
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
)
assert all(r.status_code == 200 for r in results)
assert record["delete_thread"] != threading.get_ident(), (
"rag.delete_by_source ran on the event loop thread"
)
assert state["max_active"] == 1, (
f"{state['max_active']} add/delete critical sections overlapped — "
"delete must hold the same index job lock as add"
)
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
the same job lock so it cannot race an in-flight add job."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_refresh(self):
enter(); time.sleep(0.25); leave()
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
(tmp_path / "docs_a").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/reload"),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} add/reload critical sections overlapped — "
"reload must hold the same index job lock as add"
)
+128
View File
@@ -0,0 +1,128 @@
"""Windows execution contract for the agent Bash tool."""
import pytest
from src.agent_tools import subprocess_tools
@pytest.mark.asyncio
async def test_windows_bash_uses_git_bash_with_structural_cwd(monkeypatch):
captured = {}
bash = r"C:\Program Files\Git\bin\bash.exe"
workspace = r"D:\Workspaces\Project with spaces"
process = object()
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: bash)
async def fake_exec(*argv, **kwargs):
captured["argv"] = argv
captured["kwargs"] = kwargs
return process
async def fail_shell(*_args, **_kwargs):
pytest.fail("native Windows Bash must not execute through cmd.exe")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_shell)
result = await subprocess_tools._create_bash_subprocess(
"pwd; cat package.json",
cwd=workspace,
env={"HOME": r"C:\Odysseus\data"},
)
assert result is process
assert captured["argv"] == (bash, "-c", "pwd; cat package.json")
assert captured["kwargs"]["cwd"] == workspace
@pytest.mark.asyncio
async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
async def fail_spawn(*_args, **_kwargs):
pytest.fail("no subprocess should start without Git Bash")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_spawn)
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_spawn)
with pytest.raises(RuntimeError, match="Git Bash is required"):
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work")
@pytest.mark.asyncio
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch):
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
result = await subprocess_tools.BashTool().execute(
"pwd",
{"subproc_env": {}, "session_id": None},
)
assert result["exit_code"] == 1
assert "install Git for Windows" in result["error"]
@pytest.mark.asyncio
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
captured = {}
workspace = r"D:\Workspaces\Project with spaces"
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(
subprocess_tools.shutil,
"which",
lambda name: r"C:\msys64\usr\bin\tmux.exe",
)
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: workspace)
async def fail_tmux(*_args, **_kwargs):
pytest.fail("native Windows must not enter the POSIX tmux path")
async def fake_create(command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
return object()
async def fake_stream(_process, **_kwargs):
return "ok", "", 0, False
monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux)
monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
result = await subprocess_tools.BashTool().execute(
"pwd",
{"subproc_env": {}, "session_id": "chat-1"},
)
assert result == {"output": "ok", "exit_code": 0}
assert captured["command"] == "pwd"
assert captured["kwargs"]["cwd"] == workspace
@pytest.mark.asyncio
async def test_posix_bash_keeps_existing_shell_path(monkeypatch):
captured = {}
process = object()
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", False)
async def fake_shell(command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
return process
async def fail_exec(*_args, **_kwargs):
pytest.fail("POSIX behavior must continue through create_subprocess_shell")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fake_shell)
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_exec)
result = await subprocess_tools._create_bash_subprocess("pwd", cwd="/tmp/work")
assert result is process
assert captured == {"command": "pwd", "kwargs": {"cwd": "/tmp/work"}}
+1 -1
View File
@@ -76,7 +76,7 @@ def _load_webhook_routes_for_test(monkeypatch):
module_name = "routes.webhook_routes_under_test"
spec = importlib.util.spec_from_file_location(
module_name,
Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
Path(__file__).resolve().parent.parent / "routes" / "webhook" / "webhook_routes.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
+44 -6
View File
@@ -1,17 +1,19 @@
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
Both write to a sibling ``.tmp.<pid>`` file, ``fsync`` it, then ``os.replace``
into place so a crash mid-write leaves the previous good copy untouched rather
than a truncated/empty file.
Both write to a sibling ``.tmp.<random>`` file, ``fsync`` it, then
``os.replace`` into place so a crash mid-write leaves the previous good copy
untouched rather than a truncated/empty file.
These tests cover the happy path (round-trip, indent, parent-dir creation,
full overwrite, no leftover tmp) and the two failure paths the implementation
guarantees: the target file is preserved when serialization fails before the
replace, and when ``os.replace`` itself fails.
full overwrite, no leftover tmp), the two failure paths the implementation
guarantees (the target file is preserved when serialization fails before the
replace, and when ``os.replace`` itself fails), and that two concurrent
writers to the same path don't collide on the same temp file.
"""
import importlib.util
import json
import threading
from pathlib import Path
import pytest
@@ -84,6 +86,42 @@ def test_atomic_write_json_leaves_no_tmp_file(tmp_path):
assert _tmp_siblings(tmp_path, "data.json") == []
def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
# Both writers run in this same process, so a PID-based tmp suffix is
# identical for both: whichever writer finishes first unlinks the tmp
# file (via os.replace) out from under the other, which then raises
# FileNotFoundError on its own os.replace instead of landing its write.
target = tmp_path / "settings.json"
orig_dump = json.dump
barrier = threading.Barrier(2)
errors = []
def slow_dump(obj, fp, **kwargs):
orig_dump(obj, fp, **kwargs)
fp.flush()
barrier.wait()
def write(payload):
try:
atomic_write_json(str(target), payload)
except Exception as exc: # noqa: BLE001 - captured for the assertion below
errors.append(exc)
json.dump = slow_dump
try:
t1 = threading.Thread(target=write, args=({"writer": "A"},))
t2 = threading.Thread(target=write, args=({"writer": "B"},))
t1.start()
t2.start()
t1.join()
t2.join()
finally:
json.dump = orig_dump
assert errors == []
assert json.loads(target.read_text(encoding="utf-8"))["writer"] in ("A", "B")
# ---------------------------------------------------------------------------
# atomic_write_json — failure path: target preserved on serialization error.
# ---------------------------------------------------------------------------
@@ -27,6 +27,9 @@ def _setup(monkeypatch, store, user="alice"):
mem = MagicMock()
mem.load_all.return_value = list(store)
# import_data reads through the strict loader so a store it cannot read is
# never overwritten (#5673); the double has to offer the same entry point.
mem.load_all_for_update.return_value = list(store)
saved = {}
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
+538
View File
@@ -0,0 +1,538 @@
"""Default calendar creation belongs to the caller's transaction.
Before this regression, ``_ensure_default_calendar`` committed independently.
If event persistence then failed, the event rolled back but a new ``Personal``
calendar remained (``calendar_count=1``, ``event_count=0``).
"""
import json
import threading
from contextlib import contextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb # noqa: E402
import routes.calendar_routes as calendar_routes # noqa: E402
from core.database import CalendarCal, CalendarEvent # noqa: E402
from routes.calendar_routes import EventCreate # noqa: E402
from routes.calendar_routes import ( # noqa: E402
_default_calendar_id,
_ensure_default_calendar,
)
class _RejectEventCommit(Session):
"""Reproduce an event commit failure after default-calendar creation."""
def commit(self):
if any(isinstance(row, CalendarEvent) for row in self.new):
raise RuntimeError("commit guard rejected event commit")
return super().commit()
@pytest.fixture
def session_factory(tmp_path, monkeypatch):
engine = create_engine(
f"sqlite:///{tmp_path / 'calendar.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
class_=_RejectEventCommit,
)
monkeypatch.setattr(cdb, "SessionLocal", factory)
monkeypatch.setattr(calendar_routes, "SessionLocal", factory)
try:
yield factory
finally:
engine.dispose()
def _request():
return SimpleNamespace(state=SimpleNamespace(current_user="alice"))
def _endpoint(method, suffix):
router = calendar_routes.setup_calendar_routes()
for route in router.routes:
if route.path.endswith(suffix) and method in route.methods:
return route.endpoint
raise RuntimeError(f"{method} *{suffix} not found")
def _counts(factory):
db = factory()
try:
return db.query(CalendarCal).count(), db.query(CalendarEvent).count()
finally:
db.close()
async def test_route_event_failure_rolls_back_new_default_calendar(session_factory):
create_event = _endpoint("POST", "/events")
with pytest.raises(HTTPException) as caught:
await create_event(
_request(),
EventCreate(summary="Planning", dtstart="2126-07-20T09:00:00Z"),
)
assert caught.value.status_code == 500
assert _counts(session_factory) == (0, 0)
async def test_route_event_validation_failure_rolls_back_new_default_calendar(
session_factory,
):
create_event = _endpoint("POST", "/events")
with pytest.raises(HTTPException) as caught:
await create_event(
_request(),
EventCreate(summary="Planning", dtstart="not-a-datetime"),
)
assert caught.value.status_code == 500
assert _counts(session_factory) == (0, 0)
async def test_tool_event_failure_rolls_back_new_default_calendar(session_factory):
from src.tools.calendar import do_manage_calendar
result = await do_manage_calendar(
json.dumps({
"action": "create_event",
"summary": "Planning",
"dtstart": "2126-07-20T09:00:00Z",
}),
owner="alice",
)
assert result["exit_code"] == 1
assert "commit guard rejected event commit" in result["error"]
assert _counts(session_factory) == (0, 0)
async def test_tool_event_validation_failure_rolls_back_new_default_calendar(
session_factory,
):
from src.tools.calendar import do_manage_calendar
result = await do_manage_calendar(
json.dumps({
"action": "create_event",
"summary": "Planning",
"dtstart": "not-a-datetime",
}),
owner="alice",
)
assert result["exit_code"] == 1
assert "Could not parse dtstart" in result["error"]
assert _counts(session_factory) == (0, 0)
async def test_route_list_calendars_persists_lazy_default(session_factory):
list_calendars = _endpoint("GET", "/calendars")
result = await list_calendars(_request())
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
assert _counts(session_factory) == (1, 0)
async def test_tool_list_calendars_persists_lazy_default(session_factory):
from src.tools.calendar import do_manage_calendar
result = await do_manage_calendar(
json.dumps({"action": "list_calendars"}),
owner="alice",
)
assert result["exit_code"] == 0
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
assert _counts(session_factory) == (1, 0)
def test_repeated_rename_and_reuse_uses_stable_fallback_ids(tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'renamed-calendar.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
db = factory()
try:
first = _ensure_default_calendar(db, "alice")
assert first.id == _default_calendar_id("alice")
db.commit()
# The supported user-rename migration changes owner columns while
# deliberately preserving durable row identifiers.
first.owner = "bob"
db.commit()
second = _ensure_default_calendar(db, "alice")
assert second.id == _default_calendar_id("alice", 1)
db.commit()
# Repeating the same lifecycle must advance deterministically instead
# of failing or choosing a random identifier.
second.owner = "carol"
db.commit()
third = _ensure_default_calendar(db, "alice")
assert third.id == _default_calendar_id("alice", 2)
db.commit()
rows = db.query(CalendarCal).order_by(CalendarCal.owner).all()
assert [(row.owner, row.id) for row in rows] == [
("alice", _default_calendar_id("alice", 2)),
("bob", _default_calendar_id("alice")),
("carol", _default_calendar_id("alice", 1)),
]
finally:
db.close()
engine.dispose()
def _assert_concurrent_first_use(tmp_path, occupied_owner=None):
engine = create_engine(
f"sqlite:///{tmp_path / 'concurrent-calendar.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
expected_collision_index = 0
if occupied_owner is not None:
seed = factory()
try:
seed.add(CalendarCal(
id=_default_calendar_id("alice"),
owner=occupied_owner,
name="Personal",
source="local",
))
seed.commit()
expected_collision_index = 1
finally:
seed.close()
first_staged = threading.Event()
second_selected = threading.Event()
errors = []
@event.listens_for(engine, "after_cursor_execute")
def observe_second_gap(conn, cursor, statement, parameters, context, executemany):
if (
threading.current_thread().name == "calendar-worker-second"
and statement.lstrip().upper().startswith("SELECT")
and "FROM calendars" in statement
):
second_selected.set()
def create_default(worker, hold=False):
db = factory()
try:
if not hold:
assert first_staged.wait(5)
cal = _ensure_default_calendar(db, "alice")
start = datetime(2126, 7, 20, 9 if hold else 10)
db.add(CalendarEvent(
uid=worker,
calendar_id=cal.id,
summary=f"Event {worker}",
dtstart=start,
dtend=start + timedelta(hours=1),
))
if hold:
first_staged.set()
# The second session has observed the uncommitted gap before
# this transaction releases its writer reservation.
assert second_selected.wait(5)
db.commit()
assert cal.id == _default_calendar_id("alice", expected_collision_index)
except BaseException as exc: # pragma: no cover - asserted below
errors.append((worker, exc))
db.rollback()
finally:
db.close()
first = threading.Thread(
target=create_default,
args=("first", True),
name="calendar-worker-first",
)
second = threading.Thread(
target=create_default,
args=("second",),
name="calendar-worker-second",
)
first.start()
second.start()
first.join(10)
second.join(10)
try:
assert not first.is_alive() and not second.is_alive()
assert errors == []
db = factory()
try:
rows = db.query(CalendarCal).filter(CalendarCal.owner == "alice").all()
assert [(row.id, row.name) for row in rows] == [
(_default_calendar_id("alice", expected_collision_index), "Personal")
]
assert db.query(CalendarEvent).count() == 2
if occupied_owner is not None:
occupied = db.query(CalendarCal).filter(
CalendarCal.id == _default_calendar_id("alice"),
).one()
assert occupied.owner == occupied_owner
finally:
db.close()
finally:
engine.dispose()
def test_concurrent_first_use_creates_one_sqlite_default(tmp_path):
_assert_concurrent_first_use(tmp_path)
def test_concurrent_first_use_after_rename_creates_one_fallback_default(tmp_path):
_assert_concurrent_first_use(tmp_path, occupied_owner="bob")
def test_sqlite_default_stays_in_callers_transaction(session_factory):
db = session_factory()
try:
cal = _ensure_default_calendar(db, "rollback-owner")
assert cal.id == _default_calendar_id("rollback-owner")
db.rollback()
finally:
db.close()
verify = session_factory()
try:
assert (
verify.query(CalendarCal)
.filter(CalendarCal.owner == "rollback-owner")
.count()
== 0
)
finally:
verify.close()
def test_sqlite_fallback_default_stays_in_callers_transaction(session_factory):
seed = session_factory()
try:
seed.add(CalendarCal(
id=_default_calendar_id("alice"),
owner="bob",
name="Personal",
source="local",
))
seed.commit()
finally:
seed.close()
db = session_factory()
try:
cal = _ensure_default_calendar(db, "alice")
assert cal.id == _default_calendar_id("alice", 1)
db.rollback()
finally:
db.close()
verify = session_factory()
try:
assert verify.query(CalendarCal).filter(CalendarCal.owner == "alice").count() == 0
assert verify.query(CalendarCal).filter(CalendarCal.owner == "bob").count() == 1
finally:
verify.close()
class _FakeDialect:
name = "postgresql"
class _FakeBind:
dialect = _FakeDialect()
class _FakeQuery:
def __init__(self, session):
self.session = session
def filter(self, *conditions):
return self
def with_for_update(self):
self.session.locking_read = True
return self
def first(self):
self.session.query_count += 1
if self.session.query_count == 1:
return None
return self.session.winner
class _GenericRaceSession:
"""Minimal non-SQLite session that loses the deterministic-ID race."""
def __init__(self):
self.query_count = 0
self.nested_entries = 0
self.locking_read = False
self.candidate = None
self.winner = CalendarCal(
id=_default_calendar_id("alice"),
owner="alice",
name="Personal",
source="local",
)
def get_bind(self):
return _FakeBind()
def query(self, model):
assert model is CalendarCal
return _FakeQuery(self)
@contextmanager
def begin_nested(self):
self.nested_entries += 1
yield
def add(self, row):
self.candidate = row
def flush(self):
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
def test_generic_backend_lost_race_recovers_inside_savepoint():
db = _GenericRaceSession()
winner = _ensure_default_calendar(db, "alice")
assert winner is db.winner
assert db.nested_entries == 1
assert db.locking_read is True
assert db.candidate.id == db.winner.id
def test_generic_backend_unattributed_integrity_error_is_not_retried():
db = _GenericRaceSession()
db.winner = None
with pytest.raises(IntegrityError):
_ensure_default_calendar(db, "alice")
assert db.nested_entries == 1
class _GenericRenamedSlotSession(_GenericRaceSession):
"""A different owner occupies slot zero; slot one remains available."""
def __init__(self):
super().__init__()
self.candidates = []
self.winner = CalendarCal(
id=_default_calendar_id("alice"),
owner="bob",
name="Personal",
source="local",
)
def add(self, row):
self.candidate = row
self.candidates.append(row)
def flush(self):
if len(self.candidates) == 1:
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
def test_generic_backend_renamed_slot_advances_inside_savepoint():
db = _GenericRenamedSlotSession()
fallback = _ensure_default_calendar(db, "alice")
assert fallback is db.candidates[-1]
assert fallback.id == _default_calendar_id("alice", 1)
assert fallback.owner == "alice"
assert db.nested_entries == 2
assert db.locking_read is True
assert db.winner.owner == "bob"
def test_generic_backend_fallback_keeps_outer_transaction_usable(tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'generic-savepoint-calendar.db'}",
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
# SQLite supplies a lightweight local SQL executor here; changing only the
# dispatch name exercises the real Session/savepoint branch used by
# PostgreSQL-style backends without pretending to validate their dialect.
engine.dialect.name = "postgresql"
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
seed = factory()
try:
seed.add(CalendarCal(
id=_default_calendar_id("alice"),
owner="bob",
name="Personal",
source="local",
))
seed.commit()
finally:
seed.close()
db = factory()
try:
cal = _ensure_default_calendar(db, "alice")
start = datetime(2126, 7, 20, 9)
db.add(CalendarEvent(
uid="after-fallback",
calendar_id=cal.id,
summary="Atomic",
dtstart=start,
dtend=start + timedelta(hours=1),
))
db.commit()
finally:
db.close()
verify = factory()
try:
assert [
(row.owner, row.id)
for row in verify.query(CalendarCal).order_by(CalendarCal.owner).all()
] == [
("alice", _default_calendar_id("alice", 1)),
("bob", _default_calendar_id("alice")),
]
assert verify.query(CalendarEvent).count() == 1
finally:
verify.close()
engine.dispose()
+2 -1
View File
@@ -1,3 +1,4 @@
import re
from pathlib import Path
@@ -13,7 +14,7 @@ def test_stream_render_helpers_are_visible_to_catch_block():
assert "let _cancelThinkingTimer = () => {};" in outer_scope
assert "let _removeThinkingSpinner = () => {};" in outer_scope
assert "_renderStream = () => {" in try_body
assert re.search(r"(?m)^\s*_renderStream\s*=", try_body)
assert "_cancelThinkingTimer = () => {" in try_body
assert "_removeThinkingSpinner = () => {" in try_body
assert "function _renderStream()" not in try_body
+35
View File
@@ -0,0 +1,35 @@
"""Regression coverage for authoritative Python CI validation."""
import re
from pathlib import Path
_WORKFLOW = (
Path(__file__).resolve().parent.parent / ".github" / "workflows" / "ci.yml"
)
def _indented_block(text: str, heading: str, indent: int) -> str:
pattern = re.compile(
rf"(?ms)^{' ' * indent}{re.escape(heading)}:\n"
rf"(?P<body>(?:(?:{' ' * (indent + 2)}.*|\s*)\n)*)"
)
match = pattern.search(text)
assert match is not None, f"missing {heading!r} block"
return match.group(0)
def test_ci_runs_on_integrated_dev_pushes():
workflow = _WORKFLOW.read_text()
push = _indented_block(workflow, "push", 2)
assert re.search(r"(?m)^ branches:\s*\[main,\s*dev\]\s*$", push)
assert "paths-ignore:" not in push
def test_python_tests_are_authoritative():
workflow = _WORKFLOW.read_text()
python_tests = _indented_block(workflow, "python-tests", 2)
assert "python -m pytest -q" in python_tests
assert "continue-on-error:" not in python_tests
+21
View File
@@ -306,3 +306,24 @@ def test_integration_recalls_from_chat_history_dom():
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
def test_prompt_recall_is_not_duplicated_in_app_js():
"""Only composerArrowUpRecall.js may own ArrowUp on #message (issue #5862).
static/app.js once carried a near-verbatim copy of this recall logic, wired
as a second capture-phase listener on the same textarea. That copy lacked
the draft guard here, and because it called stopImmediatePropagation it won
regardless of registration order so a typed multi-line prompt was replaced
by the last sent one instead of the caret moving up a line.
"""
app_js = (_REPO / "static" / "app.js").read_text(encoding="utf-8")
for marker in (
"_odysseusPromptRecallCapture",
"_readComposerPromptHistory",
"odysseusRecallIndex",
):
assert marker not in app_js, (
f"static/app.js reintroduces prompt recall ({marker!r}); "
"it belongs to static/js/composerArrowUpRecall.js alone"
)
+6 -1
View File
@@ -723,7 +723,12 @@ def test_local_windows_download_pid_tracks_inner_bash_and_stop_kills_tree():
routes_src = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
running_src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbookRunning.js").read_text(encoding="utf-8")
assert 'printf \'%s\\\\n\' \\"$$\\" > {pp}' in routes_src
# The Windows-local runner publishes Python's valid Win32 fallback before
# allowing Git Bash to replace it with /proc/$$/winpid.
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in routes_src
assert "/proc/$$/winpid" in routes_src
assert "pid_ready_path.touch()" in routes_src
assert '\\"$$\\" > {pp}' not in routes_src
assert "function Stop-Tree([int]$Id)" in running_src
assert "('ParentProcessId = ' + $Id)" in running_src
assert "Stop-Tree ([int]$p)" in running_src
@@ -0,0 +1,180 @@
"""Behavioral regression coverage for Windows-local Cookbook PID recording."""
import os
import subprocess
import time
from pathlib import Path
from routes.cookbook_routes import _windows_local_pid_record_line
ROOT = Path(__file__).resolve().parents[1]
COOKBOOK_ROUTES = ROOT / "routes" / "cookbook_routes.py"
def _fake_cat(tmp_path: Path, body: str) -> Path:
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
cat = fake_bin / "cat"
cat.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
cat.chmod(0o755)
return fake_bin
def _env_for(fake_bin: Path, **extra: str) -> dict[str, str]:
env = dict(os.environ)
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
env.update(extra)
return env
def _run_pid_line(
pid_path: Path,
ready_path: Path,
fake_bin: Path,
**extra_env: str,
) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", _windows_local_pid_record_line(pid_path, ready_path)],
capture_output=True,
text=True,
env=_env_for(fake_bin, **extra_env),
timeout=10,
)
def test_windows_local_pid_line_records_numeric_winpid_after_fallback(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
pid_path.write_text("11111", encoding="utf-8")
ready_path.touch()
cat_arg = tmp_path / "cat-arg.txt"
fake_bin = _fake_cat(
tmp_path,
'printf "%s\\n" "$1" > "$FAKE_CAT_ARG"\n'
'printf "%s\\n" "$FAKE_WINPID"',
)
result = _run_pid_line(
pid_path,
ready_path,
fake_bin,
FAKE_CAT_ARG=str(cat_arg),
FAKE_WINPID="42324",
)
assert result.returncode == 0, result.stderr
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
assert not ready_path.exists()
proc_path = cat_arg.read_text(encoding="utf-8").strip()
parts = proc_path.strip("/").split("/")
assert len(parts) == 3
assert parts[0] == "proc"
assert parts[1].isdigit()
assert parts[2] == "winpid"
def test_windows_local_pid_line_waits_for_python_fallback_before_replacing(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
fake_bin = _fake_cat(
tmp_path,
'printf "%s\\n" "$FAKE_WINPID"',
)
proc = subprocess.Popen(
[
"bash",
"-c",
_windows_local_pid_record_line(pid_path, ready_path),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=_env_for(fake_bin, FAKE_WINPID="42324"),
)
# The inner shell has started, but Python has not published its fallback yet.
time.sleep(0.05)
assert proc.poll() is None
assert not pid_path.exists()
# Simulate the post-Popen Python publication order.
pid_path.write_text("31100", encoding="utf-8")
ready_path.touch()
stdout, stderr = proc.communicate(timeout=10)
assert proc.returncode == 0, stderr or stdout
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
assert not ready_path.exists()
def test_windows_local_pid_line_preserves_outer_pid_when_mapping_missing(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
pid_path.write_text("31100", encoding="utf-8")
ready_path.touch()
fake_bin = _fake_cat(tmp_path, "exit 1")
result = _run_pid_line(
pid_path,
ready_path,
fake_bin,
)
assert result.returncode == 0, result.stderr
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
assert not ready_path.exists()
def test_windows_local_pid_line_rejects_malformed_mapping(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
pid_path.write_text("31100", encoding="utf-8")
ready_path.touch()
fake_bin = _fake_cat(
tmp_path,
'printf "not-a-win32-pid\\n"',
)
result = _run_pid_line(
pid_path,
ready_path,
fake_bin,
)
assert result.returncode == 0, result.stderr
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
assert not ready_path.exists()
def test_local_windows_launcher_publishes_fallback_before_releasing_inner_runner():
source = COOKBOOK_ROUTES.read_text(encoding="utf-8")
start = source.index(" def _launch_local_detached(")
end = source.index(
' @router.post("/api/model/download")',
start,
)
launcher = source[start:end]
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in launcher
assert "pid_ready_path.unlink(missing_ok=True)" in launcher
fallback = launcher.index(
'pid_path.write_text(str(proc.pid), encoding="utf-8")'
)
release = launcher.index("pid_ready_path.touch()")
assert fallback < release
# Never write Git Bash's bare MSYS $$ to the session pid file.
assert '\\"$$\\" > {pp}' not in launcher
+29
View File
@@ -0,0 +1,29 @@
"""Regression test for the document route shim (slice 2m, #4082/#4071).
The backward-compat shims at ``routes/document_routes.py`` and
``routes/document_helpers.py`` use ``sys.modules`` replacement so the legacy
import paths and the canonical ``routes.document.*`` paths resolve to the
*same* module objects. This is required because multiple tests do
``import routes.document_routes as droutes`` followed by
``droutes.SessionLocal = ...`` / ``monkeypatch.setattr(droutes, ...)`` and
``sys.modules.pop("routes.document_helpers")`` + re-import for those to
take effect at runtime, the legacy and canonical module objects must be
identical.
"""
import importlib
import routes.document_routes as _shim_routes # noqa: F401
import routes.document_helpers as _shim_helpers # noqa: F401
def test_legacy_and_canonical_routes_are_same_object():
legacy = importlib.import_module("routes.document_routes")
canonical = importlib.import_module("routes.document.document_routes")
assert legacy is canonical
def test_legacy_and_canonical_helpers_are_same_object():
legacy = importlib.import_module("routes.document_helpers")
canonical = importlib.import_module("routes.document.document_helpers")
assert legacy is canonical

Some files were not shown because too many files have changed in this diff Show More