Compare commits

...
302 Commits
Author SHA1 Message Date
RaresKeY ffd0aaf69b fix(cookbook): validate adopt host (#4282) 2026-06-15 16:44:24 +02:00
RaresKeY 81e7074d93 fix(gallery): confine replacement image path (#4285) 2026-06-15 16:42:41 +02:00
RaresKeY f66a23d19d fix(ai): validate generated image result URLs (#4289) 2026-06-15 16:40:49 +02:00
RaresKeY f602819523 fix(models): scope API-token model listing (#4292) 2026-06-15 16:38:41 +02:00
RaresKeY 85a773ea02 fix(personal): resolve upload delete path (#4291) 2026-06-15 16:38:37 +02:00
PewDiePie fb0a64fe4f Merge pull request #4306 from pewdiepie-archdaemon/readme-refresh-default-branch
docs: refresh README presentation
2026-06-15 23:28:20 +09:00
pewdiepie-archdaemon bcf46dafb9 Refresh README presentation 2026-06-15 23:26:10 +09:00
pewdiepie-archdaemon b118c33e37 test(provider): align lookalike-host URL expectations with /models behavior
build_models_url returns /models (no /v1 prefix) for non-local generic
OpenAI-compatible hosts (intentional, see endpoint_resolver.py:206). The
tests added in #4272 expected /v1/models, which is the local/deepseek
behavior. Match production semantics.
2026-06-15 23:21:49 +09:00
pewdiepie-archdaemon da74cc23e4 Merge remote-tracking branch 'origin/dev' 2026-06-15 23:13:18 +09:00
Ashvin d792b61722 test(gallery): point delete-ordering tests at the tmp image dir (#4300)
The two delete-ordering tests did monkeypatch.chdir(tmp_path) and wrote the
image under tmp_path/data/generated_images, but DATA_DIR (and therefore
gallery_routes.GALLERY_IMAGE_DIR) is always an absolute path, so the delete
resolver pointed at the repo's real data dir and ignored the chdir.

test_file_removed_on_successful_delete therefore failed on dev (the file at
the tmp path was never the one being removed), and test_file_kept_when_commit_fails
passed only by accident. Set GALLERY_IMAGE_DIR to the seeded tmp dir via
monkeypatch so both tests exercise the real path and pass deterministically.
2026-06-15 14:07:49 +00:00
pewdiepie-archdaemon 1faadf7e10 Merge remote-tracking branch 'origin/dev' 2026-06-15 23:02:46 +09:00
Kenny Van de Maele e87b44126c test(hwfit): fix non-Apple guard to assert the Apple matcher (unblocks pytest gate) (#4303)
* test(hwfit): assert the Apple matcher, not the general lookup, in the non-Apple guard

f7aa2de (#2564) added test_non_apple_gpu_with_cores_does_not_match, which
asserts _lookup_bandwidth(RTX 4090) is None. But '4090': 1008 has been in
the general GPU_BANDWIDTH table since v1.0, so _lookup_bandwidth correctly
returns the card's real bandwidth and the test fails (expected None, got
1008) - reddening the required pytest gate on dev and, by inheritance,
every open PR.

The guard's actual intent is that the Apple-specific bandwidth path does
not false-match a non-Apple card that carries a gpu_cores count. Point
the two asserts at _lookup_apple_bandwidth, which returns None for any
name without 'apple' regardless of the general table. The general-lookup
behavior (4090 -> 1008) is correct and untouched.

* fix(hwfit): route string GPU names through the Apple bandwidth helper

Second half of the #2564 regression (RaresKeY review on #4303). That
change moved the Apple tiers out of the generic GPU_BANDWIDTH table into
the dict-only _lookup_apple_bandwidth, but _lookup_bandwidth only called
that helper for dict inputs. A bare-string caller like
_lookup_bandwidth("Apple M3 Max") therefore fell through to the generic
table, found no Apple key, and returned None instead of the conservative
tier. Route both dict and string inputs through the Apple helper (a
string carries no gpu_cores, so it gets the model's lowest tier).
Regression added for the string path plus a non-Apple string control.
2026-06-15 14:01:05 +00:00
pewdiepie-archdaemon 62476ddb55 Merge remote-tracking branch 'origin/dev' 2026-06-15 22:59:57 +09:00
pewdiepie-archdaemon e899817969 Remove duplicate CodeQL workflow 2026-06-15 22:53:29 +09:00
pewdiepie-archdaemon 1cc9a003fd Fix failing post-merge tests 2026-06-15 22:49:06 +09:00
Ahmad Naalweh f7aa2de410 fix(hwfit): distinguish Apple Silicon bandwidth variants (#2564)
* fix: resolve Apple Silicon bandwidth variants

* fix(hwfit): preserve string lookup path in _lookup_bandwidth

* fix(hwfit): guard Apple bandwidth lookup against false GPU matches

Add "apple" not in gn check to _lookup_apple_bandwidth() so that
non-Apple GPUs with "m3"/"m4"/"m5" in their names (e.g. NVIDIA
Quadro M4 000) don't incorrectly match Apple bandwidth tiers.

Addresses @o3LL review comment on PR #2564.
2026-06-15 15:13:03 +02:00
Ashvin 514d345334 test(models): pin lookalike hosts to the generic OpenAI branch (#4272)
#4159 (4b0a977) made build_models_url insert /v1 for path-less bases, so
the TestBuildersRejectLookalikeHosts model assertions that expected
/models started failing and turned the pytest gate red on dev.

Both the generic OpenAI branch and the real Anthropic branch now end in
/v1/models, so a URL-only assertion no longer proves a lookalike host
dodged the Anthropic/Ollama branch. Assert _detect_provider == "openai"
directly and keep the /v1/models expectation.
2026-06-15 12:43:33 +00:00
pewdiepie-archdaemon 6d507f8128 Merge remote-tracking branch 'origin/dev' into test-main-dev-merge-20260615
# Conflicts:
#	src/tool_implementations.py
#	static/js/research/panel.js
2026-06-15 21:20:15 +09:00
pewdiepie-archdaemon 2cbd55b8bd Open email context for agent, email search across All Mail, cookbook serve polish
- Agent: pass the open email reader (uid/folder/account/from/subject/body
  preview) on every chat submit so 'reply to this' / 'write email saying
  hi' route to ui_control open_email_reply with the right UID instead of
  inventing a new .md draft. Code-level enforcement (chat_routes strips
  create_document + send_email when active_email is set); cross-session
  active_doc_id is now trusted instead of being silently dropped.
  set_active_email/clear_active_email tool-layer helpers in
  tool_implementations.

- ui_control open_email_reply: optional body argument so the agent can
  open-and-write in one call; envelope now forwards uid/folder/account/
  body/panel through tool_output. Tool description sharpened and the
  parser rejects empty bodies on reply/reply-all (forces the agent to
  write rather than open an empty draft).

- Email library: search now runs against [Gmail]/All Mail when the
  current folder is INBOX (archived emails surface). Whirlpool spinner
  + 'Searching…' placeholder while in flight. Each search result is
  stamped with its source folder so clicks open the right email instead
  of whatever shares its UID in INBOX. Search no longer re-applies the
  same text pill locally (which only checks subject/from/snippet, never
  body) so body-only matches don't get dropped after IMAP returns them.
  Initial inbox load bumped 100→500.

- Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the
  card menu and the open-reader more menu, backed by a new
  /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always
  bubble to the top of the grid regardless of active sort.

- AI reply in doc editor: never overwrites existing draft text or the
  quoted history. AI suggestion is prepended; AI-generated 'On …
  wrote:' re-quotes are stripped so the original quote isn't visually
  edited.

- Cookbook serve: pre-launch GPU driver / has_gpu / install / version-
  floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0
  etc.) before the launch chain starts. Detect 'another model already
  running on this host' and offer Stop & launch (with graceful then
  force tmux kill helpers, port release wait). Per-vendor deep-link
  buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend
  picker is now a custom dropdown with accent-coloured logos for vLLM,
  SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to
  package names in Dependencies. Runtime-readiness note moved inside
  the panel (green when ready, red when missing) with an × dismiss.
  Esc collapses the expanded card; expanded card scrolls when it
  overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce
  Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on
  one row (Reasoning Parser auto-detected per model family).
  Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto'
  input — command builders read from the GPU button strip. Default
  cookbook open is Download tab.

- Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by
  release_date; release dates can be backfilled with the new
  scripts/backfill_model_release_dates.py and recipe metadata pulled
  with scripts/import_from_vllm_recipes.py against the upstream
  vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped
  on entries).

- Calendar: Quick add hint cycles a random Odysseus-themed example per
  open (wooden horse Friday, crew muster 10am daily, council on
  Ithaca, …). Typing a time like '11pm' in the event title updates
  the hero clock live.

- Doc editor: email-mode Reply button (sparkle icon, accent) opens the
  same Fast/Full + context popover the email reader uses; Ctrl+Alt+M
  toggles markdown preview.

- Memories panel: custom sort picker with per-option icons, default
  'Latest', visible Enabled/Disabled toggle text matching the section
  description style.
2026-06-15 20:47:51 +09:00
andrewemerandClaude Fable 5 cd02ac7ef6 fix(agent): skill-prescribed tools never reach the model's schema list (#4008)
* Agent: make skill-prescribed tools actually callable

The skill index and matched-skill procedures are injected into the
prompt, but tool selection never followed: manage_skills wasn't in the
RAG-selected schema list (so the model substituted manage_memory), and
a matched skill could prescribe tools (grep, read_file) the model had
no schema for. Now:

- manage_skills rides along whenever the owner has any skills indexed
- a Jaccard-matched skill's requires_toolsets join the selection
- viewing a skill mid-turn via manage_skills unlocks its
  requires_toolsets for subsequent rounds
- admin-intent turns send _ADMIN_TOOLS schemas, matching the prompt
  text _build_base_prompt already advertises
- index_for(active_toolsets=None) no longer hides requires_toolsets
  skills from callers that don't know the active set

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

* Agent: validate skill requires_toolsets against known tools, not TOOL_SECTIONS

grep/glob/ls ship as function schemas without a prompt-prose section,
so gating on TOOL_SECTIONS silently dropped them from a skill's
requires_toolsets.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 20:32:43 +09:00
cirimandDan e7abb7559d fix(research): keep Discuss chats grounded on their report (#4006)
* fix(research): preserve Discuss spin-off primer during context trimming

trim_for_context() kept only system_msgs[:1] as essential and dropped the
rest under budget pressure. A research "Discuss" spin-off seeds the report
as a system message that sits after the preface system messages, so it
landed in extra_system and was the first thing evicted once the chat grew
— the conversation then lost its grounding and drifted off task.

Treat any system message carrying research_spinoff_from metadata as
essential, alongside the leading system prompt, so the seeded report
survives trimming. maybe_compact already retains all system messages.

Tests: tests/test_context_compactor.py::TestResearchPrimerPreserved

* fix(research): ground Discuss spin-off chats on the seeded report

build_chat_context injected global memory (pinned + hybrid-retrieved) and
personal-doc RAG every turn, keyed off the user-level memory_enabled pref
and a request-scoped use_rag flag — never the session. A research spin-off,
whose primer declares the report the sole knowledge base, thus had
unrelated keyword-matched facts pulled in ("wrong data") competing with the
report; its rag=False flag was also ignored (use_rag defaulted on).

Add _session_is_research_spinoff(sess) (detects the primer research_spinoff_from
metadata; handles ChatMessage and dict forms) and, for such sessions,
disable memory injection and force RAG off.

Tests: tests/test_chat_helpers.py spin-off detection cases

---------

Co-authored-by: Dan (cirim) <claude@cirim.org>
2026-06-15 20:31:57 +09:00
Max HsuandClaude Opus 4.8 172a8ea7b0 fix(skills): keep edit mode open on outside-the-textarea click (#4011)
Clicking the card body outside the edit <textarea> bubbled to the card's
click handler and collapsed the card, silently discarding unsaved skill
edits (issue #4002). The textarea's own stopPropagation only shields
clicks landing on it. Bail out of the card click handler while a
.skill-md-editor is present so the card only leaves edit mode via Save
(Cancel button is handled separately by #3580). Mirrors the same guard
into the built-in capability card, which shared the bug.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:31:11 +09:00
Daniel 2adae2bbba Parameterize Docker Compose volume host paths (#3907) 2026-06-15 20:30:18 +09:00
Josh Patra f5d3e5098a fix(llm): omit temperature for Kimi K2.5 and K2.6 (#3960) 2026-06-15 20:29:22 +09:00
Josh Patra 4ee5ed4dce fix(memory): return complete memory lists (#3885) 2026-06-15 20:28:25 +09:00
Josh Patra f2bfe9b91f fix(memory): exempt audits from request timeout (#3886) 2026-06-15 20:27:46 +09:00
Hsin-Chen PaiandClaude Opus 4.8 3f3c05e8c2 docs: add backup/restore guide for odysseus-backup (#2587)
The scripts/odysseus-backup snapshot/restore CLI was undocumented in
README.md and docs/. Add docs/backup-restore.md covering the snapshot,
list, verify, and restore subcommands, default include/skip behavior
(deep_research and mail-attachments skipped unless flagged), the
destructive-restore warning and its data.before-restore-* stash, a cron
example, and Docker-vs-native data/ paths (including the ChromaDB named
volume caveat). Link it from the README Data section.

Addresses the "Backup/restore guide and helper flow for data/" item in
ROADMAP.md. Docs only; no change to the tool.

Fixes #2583

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:26:47 +09:00
Dividesbyzer0 2e9f641c2c fix(windows): detect installed CUDA toolkit on launch (#2639) 2026-06-15 20:26:07 +09:00
Dividesbyzer0 627a52ac44 fix(cookbook): shim Windows Store python3 alias (#2610) 2026-06-15 20:25:30 +09:00
RaresKeYandAlexandre Teixeira 397fce6e32 docs: add pull request review template (#3128)
* docs: add pull request review template

- add a reusable review structure with findings, validation, and hygiene sections

- document priority badges, intent labels, and expected finding fields

* docs: clarify review template usage

* docs: add small PR review path

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 20:23:13 +09:00
dependabot[bot] 10cc2295e5 chore(deps): bump the npm group with 2 updates (#3989)
Bumps the npm group with 2 updates: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) and [@antithesishq/bombadil](https://github.com/antithesishq/bombadil).


Updates `@anthropic-ai/sdk` from 0.98.0 to 0.104.1
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.98.0...sdk-v0.104.1)

Updates `@antithesishq/bombadil` from 0.3.2 to 0.5.0
- [Release notes](https://github.com/antithesishq/bombadil/releases)
- [Changelog](https://github.com/antithesishq/bombadil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/antithesishq/bombadil/compare/v0.3.2...v0.5.0)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.104.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@antithesishq/bombadil"
  dependency-version: 0.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 20:21:04 +09:00
Vishnu 933ec8fec9 fix(memory): reject ambiguous multi-object outputs during skill extraction (#3985) 2026-06-15 10:44:43 +00:00
Merajul ArefinandK M Merajul Arefin 8fe98cf471 feat(auth): add per-user admin promote/demote toggle (#3078)
* feat(auth): add per-user admin promote/demote toggle

Admin-only API and Users-tab control to grant/revoke admin rights; refuses to demote the last admin.

* fix(auth): restore pre-admin privilege restrictions on demotion

Promoting now stashes the user's privilege map (privileges_before_admin)
and demoting restores it instead of resetting to defaults, so a
promote/demote round trip can no longer broaden a restricted user's
access. Users without a stash (created as admin, or promoted before this
fix) still demote to DEFAULT_PRIVILEGES so a born-admin's stored all-True
map — including can_use_bash — can't survive demotion.

---------

Co-authored-by: K M Merajul Arefin <merajul.arefin@therapservices.net>
2026-06-15 10:44:27 +00:00
nubs 55b4a5e6ff fix(ui): restore all-edge modal snap zones (#2260) 2026-06-15 12:36:34 +02:00
dependabot[bot] 3c0e9fcb25 chore(deps): bump the actions group with 4 updates (#3990)
Bumps the actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-python](https://github.com/actions/setup-python), [actions/setup-node](https://github.com/actions/setup-node) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `actions/checkout` from 4.3.1 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.3.1...df4cb1c069e1874edd31b4311f1884172cec0e10)

Updates `actions/setup-python` from 5.6.0 to 6.2.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.6.0...a309ff8b426b58ec0e2a45f0f869d46889d02405)

Updates `actions/setup-node` from 4.4.0 to 6.4.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

Updates `github/codeql-action` from 3.36.0 to 4.36.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/03e4368ac7daa2bd82b3e85262f3bf87ee112f57...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 19:26:05 +09:00
dependabot[bot] d5de061656 chore(deps): bump the python group with 3 updates (#3991)
Updates the requirements on [markitdown](https://github.com/microsoft/markitdown), [pydantic](https://github.com/pydantic/pydantic) and [pydantic-settings](https://github.com/pydantic/pydantic-settings) to permit the latest version.

Updates `markitdown` from 0.1.5 to 0.1.6
- [Release notes](https://github.com/microsoft/markitdown/releases)
- [Commits](https://github.com/microsoft/markitdown/compare/v0.1.5...v0.1.6)

Updates `pydantic` to 2.13.4
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.0...v2.13.4)

Updates `pydantic-settings` to 2.14.1
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.0.0...v2.14.1)

---
updated-dependencies:
- dependency-name: markitdown
  dependency-version: 0.1.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: pydantic
  dependency-version: 2.13.4
  dependency-type: direct:production
  dependency-group: python
- dependency-name: pydantic-settings
  dependency-version: 2.14.1
  dependency-type: direct:production
  dependency-group: python
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 19:25:15 +09:00
dependabot[bot] 8b157f452c chore(deps): bump python from 3.12-slim to 3.14-slim (#3988)
Bumps python from 3.12-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 19:23:27 +09:00
Simon Guggisberg daec3604f3 fix: correct Three Jugs eval prompt answer (#2542) (#2544) 2026-06-15 19:21:39 +09:00
nubs e75a52efbb fix(notes): reset search filter on panel reopen so stale query doesn't hide notes (#2920) 2026-06-15 11:55:46 +02:00
Mazen Tamer Salah f28703adf6 fix(gallery): remove image file only after the delete commit succeeds (#2196)
delete_gallery_image() deleted the on-disk file before setting
is_active=False and committing. If that commit failed and rolled back,
the record stayed active but its file was already gone — a broken,
unviewable image (data loss).

Soft-delete and commit first, then remove the file best-effort, so a
missing or locked file can no longer 500 a delete that already succeeded
logically.

Adds tests/test_gallery_delete_file_ordering.py covering the
commit-failure (file kept) and success (file removed) paths.
2026-06-15 11:00:32 +02:00
Kfir Sadeh d8e7cc7053 feat(ui): add real-time diagnostic logs console (#974)
* feat(diagnostics): add admin-gated real-time diagnostics logs terminal UI

* feat(ui): resolve diagnostics logs feedback and optimize client-side caching

* feat(ui): resolve diagnostics logs feedback
2026-06-15 10:32:51 +02:00
Yohann Boniface f7e2d0c0b7 docs(readme): add packaging status (#2865)
This add a badge that sync with repology to showcase how the project is present within the different package manager (current only in the AUR)
2026-06-15 16:13:15 +09:00
Bright Larson Nanevie b5a7d5ccda fix(macos): rebuild incomplete venv instead of failing on re-run (#3106)
start-macos.sh guarded venv creation with `[ ! -d venv ]`, which trusts any
existing venv/ directory even when a prior run was interrupted before pip was
bootstrapped into it. Re-runs then failed with "No module named pip" and never
self-healed, contradicting the script's "safe to re-run" promise.

Validate that the venv has a working pip before reusing it, and rebuild it
otherwise.

Fixes #3105
2026-06-15 16:12:19 +09:00
Giuseppe Castelluccioandclochard04 f7a5047228 fix(memory): fall back to utility endpoint when import session is stale (#3428)
When a session ID is sent to POST /api/memory/import but that session no
longer exists in the DB, the previous code raised HTTP 404.  The import
endpoint only needs the session as an LLM-config source; the file being
imported has nothing to do with the session.  A fallback to the utility
endpoint (already used when no session_id is supplied at all) is correct
and safe.

The extract endpoint is intentionally left alone — it reads the session's
message history and therefore genuinely requires a live session.

Co-authored-by: clochard04 <clochard724@gmail.com>
2026-06-15 16:11:29 +09:00
Mostafa Eid 4ccb7c4890 fix(windowDrag): disable duplicate top-edge fullscreen snap (#3495)
windowDrag.js ran its own top-edge fullscreen system (cy <= SNAP_PX →
_enterFs()) independently of the tileManager.js snap zones, causing
duplicate/unexpected fullscreen behavior when dragging window chips
toward the top of the screen.

Hardcode enableFullscreen to false. tileManager.js remains the single
source of truth for fullscreen/maximize snap behavior and is untouched.
2026-06-15 16:10:40 +09:00
Caleb Clavin 1aa5ffb57c fix(cookbook): serve panel content unreachable when model card is expanded (#3479) 2026-06-15 16:09:24 +09:00
Hasn 0939983ddf pwa missing icons added (#428) 2026-06-15 16:00:13 +09:00
Achilleas90andAlexandre Teixeira ffc0f1dccc Harden CalDAV write-back with retries (#1193)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 15:59:31 +09:00
Syed Ali Rizvi 57646300a4 fix(security): encrypt CardDAV password at rest in settings.json (#1741)
* fix(security): encrypt CardDAV password at rest in settings.json

CardDAV password was stored in plaintext in data/settings.json, while
other secrets (email, CalDAV) are encrypted using src.secret_storage.

On read (_get_carddav_config): decrypt the password via decrypt().
On write (update_config): encrypt the password via encrypt() before
saving to settings.json.

decrypt() is a no-op on plaintext, so existing deployments upgrade
transparently on the first read after the next config save.

* test: add coverage for CardDAV password encryption

Nine tests covering:
- encrypt-on-save and decrypt-on-read round-trip
- encrypted value is stored with enc: prefix (plaintext absent from file)
- legacy plaintext passthrough
- CARDDAV_PASSWORD env var passthrough (not decrypted)
- empty password / no settings file
- double-save does not corrupt
- encrypt() idempotent on already-encrypted value
2026-06-15 15:58:14 +09:00
spooky f23e2e6ffb docs: add agent migration manifest helper (#3028)
* docs: add agent migration manifest helper

* fix: use stat+streamed hash for metadata-only archive scans

When include_content is false, skip reading full file content and
only stat+stream-hash for size and sha256. Avoids spurious skipped-
content warnings and keeps large-export previews fast and clean.

Closes review feedback on PR #3028.

* fix: skip symlinked migration inputs

* fix: stream archive traversal warnings

* feat: stage conversation threads in agent migration manifests
2026-06-15 15:57:33 +09:00
KYDNOandCursor 955455b797 fix(kimi): resolve Kimi Code API 403 errors and User-Agent restrictions (#3549)
* fix(kimi): resolve Kimi Code API 403 errors and User-Agent restrictions

Kimi Code subscription keys require a whitelisted coding-agent User-Agent to avoid access_terminated_error 403s. This adds User-Agent probing and caching for Kimi Code endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kimi): omit temperature for kimi-for-coding API calls

Kimi Code rejects any non-default temperature with HTTP 400, which broke deep research probes and low-temp LLM rounds.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:56:54 +09:00
Karthik Rajesh 674457384a feat(cookbook): surface Docker hardware visibility warnings (#3658) 2026-06-15 15:51:04 +09:00
Alexandre Teixeira 2cf8bd14ae test: add report-only order-sensitivity runner (#3982)
* test: add report-only order-sensitivity runner

* test: report cwd in order-sensitivity runner
2026-06-15 15:49:47 +09:00
pewdiepie-archdaemon 1fcec32a3c Cookbook/Serve: 'Install in Dependencies →' link in the runtime readiness note
When the backend (vllm / sglang / llama_cpp / diffusers) is missing on
the chosen serve target, the runtime-readiness note already flips red
and reads '<backend> missing on <host>.' but offered no fix path.

Append an accent-coloured link that calls openCookbookDependencies with
expandRecipe + the model's repo id, so one click switches to the
Dependencies tab, expands the right backend row's recipe panel, and
pre-selects the model so the user just hits Run.
2026-06-14 22:57:43 +09:00
pewdiepie-archdaemon 768bcb565a Cookbook/Dependencies: variant toggle now uses the agent/chat mode-toggle; Copy inside code
- Drop the 'Install via' label and the pill-tag variant buttons. The
  toggle is now the same sliding-pill mode-toggle used by the
  Agent/Chat selector in the chat input. Pip/uv on the left, Docker on
  the right, default = Pip/uv. CSS: extended .mode-chat::before's
  translateX(100%) rule to also fire on .mode-right so non-chat
  callers can use the same animation without claiming the chat-only
  class name.
- Copy button moves inside the <pre>: absolute-positioned at the
  top-right corner, icon-only, padding-right on the pre makes room.
  Matches the Setup-token copy pattern in the integrations form.
2026-06-14 22:54:38 +09:00
pewdiepie-archdaemon 63b4ad2e9c Cookbook/Dependencies: Pip/uv vs Docker variant toggle on recipe panel
Each recipe catalog entry now carries two variants:
  variants.pip    → uv pip install …
  variants.docker → docker pull <image>

A small 'Install via' pill row in the panel toggles between them
(default = Pip/uv per the user's preference). Switching variant or
changing the model re-renders the <pre> via _refreshRecipePre(); the
display text drops the 'source venv/bin/activate' prefix for Docker
since docker pull doesn't need a venv. Run honours the active variant
so picking Docker queues 'docker pull …' as the tmux task.
2026-06-14 22:47:26 +09:00
pewdiepie-archdaemon d70eb99a0d Cookbook/Dependencies recipes: install into configured venv, drop 'uv venv'
Recipes now hold ONLY the install command(s). The rendered <pre>
prepends a 'source <envPath>/bin/activate' line so the user sees a
paste-ready sequence; Run uses env_prefix (same path the Install
button uses) to activate the configured venv before the install
command, so the install lands in the existing environment rather
than a fresh .venv in whatever CWD the tmux task happens to start in.

- cookbook-deps-recipes.js: trim each recipe to its single pip command
- cookbook.js: _recipeDisplayText() prepends the activate context for
  display; pre's data-dep-recipe-install holds the raw install-only
  command list so Run knows what to send; Run builds env_prefix the
  same way _installDep does.
2026-06-14 22:45:12 +09:00
pewdiepie-archdaemon d44de3af43 Cookbook/Dependencies: populate recipe model picker from downloaded models
The recipe dropdown was a static catalog (MiniMax / Any vLLM model). Now
it lists every model already downloaded on the active server (the same
_cachedModelIds set the Launch tab + dl-dots already drive), plus an
'Other (generic …)' fallback. The change handler uses pickRecipe(backend,
modelId) to find the best match — MiniMax ids land on the MiniMax recipe,
everything else falls back to the generic install.

cookbook-diagnosis.js: openCookbookDependencies's pre-select logic now
matches by full option value (model id) instead of label substring, since
the dropdown values are full repo ids now.
2026-06-14 22:40:52 +09:00
pewdiepie-archdaemon 25dd94234c Cookbook/Launch: pre-flight backend install check, deep-link to Dependencies
Before the quickrun (Run) button fires /api/model/serve, ask the deps
API whether the chosen backend (vllm / sglang / llama_cpp) is actually
installed on the target server. If not:

- Toast: '<backend> not installed on <host>. Opening Dependencies …'
- Route the user into the Dependencies tab via the existing
  _openCookbookDependencies helper (now exported as
  openCookbookDependencies)
- Auto-expand the recipe panel for that backend
- Pre-select the user's model in the panel's picker so the right
  recipe is highlighted out of the box

The serve task is suppressed; the Run button is re-enabled. Once the
install task finishes in Running, the user clicks Run again.

cookbook-diagnosis.js: openCookbookDependencies takes an opts object
that, when expandRecipe is set, finds the row's caret and clicks it,
then matches a recipe label by model (currently only MiniMax has a
specific entry; the generic fallback stays selected otherwise).
2026-06-14 22:35:56 +09:00
pewdiepie-archdaemon 600fa6be8a Cookbook/Dependencies: per-backend recipe panel (vllm/sglang/llama_cpp)
Each row for vllm, sglang, llama_cpp now carries an expand caret that
opens an inline recipe panel below the row. The panel has:
  - 'Serving which model?' select populated from a new tiny catalog
  - <pre> code block showing the exact shell sequence for that pair
  - Copy: clipboard the commands
  - Run: launch the joined 'cmd1 && cmd2 && …' as a tmux task on the
    currently-selected deps server (same plumbing as Install)

New file: src/static/js/cookbook-deps-recipes.js — single source of
truth for the recipes. Seeded with MiniMax M2/M2.7 + a generic fallback
for each backend (all three use 'uv venv → source .venv/bin/activate
→ uv pip install ... --torch-backend auto', the recipe the user
pasted). Adding model-specific recipes is now a one-entry edit.

Next commit: Launch-tab pre-flight that intercepts the serve click
when the backend isn't installed and deep-links into this panel.
2026-06-14 22:33:49 +09:00
pewdiepie-archdaemon 781a3ee829 Cookbook: rename 'Run' tab → 'Launch' (cookbook.js:1865) 2026-06-14 22:23:38 +09:00
pewdiepie-archdaemon a9de61771a Cookbook serve panel: tighten vertical spacing inside Advanced fold
Rows inside the Advanced details were inheriting the standard
6px row-gap from .hwfit-serve-row (used to give the Core knobs
some breathing room). Inside Advanced — where the rows are
mostly single-line dropdowns — that read as half a row of empty
space between every pair.

Now inside Advanced only:
- grid row-gap drops to 4px
- label → control margin-top drops to 1px (was 2px)
- checks row gap also drops to 4px

Outside Advanced (Core, etc.) the original spacing stays.
2026-06-14 09:14:31 +09:00
pewdiepie-archdaemon 9873f9b44f Cookbook diagnosis: fold message + suggestion into the toolbar row
Was rendering as a separate body block below the Copy/× toolbar.
Now the diagnosis message and the suggested-action text sit inline
on the left of the toolbar, with Copy and × pinned to the right —
reads as one self-contained header strip instead of stacked rows.
2026-06-14 09:03:58 +09:00
pewdiepie-archdaemon 09a82852c0 Cookbook tmux: history-limit 100k + crash-watchdog grabs 2000 lines
The tmux default 2000-line scrollback was getting blown out by
long vLLM tracebacks (DeepSeek-V4-Flash launch crash had the root
cause scrolled off; the user saw only the tail "See root cause
above"). Bumped:

- tmux server history-limit to 100000 at session creation (prepended
  to each tmux new-session command so both local + ssh remote inherit
  the larger scrollback)
- crash-watchdog capture-pane from -S -200 → -S -2000 so the
  diagnosis includes the actual exception line
2026-06-14 09:02:04 +09:00
pewdiepie-archdaemon 4074e77d93 Cookbook: auto-set KV cache to fp8 for DeepSeek V3/V4/R1 MoE families
These models OOM on --kv-cache-dtype auto (≈bf16) at any usable
context with current tensor-parallel layouts. _detectModelOptimizations
now seeds opts.kvCacheDtype='fp8' for them, and the serve panel's KV
Cache select picks that up as the default unless the user has a
saved override on this skill.
2026-06-14 08:57:29 +09:00
pewdiepie-archdaemon d3944be1be Cookbook: detect DeepSeek V4+ as MoE so Expert Parallel + Spec show
The DeepSeek branch in _detectModelOptimizations matched only V3
and R1 literally. DeepSeek-V4-Flash (and future Vx / Rx) didn't
hit any branch, so the Expert Parallel checkbox + Speculative
defaults never surfaced in the Run panel. Widened to a regex that
catches v3/v3.1/v4/v5/v10+ and r1/r2/… for both the expert-parallel
flag and the MTP speculative defaults.
2026-06-14 08:51:57 +09:00
pewdiepie-archdaemon ce964b9a00 Cookbook Run panel: drop ‹ › arrows on Speculative tokens, narrow to 44px input
The +/- step buttons next to the Speculative tokens count read as
clutter for a 1-10 single-digit input — the native number-input
spinner + manual typing is enough. Reduced the input width to 44px
so it sits tight next to the method dropdown.
2026-06-14 08:50:20 +09:00
pewdiepie-archdaemon 1d7d9c5e9c Cookbook deps: drop the manual vLLM install block + Run handlers 2026-06-14 08:49:20 +09:00
pewdiepie-archdaemon adac89c8e2 Cookbook deps: NVIDIA vs AMD ROCm-aware vLLM install commands
Reads the last hwfit scan's backend (window._hwfitSystemCache.backend)
and picks the right vLLM install path per vendor:

- NVIDIA/CUDA (default)
  - uv:     uv pip install -U vllm --torch-backend auto
  - docker: docker pull vllm/vllm-openai:latest
- AMD/ROCm
  - uv:     uv pip install -U vllm --torch-backend rocm
  - docker: docker pull rocm/vllm-dev:main

The <pre> previews are re-painted on render to match what Run will
actually launch, and the confirm dialog tags the backend so the user
knows what they're committing to.
2026-06-14 08:46:58 +09:00
pewdiepie-archdaemon 65a2e51af8 Cookbook deps: convert manual install snippets to Run buttons
Was just a copy-paste reference. Each row now has a Run button that
launches the command as a tmux task on the currently-selected deps
server (same path Reinstall already uses) — Odysseus does the work,
the user watches progress in the Active tab. Dropped the plain
pip option since the existing per-package Install button already
covers it; kept uv (recommended) and Docker pull as the two
alternatives.
2026-06-14 08:45:43 +09:00
pewdiepie-archdaemon 04a97adbb3 Cookbook: Extra args under Reasoning/Spec + manual vLLM install hints in Dependencies
- Moved "Extra args" out from above the vLLM advanced checks
  (Reasoning Parser, Speculative, MoE Env) to AFTER them, so it
  reads as "after the advanced toggles, anything else".
- Added a collapsed "Manual install (vLLM)" details block to the
  Dependencies tab description with three copy-paste recipes:
  uv venv + uv pip (recommended), plain pip, and docker pull
  vllm/vllm-openai:latest. Useful when the in-app Install button
  can't run (offline target, custom torch backend, etc).
2026-06-14 08:43:10 +09:00
pewdiepie-archdaemon 8829ae2675 Cookbook serve: nudge runtime-note dismiss × up 4px (top:-4 → -8) 2026-06-14 08:33:14 +09:00
pewdiepie-archdaemon 09a1718103 Skills test: set explicit max_tokens=4096 instead of 0
max_tokens=0 made stream_agent_loop omit the param entirely, which
on some OpenAI-compat upstreams (DeepSeek in the report) meant the
model defaulted to a very short or zero-token completion — the user
saw "the model returned an empty" even though normal chat with the
same model worked (chat sends its preset's max_tokens). Match the
chat default.
2026-06-13 23:09:15 +09:00
pewdiepie-archdaemon f03a9e79a7 Settings: tighten endpoint logo+select gap + align fallback trash right
- .adm-model-logo + .settings-select { margin-left: -4px } pulls
  the select 4px closer to its logo chip so the row reads as one
  unit instead of having an obvious gap between the icon and the
  dropdown.
- Fallback-row selects get flex:1 so the trash-can sits flush
  against the right edge of the row — matching the right edge of
  the Endpoint and Model selects in the rows above the fallback
  list (was rendering tight to the model select's content width).
2026-06-13 23:04:27 +09:00
pewdiepie-archdaemon bb66914b1e Settings: clamp logo SVGs to 18px chip + endpoint dropdown gets logo
Provider SVGs in providers.js declare only viewBox (no width/height),
so when injected into the 18×18 logo chips they fell back to the
browser default of 300×150 and blew out the row.

- CSS: SVGs inside settings logo chips (`span[id$="-logo"]`,
  the 18px wrappers in fallback rows) now stretch to 100%/100% of
  their container.
- Added matching `-logo` chip next to the Endpoint dropdowns in
  Default Chat Model and Utility Model cards.
- New `_syncEndpointLogo` helper mirrors the selected endpoint
  option's text label through providerLogo() (the select value is
  a UUID and wouldn't match anything otherwise), and
  `_fillEndpointSelect` calls it on each render.
2026-06-13 23:00:16 +09:00
pewdiepie-archdaemon 8053d6a50a Skills: dedupe by name + move Select to 2nd in kebab menu
- The API occasionally returns the same skill twice (built-in
  shadow vs user copy, or a write/read race) which made the
  duplicate-detector tag BOTH copies as the "recommended" keeper
  (the find-skills card showing duplicate #1 twice).  Loading now
  filters out repeats by lowercased name before render.
- Reordered the per-skill kebab menu: Publish/Unpublish → Select
  → Edit → Test → Audit → Delete. Select previously sat at the
  bottom; lifting it next to Publish puts the bulk-mode entry
  point with the other bulk-style action.
2026-06-13 22:49:48 +09:00
pewdiepie-archdaemon 7cbf5a2c00 Research panel: connect Settings toggle to body + lift textarea 4px
- When Settings is expanded, the toggle bar's bottom radius/border
  flattens and merges into the row below (zero gap, softer
  top-border on the body) so the row visually reads as the toggle's
  open-state content instead of an unrelated card below it.
- .research-query margin-top trimmed from 6px to 2px (lifts the
  textarea ~4px closer to the description line above).
2026-06-13 22:41:49 +09:00
pewdiepie-archdaemon 0895c70fc9 Research: drop visible category row, move Format override into Settings
Auto handles 90%+ of cases — the row of category buttons was
visual noise on the main panel. Now:
- Removed the .research-category-row from above the textarea.
- Added a Format <select> inside Settings (next to Rounds) with
  Auto / Product / Compare / How-to / Fact-check options. Default
  is Auto, same as before.
- Updated all the JS that read .research-cat.active / data-cat to
  read #research-category.value instead (_saveSettings, _readSettings,
  _resetCategoryToAuto, _editJob, _restoreSavedSettings).

Same wire to the backend — settings.category still carries through.
2026-06-13 22:38:20 +09:00
pewdiepie-archdaemon 16c41612ca Research panel: ? hint chip on Rounds + cogwheel icon on Settings toggle 2026-06-13 22:34:12 +09:00
pewdiepie-archdaemon 7ef3e353c6 Research panel: push #research-stats count chip down 4px 2026-06-13 22:33:09 +09:00
pewdiepie-archdaemon 10b9e6b81f Research panel: accent-tint the research SVG next to the title 2026-06-13 22:32:51 +09:00
pewdiepie-archdaemon 360ce696e0 Research panel: pull "past runs in Library" hint up 4px (top:-4px) 2026-06-13 22:32:30 +09:00
pewdiepie-archdaemon 0548d335d4 Research panel: move the research SVG next to the Research title 2026-06-13 22:31:41 +09:00
pewdiepie-archdaemon 79d55b46a6 Research panel: pull loop-agent description line up 4px (margin-top 6→2) 2026-06-13 22:31:09 +09:00
pewdiepie-archdaemon 93c0529e00 Research panel: "Past runs in Library" hint inline with loop agent line
Was rendering on its own row under "Multi-step web research with an
LLM-in-the-loop agent". Now appended to that same flex-wrap line as
"— past runs in Library, Research" so the header section stays one
visual block instead of two.
2026-06-13 22:29:57 +09:00
pewdiepie-archdaemon a29c2b25d0 Research panel: Past Research library hint goes inline with section title
Was rendering on a second row below the "Past research" header,
inflating it to two rows. Now appended to the title span as a small
inline chip — "Past research — all in Library, Research" — keeping
the header at one row. Same click → close panel + open Library tab.
2026-06-13 22:27:49 +09:00
pewdiepie-archdaemon 654f9f82c7 Cookbook: don't auto-fold Direct Download from inside its own body
The capture-phase scroll listener was firing for scrolls anywhere
in the modal — including the Trending models list, which lives
inside the Direct Download fold body. Scrolling that list was
auto-folding the section that contains it.

Bail early if the scroll target is the fold body or a descendant —
the section only folds on scrolls in sibling scrollers (.cookbook-body,
.hwfit-list, .modal-content).
2026-06-13 22:26:04 +09:00
pewdiepie-archdaemon 45b3cd15df Research: rotate textarea placeholder through 10 example queries
Each time the panel opens we pick a random entry from a list of 10
diverse research prompts (history, tech, food, science, fact-check,
how-to) so the textarea hint feels fresh and shows the breadth of
queries the tool handles instead of always nudging toward the same
Odysseus example.
2026-06-13 22:23:58 +09:00
pewdiepie-archdaemon d006e38a2f Cookbook task menu: merge Edit actions + group items into sections
- Removed standalone "Edit cmd & relaunch" — "Edit in serve panel"
  renamed to "Edit & relaunch" and is now the single edit entry.
  Tooltip notes that the raw cmd is still editable inside the panel.
- Tagged each item with a group (run / edit / endpoint / copy /
  danger) and renderer inserts a thin divider whenever the group
  changes, so the menu reads as visual blocks instead of one long
  list.
2026-06-13 22:18:13 +09:00
pewdiepie-archdaemon 438db357ff Cookbook Active tab: header → Active, Reconnect → Reconnect tmux, section dividers
- Header h2 inside the Active group now says "Active" (matches
  the renamed tab) instead of "Running".
- Both context-menu Reconnect entries (the normal one and the
  recover-from-vanished-process fix) say "Reconnect tmux" so the
  user knows what the action actually does.
- Sibling cookbook-server-section-* blocks inside the Active group
  get a top divider + 14px gap so transitions between server
  groups (local / remote-host / etc) read clearly.
2026-06-13 22:11:45 +09:00
pewdiepie-archdaemon 3ff4eb5519 Cookbook: _gpuToggleTotal updates on every scan, not just the first
Previously the global GPU-toggle total was set once and never
overridden, so a first scan on the local 1-GPU container left
the Run-panel GPU button row stuck on GPU 0 even after switching
to a 4-GPU remote host. Now any scan returning a positive total
updates the binding; zero/missing values still don't clobber a
known-good count (no flicker during in-flight re-probes).
2026-06-13 22:06:35 +09:00
pewdiepie-archdaemon f34cb42b07 Cookbook: runtime readiness text moves to model title chip
Mirrored the panel's runtime readiness note into a small chip
appended to the .memory-item-title at the top of the expanded
serve card. The in-panel note becomes a hidden source-of-truth.

This way the "vLLM ready on … : vLLM CLI: …; python package:
vllm 0.22.0" status sits inline with the model name where the
user is already looking, instead of buried below the toolbar row.
2026-06-13 21:57:21 +09:00
pewdiepie-archdaemon ac4de93928 Cookbook: rename Serve tab → Run (label only, data-backend stays Serve) 2026-06-13 21:55:24 +09:00
pewdiepie-archdaemon 6763fe4d44 Cookbook GPU/RAM toggle: default to whichever pool has more capacity
On initial render, compare total_ram_gb vs gpu_vram_gb — if RAM is
the larger pool, pre-select the RAM (count=0) button instead of the
max-GPU button. Boxes with more system RAM than VRAM (low-VRAM
GPU + lots of system memory, or CPU-only servers with a small
adapter) now open on the dominant pool.
2026-06-13 21:40:40 +09:00
pewdiepie-archdaemon 44a60c1261 Cookbook toolbar: move Search next to Standard, Engine/Quant/Context to right
New order: [Standard ▾] [Search ............] [Engine] [Quant] [Context]
so the two primary picks (type + free text) sit together at the
left, with the more advanced filters lined up to the right.
2026-06-13 21:30:05 +09:00
pewdiepie-archdaemon f09f606bec Cookbook fold: smooth max-height + opacity transition
display:none toggle was instant and felt jarring during auto-fold/
auto-expand. Swapped to a CSS class `.is-folded` that transitions
max-height (0 ↔ 1200px) and opacity (0 ↔ 1) over ~280ms with ease,
so both manual chevron clicks and the scroll-driven toggles slide
in/out smoothly.
2026-06-13 20:14:34 +09:00
pewdiepie-archdaemon e6349c016e Cookbook auto-fold: auto-expand when scrolling back to top
scroll handler now tracks per-target scrollTop via WeakMap. Downward
scroll on any scroller in the cookbook modal folds Direct Download;
scrolling back to top (scrollTop <= 0) unfolds it. Manual chevron
clicks still win — they persist to localStorage; auto-toggles
don't, so the user's last explicit pick survives reload.
2026-06-13 20:12:30 +09:00
pewdiepie-archdaemon e630605aef Cookbook auto-fold: capture-phase scroll listener catches hwfit-list
IntersectionObserver missed the case because scrolling inside the
nested .hwfit-list (max-height:52vh own scroller) doesn't move the
header out of view at all. The user wants any downward scroll in
the scan/download area to fold Direct Download.

Switched to a capture-phase scroll listener on #cookbook-modal that
catches every scroll event from any nested scroller (.hwfit-list,
.cookbook-body, .modal-content). Folds only on downward scrolls so
scrolling back up doesn't keep re-folding.
2026-06-13 20:10:46 +09:00
pewdiepie-archdaemon 74e563dabc Cookbook auto-fold: use IntersectionObserver to catch any scroll source
The scroll listener on .cookbook-body never fired — the user is
likely scrolling inside the nested .hwfit-list (max-height:52vh)
which doesn't bubble to its parent. IntersectionObserver fires
whenever the Direct Download header crosses the viewport edge
regardless of which container moved.

Folds only when boundingClientRect.top < 0 (header pushed up past
the top) so modal close / detach doesn't trigger it.
2026-06-13 20:07:32 +09:00
pewdiepie-archdaemon ae0b29af3d Cookbook auto-fold: target the actual scroll container (.cookbook-body)
Previous .modal-body / .cookbook-content lookup matched neither the
desktop scroller (.cookbook-body) nor the mobile one (#cookbook-modal
.modal-content), so the scroll listener was attached to document.body
and never fired. Walk up to whichever scroller actually exists.
2026-06-13 20:05:33 +09:00
pewdiepie-archdaemon d68c75a82c Cookbook: auto-fold Direct Download when its header scrolls past top
Added a scroll listener on the parent .modal-body / cookbook-content
that folds the Direct Download body once its h2 header has scrolled
above the container's top edge. Frees the viewport for the Scan
section below while leaving the chevron clickable to expand again.

Auto-fold doesn't write to localStorage (only manual clicks do)
so the user's last explicit preference still wins on reload.
2026-06-13 20:03:14 +09:00
pewdiepie-archdaemon a615f7f786 Cookbook Trending: drop ↻ refresh button (trending list reloads on toggle) 2026-06-13 20:01:54 +09:00
pewdiepie-archdaemon 0808de0b3b Cookbook Trending: shrink trending-up icon 18px → 15px 2026-06-13 20:01:26 +09:00
pewdiepie-archdaemon aba3a7ae43 Cookbook Trending: accent trending-up icon + chevron on right + larger row
- Added a trending-up (market-up) SVG before the label, tinted
  accent so the section reads as "what's hot".
- Chevron ▸ moved from the left to the right side of the toggle
  row (still rotates via the existing CSS).
- Bumped the toggle row taller (26→34px) with 13px font + 18px
  icon so the section header has more presence.
2026-06-13 19:59:41 +09:00
pewdiepie-archdaemon fa3adca5fc Cookbook Trending: HF link pill tinted accent
Inside #cookbook-hf-latest-list the HF ↗ link is the row's main
affordance, so tint it accent instead of the muted-gray default
used elsewhere.
2026-06-13 19:58:53 +09:00
pewdiepie-archdaemon f78084c230 Brain cards 32px tall + Trending tab up 8px + drop hwfit Rescan
- Brain admin-card header rows get min-height:32px so cards with
  toggles and cards without (Inject Skills) align.
- Cookbook Trending models tab nudged up 8px (top:-3 → -11).
- Removed the ↻ RESCAN button in hwfit toolbar; manual EDIT still
  available and auto-probe runs on container restart.
2026-06-13 19:56:22 +09:00
pewdiepie-archdaemon 7004e1de7b Brain settings: reorder + AI star icons on each toggle
- Reordered: Auto-extract memories → Auto-extract skills →
  Auto-approve skills → Inject Skills (Auto-approve now above
  Inject so all three AI-driven toggles cluster together)
- Added accent-tinted star icon (the AI star) before:
  Auto-extract memories, Auto-extract skills, Auto-approve skills
- Inject Skills gets a neutral down-arrow-into-line icon (it's
  configuration, not AI work)
2026-06-13 19:36:10 +09:00
pewdiepie-archdaemon e2a30c0600 Skills: Audit on left + accent star, Select w/ dot/X icon swap
- Reordered the toolbar so Audit sits left of Select (matches the
  brain memories layout where bulk actions live before Select)
- Renamed "Audit all" → "Audit"
- Star icon in Audit now tinted with var(--accent, var(--red))
- Select button gets the same dot/X SVG swap used in brain
  memories (dot in idle state, X when bulk-select mode is active)
2026-06-13 15:47:16 +09:00
pewdiepie-archdaemon eb0abe7c90 Doc compose: Cc toggle and X close up 1px (top:calc(50% + 2px) → +1px) 2026-06-13 14:59:46 +09:00
pewdiepie-archdaemon c822d34ce6 Revert Chat/Agent mode tag in message header
Per user report — the tag's mode metadata coincided with a
500 error on agent mode (especially on mobile). Removing the
UI tag, the chat.js writes of metadata.mode, and the CSS pill
so agent mode posts work cleanly again.

Touches:
- chat.js: drop _sendMode capture + meta.mode writes (user + assistant)
- chatRenderer.js: roleTimestamp() back to a single (when) arg, drop
  the .role-mode-tag append; updated three call sites
- style.css: dropped .role-mode-tag and .role-mode-agent rules
2026-06-13 11:39:32 +09:00
pewdiepie-archdaemon 0889eb4e01 Doc compose: accent prefix labels for each field + Cc btn up 2px
- Each input now has a sibling .email-field-prefix span (To / Cc /
  Bcc / Subject) absolute-positioned at the left edge in the
  accent color. Inputs get padding-left:44px (64px for Subject)
  so typed text doesn't slide under the prefix.
- Placeholders shrink back to just the example so only the
  prefix gets the accent color, not the example text.
- Cc toggle moved another 2px up (calc(50% + 4px) → calc(50% + 2px)).
2026-06-13 09:35:21 +09:00
pewdiepie-archdaemon 77f00eeab1 Doc compose: accent-tint the To/Cc/Bcc placeholder hints 2026-06-13 09:33:28 +09:00
pewdiepie-archdaemon 86daf254cf Doc compose: Cc toggle up 4px (8→4), X close up 2px (4→2) 2026-06-13 09:33:03 +09:00
pewdiepie-archdaemon 9ea3a250db Doc compose: drop field labels, expand placeholders with examples
- Removed the <label>To/Cc/Bcc/Subject</label> elements — they
  doubled what the placeholder said.
- Placeholders now carry both the field name AND an example so an
  empty input still tells the user what to type:
    To  recipient@example.com
    Cc  cc@example.com, example2
    Bcc  bcc@example.com
    Subject
2026-06-13 09:29:20 +09:00
pewdiepie-archdaemon c537d2b95c Doc compose Cc/Bcc X: nudge 4px down (top:50% → top:calc(50% + 4px)) 2026-06-13 09:28:24 +09:00
pewdiepie-archdaemon f538da9a8e Doc compose: X close button inside Cc and Bcc fields
Adds a per-field X (24x24 SVG, opacity 0.4 → 1 + accent on hover)
absolute-positioned at the right edge of each Cc/Bcc field. Click
hides both rows, clears their inputs, and restores the Cc opener
on the To row. Inputs get padding-right:32px so the close button
doesn't overlap typed text.
2026-06-13 08:40:37 +09:00
pewdiepie-archdaemon 015aeb1fab Doc compose Cc toggle: another 4px down (4 → 8) 2026-06-13 08:39:03 +09:00
pewdiepie-archdaemon 0d27480719 Doc compose Cc toggle: nudge 4px down (top:50% → top:calc(50% + 4px)) 2026-06-13 08:37:38 +09:00
pewdiepie-archdaemon 81a9a1fed3 Doc compose Cc toggle: vertically center inside To field
Was `top: calc(50% + 4px)` which left the button 4px below the
true vertical center of the input — visibly misaligned. Dropped
the +4 offset so the toggle anchors at top:50% / translateY(-50%)
and tracks the input's center.

Also removed the redundant base rule's position:relative + top:2px
nudge — it was being overridden by the more-specific
.email-field .email-cc-toggle absolute positioning anyway.
2026-06-13 08:12:03 +09:00
pewdiepie-archdaemon a01ca5a0a1 Email reminders: "Note" picks open a write-your-own-text modal
- Renamed "Note (no timer)" → "Note".
- Clicking it now opens a small modal with a textarea + Save/Cancel.
- The typed text becomes the todo item; due_date is omitted so no
  timer fires. Esc cancels; Cmd/Ctrl+Enter saves.
2026-06-13 08:09:37 +09:00
pewdiepie-archdaemon 3239430996 Email reminders: add "Note (no timer)" option
Re-adds the timer-less note path next to the time-based presets.
Picking it POSTs the same payload but omits due_date so the entry
lives in notes as a plain reply todo with no reminder firing.
Toast: "Reply note saved" instead of "Todo reminder set for …".
2026-06-13 08:06:53 +09:00
pewdiepie-archdaemon 65ead1f799 Email library: reset select-mode + selectedUids on open
Was sticking on toggled-on state if the user closed the library
while in select-mode — reopening showed the Cancel/X toggle even
though no emails were selected. Force-reset state._selectMode and
state._selectedUids in openEmailLibrary so each open starts fresh.
2026-06-13 08:03:10 +09:00
pewdiepie-archdaemon 6cc45a4f77 AI reply: 1st click shows cached, 2nd click clears + opens menu
Correct behavior:
1. Cached draft + first click → opens the cached reply
2. Cached draft + second click → clears the cache and opens the
   Fast/Full + context menu so the user can request a fresh draft
3. No cache → opens the menu directly

Per-button shownOnce dataset tracks the first-click state so the
second click triggers the menu instead of replaying the cached
reply again.
2026-06-13 08:01:27 +09:00
pewdiepie-archdaemon f6c4c9a67c AI reply always reopens menu + Cc toggle 2px down in doc compose
- AI reply: removed the cached_ai_reply shortcut so clicking the
  button always reopens the Fast/Full + context menu. Lets the user
  ask for a fresh draft (with new steering) instead of being locked
  into the cached one.
- .email-cc-toggle gets position:relative + top:2px so it
  baseline-aligns with the To: field chips next to it in the
  document email compose.
2026-06-13 07:59:41 +09:00
pewdiepie-archdaemon 10a25f5959 Email library: Select button matches brain memories (dot↔X swap)
- Initial button: dot-in-circle SVG + "Select" label
- After click (select-mode on): X SVG + "Cancel" label + .active class
- Same SVG glyphs as memory.js so the two pages feel consistent.
Hooked into the toolbar Select toggle AND the bulk-bar Cancel button
so both reset the icon state.
2026-06-13 07:58:24 +09:00
pewdiepie-archdaemon a57327c13f Email reader cluster: solid bg in wrapped 2-row mode to stop body bleed-through 2026-06-13 07:32:54 +09:00
pewdiepie-archdaemon 37e49246a6 Email reader: To/Cc expand as floating panel instead of inline reflow
When the chevron opens the details, the To/Cc rows pop up as an
absolutely-positioned panel anchored to the bottom of the meta
block — with bg, border, rounded corners and a shadow. Nothing
in the rest of the header reflows: From row stays put, the action
cluster stays put, the email body content stays put. This kills
all the height-/spacing-jump quirks the inline-expanded design
was fighting.
2026-06-13 07:30:30 +09:00
pewdiepie-archdaemon 0351e5e166 Revert "Email reader meta: full chip names + locked-in From/To/Cc labels"
This reverts commit 98c05dd08d.
2026-06-13 07:26:24 +09:00
pewdiepie-archdaemon 98c05dd08d Email reader meta: full chip names + locked-in From/To/Cc labels
- .email-reader-meta .recipient-chip drops max-width and overflow
  truncation so the full name renders in each chip. The parent
  .recipient-chips span already has overflow-x:auto, so users can
  swipe horizontally to reveal any chip whose tail is clipped off
  the right edge of the row.
- Strong (From: / To: / Cc:) labels get explicit white-space:nowrap
  + flex-shrink:0 so they never truncate even when the row is
  squeezed to its minimum width.
2026-06-13 07:25:02 +09:00
pewdiepie-archdaemon 4811af7ab2 AI reply menu: viewport-aware placement on mobile
- Horizontal: max-width and left already clamped to viewport-16.
- Vertical: prefer below the button, but flip ABOVE if there's
  more space there (e.g. button near the bottom of the viewport).
- max-height clamped to viewport-16 with overflow:auto as a final
  guard so the menu can never extend past the screen edge.
2026-06-13 07:21:18 +09:00
pewdiepie-archdaemon ba17829202 AI reply menu: Fast/Full sit below the context textarea as confirm
Dropped the two-step (pick mode → context → OK) flow. Now the
context textarea is at the top of the popover and Fast (left) /
Full (right) sit below as the confirm buttons themselves — they
fire the draft with whatever's currently in the textarea (empty
= no steering).
2026-06-13 07:16:26 +09:00
pewdiepie-archdaemon 8f696064d5 AI reply menu: outside-click closer ignores clicks inside the menu
The document-level capture listener was closing the popover on
ANY click — including clicks inside the context textarea, which
made it impossible to focus the input. Replaced with an inline
handler that bails when the click target is inside the menu.
2026-06-13 07:15:44 +09:00
pewdiepie-archdaemon 3819a23344 AI reply menu: click Fast/Full → context input → OK
Restructured flow:
1. Click Fast or Full → reveals an optional context textarea
   ("Add context (optional)") below
2. Type optional steering note or leave blank
3. Click OK → triggers the draft with the chosen mode + note

Dropped the standalone … note-toggle button — the textarea is now
gated on picking a mode, which makes it easier to discover.
2026-06-13 06:59:10 +09:00
pewdiepie-archdaemon cedc38fee8 AI reply menu: drop draft sub-buttons + viewport-clamp on mobile
- Removed the conditional Draft fast / Draft full buttons. Note
  textarea is always-on via the … toggle, and whatever's in it
  is picked up by the existing Fast / Full buttons as noteHint.
- Clamped the popover max-width and left position to
  Math.min(220, viewport-16) + 8px margin so the (now wider) menu
  doesn't spill off the right edge on narrow mobile screens.
2026-06-12 23:41:46 +09:00
pewdiepie-archdaemon 198af4709d AI reply menu: add … note input to steer the draft
Top row keeps Fast / Full + a new horizontal-dots button. Clicking
the dots reveals a textarea ("e.g. reply nicely but say no"); as
soon as text is in it the panel shows Draft fast / Draft full
buttons that pass the note through as noteHint to the AI reply
endpoint. Empty textarea hides the draft buttons so the user only
gets the steered draft when they've actually typed direction.
2026-06-12 23:39:05 +09:00
pewdiepie-archdaemon 696ff78302 Mobile sidebar: force opaque background to stop chat model picker bleed-through
Firefox mobile rendered the backdrop-filter:blur + var(--panel)
combination on the slide-out sidebar as semi-transparent, so the
chat input bar's selected-model label (e.g. "minimax") was
visible behind the drawer. Force background:var(--panel) and
backdrop-filter:none inside the mobile @media block.
2026-06-12 23:37:39 +09:00
pewdiepie-archdaemon f2da86b455 Email reader mobile: kill background gradient + padding-left too
The left-edge gradient fade was likely the source of the perceived
shadow under the icons on mobile. Forced background:none and the
matching padding-left:0 on mobile so the cluster reads as bare
icons without any soft edge.
2026-06-12 23:26:01 +09:00
pewdiepie-archdaemon 5212758698 Email reader mobile: force-disable overlay box-shadow with !important 2026-06-12 23:19:53 +09:00
pewdiepie-archdaemon 9e73912d24 Email reader mobile: drop overlay shadow + lift action cluster 1px more 2026-06-12 23:17:32 +09:00
pewdiepie-archdaemon 6d328b1ad7 Email library: title also shifts 4px right in expanded card view
Was only the date moving — the expanded card had a more-specific
`padding: 4px 0 6px` shorthand on the title row that zeroed out
the padding-left from my earlier nudge. Added the expanded-card
selector to the padding-left:4px rule so the title now lines up
with the meta line in both list and expanded states.
2026-06-12 21:50:50 +09:00
pewdiepie-archdaemon 27c92caee8 Email library: nudge card subject + date line 4px right 2026-06-12 21:41:28 +09:00
pewdiepie-archdaemon 85966881d3 Email reader: lift action cluster 2px more (-7px → -9px margin-top) 2026-06-12 21:30:38 +09:00
pewdiepie-archdaemon dc170b1f58 Email reader: From row no longer wraps label onto its own line
Was using flex-wrap:wrap on the From row, which let the chip span
flip onto a new row below From: when the available width briefly
dropped — then snap back as the chip span's overflow-scroll kicked
in. Switching to flex-wrap:nowrap keeps the label glued to the
chip; the chip span shrinks/scrolls horizontally instead.
2026-06-12 10:26:48 +09:00
pewdiepie-archdaemon 37269fd96a Email reader docked: always show To/Cc, hide chevron toggle
In docked mode the header already reserves vertical space for the
absolute action cluster, so the To/Cc details fit without any
height tradeoff — force [hidden] open and hide the chevron toggle
so the recipients are always visible there.
2026-06-12 10:25:42 +09:00
pewdiepie-archdaemon e832133e47 Email reader docked: +2px more between From/To and To/Cc (now 4px each) 2026-06-12 10:24:25 +09:00
pewdiepie-archdaemon 51a41c0c30 Email reader docked: uniform 2px spacing between From / To / Cc
Was From→To = 0 (meta gap 2 + details margin-top 0) while To→Cc
was 6 (details gap). Set details gap to 2 in docked too so all
three meta rows have the same vertical distance. Dropped the
per-row margin-top:4 docked override since spacing now comes
entirely from gaps.
2026-06-12 10:20:11 +09:00
pewdiepie-archdaemon 8b8ec7fb1d Email reader: To/Cc details down 2px more (margin-top 2 → 4px) 2026-06-12 10:19:03 +09:00
pewdiepie-archdaemon 8f4747b1ff Email reader: nudge To/Cc details down 2px (margin-top 0 → 2px) 2026-06-12 10:18:09 +09:00
pewdiepie-archdaemon be7b3d796c Email reader: drop 2-row wrap breakpoint from 600px to 450px 2026-06-12 08:09:58 +09:00
pewdiepie-archdaemon 760c8ef72c Email reader docked: stretch meta so icons land right edge
Docked header is flex-direction:column, and the base
align-items:flex-start was sizing the meta to its chip width and
parking it at the left — the absolute cluster's right:0 then
landed at the meta's right edge in the middle of the pane.
align-items:stretch makes meta fill the header width so right:0
hits the actual right edge.
2026-06-12 08:02:53 +09:00
pewdiepie-archdaemon 3c4fb62d3a Email reader: docked uses same icon layout as undocked
Dropped the docked-specific overrides (cluster flowing below meta,
padding-right:0, header min-height:0). The same container-query
rules drive both: cluster floats top-right and wraps to 2 rows
when the reader width crosses 600px, snaps to overlay below 380px.
Docked pane width is just another container width.
2026-06-12 07:58:17 +09:00
pewdiepie-archdaemon cc86c3dd04 Email reader: +4px breathing room under wrapped 2-row cluster (92→96px) 2026-06-12 07:52:31 +09:00
pewdiepie-archdaemon 32898a68eb Email reader: header grows on wrap + no slide-down at overlay break
1. Moved the min-height from .email-reader-header to .email-reader-meta
   (92px) inside the <600 container query. Targeting the container
   itself in its own @container rule was flaky; using a descendant
   that affects the parent's intrinsic height works reliably.
2. Dropped the margin-top:0 reset on the cluster in the <380 overlay
   rule — that was clearing the base -7px lift and sliding the
   cluster ~7px downward at the breakpoint. Now both states use the
   same -7px lift so the visual position is stable across the
   transition.
2026-06-12 07:50:48 +09:00
pewdiepie-archdaemon 55e438d18c Email reader: prune competing rules from grid-era refactor
Dropped the @media(769px) from-row min-height + align-items:center
and the strong > top:-2px nudge — leftovers from the grid layout
that were forcing extra height and label offsets the block-flow
meta doesn't need.

Consolidated docked overrides into a single flat block (no @media
wrapper) and merged the two .email-reader-meta declarations into
one. Same visual result, much less competing CSS to debug.
2026-06-12 07:50:02 +09:00
pewdiepie-archdaemon a653f74cab Email reader: grow header min-height to fit wrapped 2-row cluster
When the cluster wraps to 2 rows (44 + 4 gap + 44 = 92px tall), it
was peeking out below the header bottom because min-height stayed
at 60px (only ~44px of cluster room). Bumped min-height to 108px
inside the same <600 container query so the wrapped cluster sits
fully inside the header with 8px breathing room top + bottom.
2026-06-12 07:42:31 +09:00
pewdiepie-archdaemon 4913a1363b Email reader: block-flow meta with absolute cluster — no more jump
Replaced the grid layout (which made From row height depend on
cluster height, causing To/Cc to shoot up or down at the wrap
breakpoint) with a plain block stack:
- meta = position:relative block
- From row + details = natural block flow with padding-right
  reserving space for the absolute cluster on the right
- cluster = position:absolute top-right, width changes per
  container query (308px wide / 158px narrow / 180px overlay)
- padding-right tightens from 320px → 170px → 0 as the cluster
  shrinks and finally goes overlay
- details margin-top dropped from -10px to 0 since there's no
  grid row gap to compensate for

To/Cc now hugs From with no jumps when the cluster wraps or
overlays.
2026-06-11 22:54:29 +09:00
pewdiepie-archdaemon 6edcc07c1b Email reader: lock From-row height when details expanded to kill jump
Removed the medium-mode -12px details margin compensation — it
under/over-shot depending on grid row sizing. Replaced with a
:has() rule: when the user expands To/Cc, the From row gets
min-height 92px (matching the cluster's 2-row max height). Row 1
becomes the same size whether the cluster is 1 row (wide) or 2
rows (narrow), so resizing across the 600px wrap breakpoint no
longer makes To/Cc shoot up 4px.
2026-06-11 22:47:53 +09:00
pewdiepie-archdaemon 7369c7c642 Email reader: extra 2px details lift in wrapped-cluster mode (no jump) 2026-06-11 22:43:23 +09:00
pewdiepie-archdaemon 7db4e8df4a Email reader: pull To/Cc details 2px tighter under From (-8px → -10px) 2026-06-11 22:41:02 +09:00
pewdiepie-archdaemon 5d5cfc07d7 Email reader: pull To/Cc details up 2px so they don't jump at overlay break 2026-06-11 22:38:48 +09:00
pewdiepie-archdaemon d592b1e6af Email reader: reserve row-1 height when cluster goes absolute
When the cluster snaps to absolute overlay at <380px, it stops
contributing to grid row sizing — row 1 was collapsing to the From
row's natural height, which made the To/Cc details slide upward and
left the floating cluster visually misaligned against them. Setting
min-height:88px on the From row inside the same container query
holds row 1 at the cluster's two-row height so nothing jumps.
2026-06-11 22:36:43 +09:00
pewdiepie-archdaemon b5449ea3f9 Email reader: 6px slack on cluster width to enforce 2-row max
Was fanning out to 3 rows because the 152px max-width (3 icons +
2 gaps exact) had no slack — subpixel rounding could push the
third icon over and trigger another wrap. Bumped to 158px in the
in-grid mode (600px breakpoint) and 180px in the absolute-overlay
mode (380px breakpoint, where the 22px padding-left from the
gradient fade was also eating into the 3-icon row width).
2026-06-11 22:32:00 +09:00
pewdiepie-archdaemon 73dbf3cde7 Email reader: lock cluster to 158px wide + right-edge anchor
Was wrapping into 4+ rows at narrow widths because the cluster's
grid column could shrink below the 3-icon cap. Set both min-width
and max-width to the 3-icon row width and justify-self:end on the
cluster so the icons stay glued to the right edge instead of
sliding toward the middle when the cluster is wider than its
content.
2026-06-11 22:28:48 +09:00
pewdiepie-archdaemon debd2cd386 Email reader: wire up emailreader container so wrap caps fire
The 600px / 380px breakpoints were @container docpane queries but
the email reader isn't inside a docpane container — they never
fired and the cluster wrapped to 3+ rows at narrow widths. Added
container-type:inline-size + container-name:emailreader on
.email-reader-header and switched the queries to that container,
so the 2-row cap now actually applies.
2026-06-11 22:25:09 +09:00
pewdiepie-archdaemon d95abaff1b Email reader: cap action cluster at 2 rows then overlay with shadow
Three-step shrink:
1. > 600px pane: cluster sits in col 2 as 1 row of 6
2. 380-600px pane: cluster capped at 3-icon width so wrapping
   stops at 3 + 3 (max 2 rows) — chips share width with the 2-row
   cluster instead of multiplying into 3+ rows
3. < 380px pane: cluster snaps to absolute overlay with left-edge
   box-shadow, still capped at 3-icon width so it's the same 2-row
   shape but floating above the truncated chips
2026-06-11 22:21:04 +09:00
pewdiepie-archdaemon 13086c3662 Email reader: 6-in-1-row default, wrap to 3+3 only when chip touches
Grid tracks now:
- col 1: minmax(60px, 250px) — chip natural width capped at 250px,
  with the 60px (4 char) floor enforced on From / To / Cc alike
- col 2: minmax(48px, 1fr) — takes the rest, shrinks first when
  the pane narrows

Removed the hard max-width on the action cluster so on wide panes
it stays as one row of 6. Once col 2 shrinks below the 1-row width,
flex-wrap kicks in and the icons re-stack to 3+3. Chips only start
to shrink past that point.
2026-06-11 22:18:37 +09:00
pewdiepie-archdaemon 5719e4db5f Email reader: lock cluster to 3+3 layout, shadow overlay at <380px
- Action cluster's max-width is calc(48*3 + 4*2) so the 6 icons
  always lay out as 3 top / 3 bottom by default.
- When the pane narrows the chips in col 1 shrink first (with 60px
  min so 4 chars + ellipsis stay visible).
- At <380px the cluster snaps to absolute overlay with a left-edge
  box-shadow so it reads as floating above the truncated chip.
2026-06-11 22:15:20 +09:00
pewdiepie-archdaemon 9ac3f40955 Email reader: icons wrap before chips shrink + 60px min chip width
Two-step shrink behavior:
1. As the pane narrows, the action cluster (max-width:50% of meta)
   wraps to a 2-row icon stack first
2. Then the recipient chip span starts overflow-scrolling, but
   keeps a 60px min-width (~4 chars) so the first chars of the
   sender/recipient name stay visible
2026-06-11 22:14:25 +09:00
pewdiepie-archdaemon 3a5c58da75 Email reader: To/Cc rows constrained to col 1 + cluster spans rows
Previously only the From row affected the action cluster's column
width — To/Cc detail rows spanned both columns and ignored the
cluster. Now:
- meta-details lives in col 1 only so the To/Cc chips shrink
  together with the From chip when the pane narrows
- action cluster spans rows 1 and 2 so its width is set by the
  widest col-1 content; a long To/Cc list triggers the wrap to a
  2-row icon stack just like a long From sender does
2026-06-11 22:11:47 +09:00
pewdiepie-archdaemon 7cf3402ef4 Email reader: grid layout so action cluster wraps before overlaying
Meta switched to CSS grid in undocked mode:
- row 1, col 1: From row (label + chip + chevron)
- row 1, col 2: action cluster
- row 2, span: To/Cc details

The cluster shrinks alongside the chip and flex-wraps into a 2-row
icon stack before crowding the chip. At very narrow pane widths
(< 380px via @container docpane) it snaps back to absolute overlay
so From: still fits.

Docked mode overrides meta back to flex column so the cluster
flows naturally last — under From, and under To/Cc when expanded.
2026-06-11 22:08:51 +09:00
pewdiepie-archdaemon 6066d0af02 Email reader: solid bg + gradient fade on action cluster overlay
Was rendering as a transparent ghost — From chip / sender text bled
through the gaps between icons. Added a left-fading gradient
backed by var(--bg) so the cluster reads as an opaque overlay
while chips poking out from underneath blend smoothly into its
left edge.
2026-06-11 22:06:58 +09:00
pewdiepie-archdaemon 7e029db44a Email reader: don't search-pivot from From/To/Cc chips + accent search icon
- Window-level recipient-chip click handler now bails if the chip
  is inside .email-reader-meta — the per-reader handler still
  toggles the expanded-address view on click.
- The from-sender (magnifying glass) search button SVG is now
  tinted with var(--accent-primary) so it stands out as a deliberate
  search action against the neutral Reply / Forward / etc icons.
2026-06-11 22:05:40 +09:00
pewdiepie-archdaemon f569b9394e Email reader docked: action cluster drops below To/Cc when expanded
Moved the action cluster out of the From row to a sibling of meta
inside .email-reader-meta. Undocked: cluster is absolute-positioned
top-right of the header so it overlays the From line as before.
Docked: cluster is in-flow at the bottom of the meta column, so it
sits below the From row when collapsed and below the To/Cc rows
when the user expands the recipient details via the chevron.
2026-06-11 22:04:34 +09:00
pewdiepie-archdaemon fce9942ae0 Chat: fix mode-tag breakage — toggleState wasn't in scope at those sites
The previous commit read toggleState.mode before it was declared
(send-time site near line 632) and outside its closure (assistant
finalize site near line 3426). Both threw ReferenceError / TDZ on
first send, which crashed the chat send + render pipeline.

Read fresh via Storage.loadToggleState() at each site, defaulting to
'chat' on any error. Mode-tag rendering otherwise unchanged.
2026-06-11 22:00:22 +09:00
pewdiepie-archdaemon 93ae65f99f Email reader undocked: wrap action cluster to 2 rows before overlay
Cluster is now in-flow with margin-left:auto and flex-wrap:wrap so
when the chip text grows long enough to crowd it, the buttons split
to a second row of icons before they have to cover the chip. The
absolute-overlay behavior kicks back in at very narrow pane widths
(<380px via @container docpane) so From: still fits on one row when
the pane is truly cramped.
2026-06-11 21:59:45 +09:00
pewdiepie-archdaemon f8d3890e6a Email reader undocked: nudge action cluster 1px down (-8px → -7px) 2026-06-11 21:58:09 +09:00
pewdiepie-archdaemon 85a11ad416 Email reader undocked: lift action cluster 2px more (-6px → -8px) 2026-06-11 21:56:20 +09:00
pewdiepie-archdaemon 2be0c5c892 Email reader docked: drop whole From row 4px + right-align icons
Pulled the From row's negative margin from -8 to -4 so the whole
row (From: label AND chip) sits 4px lower together. Action cluster
below now justifies flex-end so the icons sit at the right edge
of the row instead of left-aligned.
2026-06-11 20:44:47 +09:00
pewdiepie-archdaemon e0af7bd8a0 Chat: show Chat/Agent tag next to message timestamp
Sometimes the user lands in chat mode without realizing — surface the
mode the message went out on as a small uppercase pill right after the
timestamp in the role header.

- roleTimestamp(when, mode) gains an optional mode arg. Agent renders
  in accent; Chat renders in muted/neutral. Other values render
  nothing (back-compat for older history without the field).
- The three roleTimestamp call sites pass metadata?.mode through.
- chat.js writes mode into the user-message metadata at send time and
  into the assistant metadata when the active-stream render lands,
  reading toggleState.mode so research/agent overrides upstream still
  flow through correctly.

Historical messages from before this change just don't show the pill —
graceful fallback, no migration needed.
2026-06-11 20:44:18 +09:00
pewdiepie-archdaemon 1d1678214a Email reader: nudge undocked action cluster down 2px (-8px → -6px) 2026-06-11 20:43:41 +09:00
pewdiepie-archdaemon 06899c669c Email reader: lift undocked action cluster another 4px (-4px → -8px) 2026-06-11 20:41:00 +09:00
pewdiepie-archdaemon 05f05dd372 Email reader: shift From: label down 4px in docked mode 2026-06-11 20:40:33 +09:00
pewdiepie-archdaemon a195f4f194 Email reader: docked mode flows action cluster UNDER From row
When the modal is docked there's no room to overlay the actions on
the From line. Now:
- From row gets flex-wrap so the action cluster drops to its own
  row below the From label + chevron
- Action cluster goes position:static, flex-basis:100%, no gradient
  fade, no padding-left, left-aligned
- Whole From row pulled up 8px to claim back vertical space
- Header min-height drops back to 0 since buttons no longer
  overlap

Also bumped the gap from From to To/Cc details by 2px (-8 → -6).
2026-06-11 20:39:22 +09:00
pewdiepie-archdaemon 28caa40e68 Email reader: pull From label + actions up 2px more in docked mode 2026-06-11 20:36:24 +09:00
pewdiepie-archdaemon 6c1ce446f5 Email reader: lift action cluster 4px and From: label 2px on desktop 2026-06-11 20:34:45 +09:00
pewdiepie-archdaemon 729494a59b Email library: match magnifier color/opacity to other search fields
opacity 0.55 → 0.45 and explicit color:var(--fg), matching the
.cal-search-icon treatment so the email chip-bar magnifier reads at
the same muted intensity as the calendar search field.
2026-06-11 20:33:16 +09:00
pewdiepie-archdaemon df69bced42 Email reader: taller header to fit absolute-positioned action cluster
Bumped header min-height to 60px and padding-top to 8px so the
44px-tall action buttons (absolutely positioned inside the From
row) have room without overflowing the header. From row gets
min-height:44px on desktop so the buttons fit cleanly inside it.
Dropped the now-redundant negative margin nudges on the From row
and the strong label.
2026-06-11 20:33:02 +09:00
pewdiepie-archdaemon 12c8f9637f Email reader: search input up 1px, AI reply menu pared to Fast/Full
Search input gets position:relative;top:-1px so the placeholder text
sits 1px higher inside the chip bar.

AI reply choice popover: drop the '...' kebab and the 'Draft with
note' textarea row entirely. Replace the concentric-circle Full icon
with our standard accent dot (filled 6px circle in viewBox 24).
2026-06-11 20:31:04 +09:00
pewdiepie-archdaemon 7fe8a70032 Email reader: actions overlay chip instead of wrapping below when narrow
Pinned .email-reader-actions-inline to absolute top:0 right:0 of the
From row with a gradient fade. When the window narrows the cluster
stays on the From line and the recipient-chips span scrolls under
it, so users can swipe/drag to reveal recipients tucked behind the
buttons instead of seeing From: jump above the action row.
2026-06-11 20:29:24 +09:00
pewdiepie-archdaemon 2e8e097683 Revert "Email reader: AI reply becomes a split button (main + caret)"
This reverts commit 86965950ac.
2026-06-11 20:28:42 +09:00
pewdiepie-archdaemon 24dfd04964 Email reader: lift From: label 4px above the chip on desktop 2026-06-11 20:25:51 +09:00
pewdiepie-archdaemon 86965950ac Email reader: AI reply becomes a split button (main + caret)
Main button: open cached AI draft if one exists, otherwise generate
a fast draft inline. No more intermediate Fast/Full/Note menu.

Caret on the side opens a focused popover with just a textarea +
Generate button — the user types instructions (e.g. 'thank them and
confirm Tuesday at 2', 'decline politely') and submitting fires the
full-mode generation with those instructions as the noteHint.

- _aiReplySplitButtonHtml(data) centralizes the new HTML so all three
  reader render sites use the same markup.
- _showAiReplyChoice rewritten — drops the Fast/Full toggle row plus
  the kebab + 'Draft with note' two-step. Ctrl/Cmd+Enter submits.
- _handleAiReplyButton routes based on which inner button was clicked
  (caret → popover, main → run-or-open).
- The three reader event registrations now listen on .ai-reply-split
  so both inner buttons feed the same handler.
2026-06-11 20:24:19 +09:00
pewdiepie-archdaemon 79e9225c68 Email reader: From row up another 2px on desktop (-6px → -8px) 2026-06-11 20:24:17 +09:00
pewdiepie-archdaemon 1a3880347f Email reader: From row up another 2px on desktop (-4px → -6px) 2026-06-11 20:23:19 +09:00
pewdiepie-archdaemon 20968d5a87 Email reader: shift From row up 4px on desktop, +2px To/Cc gap
- Desktop (>= 769px): From row gets margin-top -4px so the whole
  From + action cluster sits 4px higher in the header.
- Mobile @media block untouched.
- To/Cc gap bumped 4px → 6px for slight breathing room.
2026-06-11 20:22:12 +09:00
pewdiepie-archdaemon a7200dd39b Email reader: nudge meta chevron 1px right (-4px → -3px margin) 2026-06-11 20:21:15 +09:00
pewdiepie-archdaemon d1f732bae1 Email reader: align From/To/Cc labels to a fixed 36px column
Strong labels reserve min-width:36px so the chips after each label
start at the same x — From, To, Cc all line up. Killed the
docked/docpane grid-stack overrides that were splitting label and
chips onto separate rows, since chips already scroll horizontally
inside each row when there are too many.
2026-06-11 20:19:02 +09:00
pewdiepie-archdaemon d849189b8c Email reader: tighten spacing in docked view + meta details
- Docked: From row + action cluster nudged up 4px
- Chevron pulled 4px left so it sits tight to the From chip
- To/Cc detail block pulled up 8px to hug the From row
- 4px gap between To and Cc rows (was 2px)
2026-06-11 20:18:06 +09:00
pewdiepie-archdaemon d30b2d11e6 Email reader: all actions on the From row, wrap when narrow
Found the culprit — the docked-modal CSS forced .email-reader-meta-row
into a single-column grid, which collapsed the From row into a
vertical stack and pushed the action buttons below it.

Fix:
- Merged the primary + secondary action rows into one flat
  .email-reader-actions-inline cluster inside the From row
- Made the cluster flex-wrap so it stays inline when undocked and
  wraps below the chip when truly cramped (docked, narrow tab)
- Excluded .email-reader-meta-from from the docked-modal and
  narrow-docpane grid-stack rules — those overrides now only
  apply to the To/Cc detail rows
2026-06-11 20:07:35 +09:00
pewdiepie-archdaemon 156009f9ad Email library: magnifying glass inside the chip-bar search field
Absolutely-positioned 13px search SVG at the left edge of the chip bar
(same circle+line glyph used elsewhere). Bar padding-left bumped 8→26
to leave room. pointer-events:none on the icon so clicks still land
on the input, opacity 0.55 to match other muted prefix icons.
2026-06-11 20:06:22 +09:00
pewdiepie-archdaemon 0aa8d17d6c Email: bookmark icon everywhere for favorites; subject matches in suggestions
Star → bookmark banner SVG also in the card title row (em.is_flagged
glyph) and the inbox toolbar's _starIcon / _starFilledIcon, so every
favorites affordance matches the chats sidebar bookmark.

Search dropdown gains a third suggestion kind:
- kind: 'email' rows surface emails from the snapshot whose subject or
  sender name match the typed term (top 4, scored by startsWith vs
  substring). Render row carries a small envelope glyph + bolded
  subject + 'from name' on the right.
- Picking one closes the dropdown and expands that exact card via
  _toggleCardPreview, scrolling it into view.
2026-06-11 19:46:45 +09:00
pewdiepie-archdaemon 39331fafb5 Email reader: primary action row literally inside the From row
Restructured the DOM so the Reply / Reply-all / Forward row lives
INSIDE the email-reader-meta-from div (after the chips span), and
the Summary / AI / More row sits directly below as a sibling of
From inside the meta. Killed the outer email-reader-actions
wrapper that kept letting the buttons drift out of position.

CSS now pushes the primary row right via margin-left:auto on the
From row and right-aligns the secondary row below it.
2026-06-11 19:46:06 +09:00
pewdiepie-archdaemon 05f87b0f50 Email reader: Reply group on From row, Summary/AI/More below
Reorganized the action cluster into two visible rows so each fits
the available width:
- Top row (on the From line): Reply / Reply-all / Forward
- Bottom row (under it):      Summary / AI reply / More

Action cluster goes back to flex-direction:column, the row
wrappers are flex rows again (no more display:contents flatten).
2026-06-11 19:39:13 +09:00
pewdiepie-archdaemon 9f1435f761 Email library: swap Favorites icon star→bookmark banner (matches chat .session-fav) 2026-06-11 19:39:12 +09:00
pewdiepie-archdaemon 772ddf4a86 Email library: filter pills render as icon-only chips
After picking a filter from the dropdown the pill was 'icon + Unread'.
Drop the text — the icon is the affordance — so the pill collapses to
just the glyph + ×. Hover surfaces the friendly label via the title
attribute. Contact + text pills still carry their text label.
2026-06-11 19:37:56 +09:00
pewdiepie-archdaemon 432b41cede Email reader: top-align action cluster against From row
align-items: flex-start on the header keeps the action cluster
locked to the From line when the user expands the To/Cc details
— previously it drifted to vertical center as the meta grew taller.
2026-06-11 19:34:20 +09:00
pewdiepie-archdaemon e7466175ef Email library chip-bar: filter + tag suggestions with their icons
Typing a filter keyword now surfaces the matching filter row in the
autocomplete (each with its existing dropdown icon). Picking one pins
a filter pill and drives the global filter state.

Keyword catalog (_LIB_FILTER_OPTIONS):
- has-attachments  ← 'attachment', 'attachments', 'has attachment', 'attach'
- unread           ← 'unread', 'new', 'unseen'
- favorites        ← 'favorite', 'starred', 'star', 'flagged'
- undone           ← 'undone', 'pending', 'todo'
- reminders        ← 'reminder', 'reminders'
- unanswered      ← 'unanswered', 'unreplied', 'no reply'
- pending_30d      ← 'pending 30d', 'pending', 'recent pending'
- stale_30d        ← 'stale', 'old', 'stale 30d'
- tag:urgent       ← 'urgent', 'critical'
- tag:reply-soon   ← 'reply soon', 'reply', 'follow up'
- tag:spam         ← 'spam', 'junk'
- tag:newsletter   ← 'newsletter', 'newsletters', 'subscriptions'
- tag:marketing    ← 'marketing', 'promo', 'promotional'

Filter pill behaviour:
- Only one filter pill is active at a time — adding a new one replaces
  any existing filter pill.
- _applyFilterPillSideEffect drives the existing #email-lib-filter
  select (or the #email-attach-btn toggle for has-attachments). The
  server-side list refetch follows for free via the existing 'change'
  handler.
- Removing the filter pill clears the side effect.

Pill render gains the filter icon as a leading glyph; the suggestion
row renders icon + label in the accent colour so it visually reads as
a filter, not a contact.
2026-06-11 19:33:55 +09:00
pewdiepie-archdaemon 5bf7caecc9 Email reader (mobile): top-align meta with the two-row action cluster
After the toolbar reshuffle the action block is now two stacked rows
(Summary/More above Reply/Forward/AI), making it taller than the meta
block. The mobile header rule was align-items:center, which then pulled
the From:/To: rows down into the vertical middle of the header — the
'From: is in the middle' symptom. Switch to flex-start so meta sticks
to the top edge where the user expects it.
2026-06-11 19:29:41 +09:00
pewdiepie-archdaemon 4bf389ed09 Email reader: actions inline on the From row
With the meta collapsed to a single visible From row + chevron,
there is room to put the action cluster on that same row as a
right-aligned sibling. Dropped the absolute positioning and
gradient-fade overlap — actions now flex-end via margin-left:auto
so From sits on the left and Reply / Reply-all / Forward / AI /
Summary / More all sit on the right of the same row.

Also moved the chevron inside the recipient-chips span so it sits
adjacent to the sender chip instead of wrapping onto a second line.
2026-06-11 19:23:34 +09:00
pewdiepie-archdaemon 90acad0d4b Email library chip-bar: AND across pills, plain Enter commits text, pill × up 4px
1. Multiple pills now AND together — 'alice + bob' means both alice
   AND bob are somewhere on the email, not 'from alice OR from bob'.
   (some → every in the filter.)
2. Default autocomplete focus is now -1 (no row pre-selected) so plain
   Enter commits the input as a text pill — typing then Enter behaves
   like a normal search. ArrowDown / ArrowUp + Enter still picks a
   contact suggestion. Tab still autocompletes the most-relevant match
   regardless of arrow state.
3. Pill × button nudged up 4px so it sits on the visual centerline
   inside the 18px pill height.
2026-06-11 19:21:37 +09:00
pewdiepie-archdaemon 6e6b860f04 Email reader: collapse To/Cc behind Gmail-style chevron
Only the From row shows by default. When the email has To and/or
Cc recipients, a small chevron sits next to the From chip — click
it to inline-expand the To/Cc rows below (rotates 180deg open).

Trims the header to a single visible row in the common case,
leaving the action cluster plenty of vertical headroom to stay
on a single row.
2026-06-11 19:19:12 +09:00
pewdiepie-archdaemon e4c7a3aad9 Email reader: keep From/To/Cc on separate rows, label tight to chips
Reverted the single-row meta strip — misread the user's ask. Each
meta field gets its own row (From / To / Cc stacked), label sits
tight to the chips on the same line, recipient chips inside the
row still scroll horizontally so long lists slide under the
floating action cluster.
2026-06-11 19:15:55 +09:00
pewdiepie-archdaemon ac4627b69d Email reader: collapse From/To/Cc into a single inline row
Three stacked meta rows wasted vertical space — From, To, Cc now
share one horizontal strip with each label tight to its chips. The
strip itself scrolls horizontally so the action cluster (still
floating top-right) can cover the right edge and the user can drag
to reveal recipients hidden underneath.

This also gives the actions a single shared row, since the meta
no longer dictates a multi-row header height.
2026-06-11 19:12:06 +09:00
pewdiepie-archdaemon 99660e1c6d Email library chip-bar: smaller pills, persist across refresh, Esc + sender click
Four fixes from the first round of usage:

1. Pill height was larger than the chip-bar's row — shrink to a fixed
   18px-tall pill (line-height + height pinned) so it sits inside
   the input row.

2. List refresh wiped pill state — when _loadEmails replaces
   state._libEmails (refresh, folder switch, etc.), refresh the
   snapshot to the new list and re-apply the pill filter so pills
   persist instead of resetting to 'show all emails'.

3. Click-to-add only worked inside the open email reader. Extend the
   capture-phase handler to ALSO catch clicks on .email-meta-sender
   inside the library grid — the list card's sender name is the most
   natural place to want to pivot from.

4. Esc inside the chip-input didn't close the modal. New behaviour:
   if the autocomplete dropdown is open, Esc closes only the dropdown
   (and swallows the event); otherwise Esc blurs the input and bubbles
   so the existing modal Esc handler can close the library.

Also wires data-email + data-name on .email-meta-sender so the click
handler has reliable targeting.
2026-06-11 19:11:07 +09:00
pewdiepie-archdaemon f91f37ef70 Email reader: flatten action rows with display:contents
The primary/secondary row wrappers were still creating nested flex
containers — even with parent flex-direction:row the two row divs
sized to content and could stack visually. Switching the wrappers
to display:contents collapses them entirely so all 6 buttons
become direct flex children of .email-reader-actions and lay out
on a single row guaranteed.
2026-06-11 19:08:22 +09:00
pewdiepie-archdaemon 682ec11003 Email library: gallery-style chip-input search with contact autocomplete
Replace the single text-input + IMAP search round-trip with a deterministic
local chip-bar filter modelled on the gallery's tag pills.

What lives in the bar
- Each filter is a pill: { type: 'contact', name, email } or
  { type: 'text', text }.
- Click anywhere in the bar lands the cursor in the input field.
- Typing populates a dropdown of matching contacts + recently-seen senders
  (cached per modal open via _buildSuggestionSource).
- Tab / Enter on a highlighted suggestion → adds a contact pill.
- Enter on free text with no suggestion match → adds a text pill.
- Backspace on empty input → pops the last pill.
- × on a pill removes that one.
- Arrow keys navigate the suggestion list.

Filtering
- _applyPillFilter snapshots the loaded list once, then for every render
  shows emails where ANY pill matches:
    contact pill — from_address equals OR to/cc contains the pill's email
    text pill    — broad substring match across subject/from/snippet

Click-to-add
- Capture-phase click handler on .recipient-chip inside the email reader
  drops the person into the library as a contact pill (and reopens the
  library window if it was closed/minimized).

Removed the debounced /api/email/search IMAP call and its 'Loading emails'
side effect. The dropped server search was the source of the 'type
jonathan, get stuck on Loading' bug.
2026-06-11 19:02:05 +09:00
pewdiepie-archdaemon 41c0ffbb52 Email reader: collapse action cluster to a single row
Reply / Reply all / Forward / AI / Summary / More now flow inline
on one row instead of being split into a primary (Summary+More) and
secondary (Reply group) stack. Mobile + docked overrides also
flipped from column to row.
2026-06-11 18:59:50 +09:00
pewdiepie-archdaemon be430fc4a4 Email reader: actions float top-right over scroll-able recipient row
From/To/Cc back on the left, action cluster (Reply / Reply-all /
Forward / AI / Summary / More) absolute-positioned top-right with a
gradient fade so chips that overflow slide cleanly underneath. The
recipient-chips lists no longer wrap — they scroll horizontally,
matching the account-chip strip pattern, so users can drag/swipe
to reveal recipients hidden under the action cluster.

Mobile (@media max-width:768px) gets the same row+absolute layout
instead of the previous column with actions on top. The narrow
container query (docpane max-width:460px) still falls back to
in-flow column so it doesn't overlap on very narrow panes.
2026-06-11 18:55:31 +09:00
pewdiepie-archdaemon 15f2b106ab Email reader: move action toolbar to the TOP, meta below
Was: from/to/cc/date meta on the left, action cluster (Reply / Reply
all / Forward / AI reply / Summary / More) pinned to the right of
the header. Now: actions stretch across the top in their two existing
sub-rows, the from/to/cc meta sits below.

Pure CSS — no template restructure. The .email-reader-header flexbox
flips to flex-direction:column, .email-reader-actions gets order:-1
to render first, and the existing flex-end aligned action-row rules
swap to flex-start so buttons read left-to-right across the top
toolbar. Mobile media query overrides bend the same way so the
layout is consistent across breakpoints.
2026-06-11 18:48:34 +09:00
pewdiepie-archdaemon e310336a42 AI Reply note: hide 'Draft with note' button until the textarea has text 2026-06-11 18:46:16 +09:00
pewdiepie-archdaemon e1585aa4aa AI Reply menu: '...' kebab opens a note input to steer the draft
The Fast/Full popover now has a kebab (three-dot) button alongside the
two preset choices. Clicking it expands a textarea below with a
'Draft with note' send button. The textarea is for the user to tell
the AI how to reply ('confirm Tuesday at 2', 'decline politely', 'say
we'll need an extra week') instead of accepting a generic draft.

Plumbing:
- emailLibrary.js: kebab button + note panel inside .email-ai-reply-choice
  menu. Submitting calls _runAiReplyFromButton with mode='ai-reply-full'
  and a noteHint string.
- _runAiReplyFromButton signature gains noteHint; passes it through
  state._onEmailClick as opts.noteHint.
- emailInbox.js consumer: forwards opts.noteHint into _openEmail's new
  5th arg, which puts it in the /api/email/ai-reply POST body as
  user_hint.
- routes/email_routes.py /ai-reply: reads user_hint, appends a
  'User's instructions for THIS reply' section to the user message
  (priority over default tone/length). Also skips the per-message
  AI-reply cache when a hint is set — the cached generic draft would
  silently override the instructions otherwise.
2026-06-11 18:41:11 +09:00
pewdiepie-archdaemon 6a392542f3 Email reader: two-row action layout — Summary+More above, Reply/Forward/AI reply below
Restructure the action cluster so it stays as two visible rows inside
.email-reader-actions instead of flattening via display:contents:
- Top row: Summary, More
- Bottom row: Reply, Reply all (conditional), Forward, AI reply
Dropped the Search button — wasn't part of the requested layout.

CSS: .email-reader-actions becomes flex column with both rows
right-aligned; .email-reader-actions-row becomes a real flex row
(no more display:contents flattening) so each row stays on its own
line. Whole block continues to sit beside the From/To meta inside
.email-reader-header.
2026-06-11 18:40:16 +09:00
pewdiepie-archdaemon 7b3bc598f4 AI Reply Fast/Full icons: paint with var(--accent, var(--red)) 2026-06-11 18:36:27 +09:00
pewdiepie-archdaemon 239cc02422 AI Reply menu: SVG icons for Fast (lightning) and Full (concentric circles) 2026-06-11 18:29:35 +09:00
pewdiepie-archdaemon 44f12f266e Email library: await _loadAccounts before loading emails
After dropping the 'Default' chip, _loadAccounts started setting
state._libAccountId asynchronously while _loadEmails fired in parallel
with the still-null id. The list request was going out with no
account_id (so the server defaulted) while subsequent per-email reads
used the explicit id set after _loadAccounts resolved — back to the
same desync the chip-removal was meant to fix.

Sequence them: await _loadAccounts first, then kick off the folders /
reminders / emails fetches. The list always carries the right
account_id from the very first call.
2026-06-11 17:15:49 +09:00
pewdiepie-archdaemon 8e8ce8ddd6 Email reader: 'open in new tab' windows don't auto-dock left on Reply
Replying from an email opened in a new tab was dragging that window to
the left-sidebar dock — same treatment as the main email library, even
though the user had explicitly opted to pop it into its own floating
viewer. Annoying when the viewer is mid-screen and Reply yanks it.

Add an early bail in _snapEmailModalToLeftSidebar for modals whose id
starts with 'email-view-' (the 'open in new tab' reader). Compose still
opens; the floating viewer just stays where it is, on top of the
library. User can move/close it themselves.
2026-06-11 17:13:15 +09:00
pewdiepie-archdaemon f2ccf8b21f Email library: drop the 'Default' chip — pick an explicit account always
Bug: clicking the dot to change the server-side default account while
viewing 'Default' left a desynced state — the email list still showed
the OLD default's cached UIDs, but the server's default now pointed
at a different account. Opening any email used the visible UID +
account_id='' on the read endpoint, which resolved against the NEW
default account → wrong email content (or older mail entirely).

Fix: remove the 'Default' chip. _loadAccounts now auto-selects the
is_default account (or the first one) into state._libAccountId so the
list view + every per-email request always carries an explicit
account_id and can't desync from set-default.

The dot button still lives on each account chip for changing which
account the server treats as the default — but it no longer affects
which account the list is currently displaying.
2026-06-11 17:11:55 +09:00
pewdiepie-archdaemon 5d9d21f227 Email filter Unread: use the incognito eye SVG (eye with X) instead of the ringed dot 2026-06-11 17:08:46 +09:00
pewdiepie-archdaemon 537f492762 Email reader: pin More to far right + allow actions to wrap beside meta
- .email-reader-actions flex-wrap nowrap → wrap so when the cluster
  exceeds the room next to a tall multi-recipient meta block, the
  buttons wrap within the actions area instead of pushing the whole
  block onto its own row below From/To.
- New rule: .email-reader-more-wrap gets order:99 so the More kebab
  sits at the far right of the flattened flex row instead of in the
  middle (its source order put it ahead of the secondary row's AI
  Reply / Summary buttons after display:contents flattening).
2026-06-11 17:06:26 +09:00
pewdiepie-archdaemon 6a0a7622fd Email filter picker: nudge up 2px on desktop (3px → 1px) 2026-06-11 17:05:47 +09:00
pewdiepie-archdaemon 719867a819 Email library: nudge .email-filter-btn up 4px 2026-06-11 17:01:23 +09:00
pewdiepie-archdaemon 9dfea188bf Email filter: custom dropdown with SVG icons for each option
The All/Unread/Favorites/etc selector was a native <select>, which
can't render SVG inside <option>. Replace it with a custom picker
that:

- Keeps the existing <select id="email-lib-filter"> as the value
  store (hidden via display:none). All existing 'change' listeners
  keep working — the picker just dispatches a change event after
  updating the select's value.
- Renders a styled button + drop-out menu built from the select's
  options (preserves optgroup labels like 'Tags').
- Each option carries an SVG icon: lines for All, ringed dot for
  Unread, star for Favorites, empty checkbox for Undone, bell for
  Reminders, reply arrow for Unanswered/Reply-soon, clock for
  Pending, calendar-x for Stale, exclamation-triangle for Urgent,
  ban for Spam, newsletter and megaphone for the marketing tags.
- Icons use var(--accent) so they pick up the user's theme color.
- Click outside / Esc closes the menu (Esc handler is capture-phase
  + stopPropagation so it doesn't bubble to the modal-close listener
  and shut the whole email window).

CSS scoped under .email-filter-picker.
2026-06-11 12:53:39 +09:00
pewdiepie-archdaemon df908b4c11 Email reader: regroup More menu + reshuffle toolbar rows
More menu reorganization:
- Group 1: Open in new tab, Remind to reply
- Group 2 (state): Mark as Unread/Read, Mark as Done/Not Done, Move to
  Archive, Save sender to contacts
- Group 3 (destructive, unchanged): Move to Spam, Move to Trash,
  Delete Permanently
- Renames: Done→'Mark as Done', Archive→'Move to Archive', Mark
  Read/Unread→'Mark as Read'/'Mark as Unread'.
- Mark Unread moves out of group 1 down into the state-change group
  alongside Done; Save sender to contacts moves down into the same
  state group.

Toolbar row reshuffle (applies to both the email-list card reader and
the email document view):
- Row 1 (primary): Reply, Reply all, Forward, Search, More — Forward
  no longer has to fight Search/More for space in the secondary row.
- Row 2 (secondary): AI reply, Summary — gets its own dedicated row.
2026-06-11 12:50:47 +09:00
pewdiepie-archdaemon be126afcf8 Email accounts strip: bigger 18x18 hit target around the small default-dot
The 6px dot was easy to miss on touch / small-cursor setups. Replace
padding-only sizing with explicit width:18px;height:18px on the
button, dot centered inside via justify-content. Anchor moved from
right:9 → right:6 so the visible dot stays where it was; the extra
clickable area extends inward from the chip edge.
2026-06-11 12:44:02 +09:00
pewdiepie-archdaemon b2243efd3f Email accounts strip: nudge default-dot 1px up + 2px left 2026-06-11 12:18:02 +09:00
pewdiepie-archdaemon 79c04c71e9 Email accounts strip: shrink default-dot to 6px (matches sidebar notif dot) 2026-06-11 11:57:46 +09:00
pewdiepie-archdaemon ebd2332db4 Agent prompt builder: stop re-adding ALWAYS_AVAILABLE on top of filtered tools
Found the reason yesterday's tool-retrieval drop wasn't taking effect:
in _build_agent_prompt, when relevant_tools was provided, it computed
  tool_names = set(ALWAYS_AVAILABLE) | set(relevant_tools)
which silently re-added every tool get_tools_for_query had just
deliberately discarded. So when a 'save this for <person>' query
dropped manage_memory from the retrieved set, the prompt builder put
it right back, and the model saw both tools again.

Trust the relevant_tools set. get_tools_for_query already starts from
ALWAYS_AVAILABLE — any discard there is intentional and should
propagate. Only force-include ask_user and update_plan here as belt-
and-suspenders since the agent loop relies on those for its own
control flow.

Other callers (task_scheduler) already union ALWAYS_AVAILABLE or
ASSISTANT_ALWAYS_AVAILABLE into relevant_tools before passing it in,
so they're unaffected.
2026-06-11 09:49:20 +09:00
pewdiepie-archdaemon 070ec4c711 Email accounts strip: nudge default-dot 1px left + shrink 10→8px 2026-06-11 09:49:03 +09:00
pewdiepie-archdaemon 6fc79e90ac Settings/Contacts (CardDAV): show '(unchanged)' placeholder when password is saved
GET /api/contacts/config masks the saved password as '***' (or ''
when none). Mirror that into the password input's placeholder so users
can see at a glance that a password is on file — matching the email
account form's '(unchanged)' pattern.
2026-06-11 09:47:28 +09:00
pewdiepie-archdaemon f5ad59317c Tool retrieval: HARD drop manage_memory when query is a contact-save pattern
Description-level steering wasn't enough — even with the explicit 'DO
NOT use for info about another person' in manage_memory's description,
models kept choosing memory over manage_contact. They can't if memory
isn't in the toolset.

New logic in ToolIndex.get_tools_for_query: detect three contact-save
patterns and discard manage_memory from the returned set (overriding
ALWAYS_AVAILABLE):

1. 'save [up to 3 words] for/to <name>' where <name> isn't a timing /
   pronoun stopword (later, tomorrow, me, you, future, etc.). Catches
   the canonical 'save this for X' and the wider 'save this address
   for X', 'save it for X'.
2. 'to/in/into (my) contacts' or 'address book'. Catches both 'add X
   to my contacts' and 'put this in my address book for X'.
3. Possessive: 'save (his/her/their) (address/phone/email/...)'.
   Stronger signal — also force-adds manage_contact to the set in
   case the keyword fallback missed it.

Verified: 8 positive contact patterns all drop memory, 10 false-
positive 'save X for later/tomorrow/me/the next thing' all keep it.
2026-06-11 09:46:34 +09:00
pewdiepie-archdaemon 803df21fc2 Email accounts strip: nudge default-dot 2px left (right 4→6) 2026-06-11 09:44:03 +09:00
pewdiepie-archdaemon df47536b8d manage_memory descriptions: explicit deferral to manage_contact for person info
Even with manage_contact in the retrieved tool set, models were still
defaulting to manage_memory when the user pasted an address + 'save for
<person>'. Both tools were in front of the model and it picked memory.

Tighten both descriptions to steer at decision-time:
- agent_loop.py manage_memory description: clarify scope is facts
  about the USER, with an explicit 'DO NOT use for info about another
  person' + a 'use manage_contact instead' line.
- tool_index.py manage_memory description: same in shorter form, so the
  embedded retrieval signal is consistent with the prompt-time
  description.
2026-06-11 09:25:23 +09:00
pewdiepie-archdaemon 2049eb7713 Contacts UI: address + phone inputs, search filter, address-only adds
The contacts manager in Settings was stuck at name+email inline only —
no address field, no phone input on add, no search to find anything in
a list of 100+ contacts.

UI:
- Add form gets phone and address inputs alongside name/email. The
  email-required gate becomes name-OR-email so address/phone-only
  entries are creatable.
- Edit form gets an address input, threaded into the PUT body.
- Search input above the list filters client-side by name / emails /
  phones / address (debounced 80ms). Count badge shows N/M when a
  filter is active.

Backend:
- /api/contacts/{uid} PUT now accepts address and routes it through
  _update_contact (which already supports it after the previous
  commit). Validation loosened: name OR email OR address.
- /api/contacts/add POST now accepts phone + address. Phone goes
  through an immediate _update_contact since _create_contact's
  signature only takes name+email+address.
2026-06-11 09:23:14 +09:00
pewdiepie-archdaemon f42cee8512 Email accounts strip: bigger default-dot (10px) + 4px more chip padding
8px ring read as a sliver next to the chip label. Bump to a 10x10 SVG
with stroke-width:3 for the hollow ring so it presents like the
sidebar notif dot at this size. Chip padding-right bumped 20→24 so
the larger glyph isn't crushed against the text.
2026-06-11 09:18:34 +09:00
pewdiepie-archdaemon 8a00f954a9 Tool retrieval: catch 'add X to (my) contacts' / 'address book' phrasings
The literal phrase 'add to contacts' missed when there was a name
between 'add' and 'to', e.g. 'add Pat to my contacts'. Anchor on the
tail with 'to my contacts', 'to contacts', 'to address book' so word
boundaries fire regardless of what sits in front.
2026-06-11 09:18:30 +09:00
pewdiepie-archdaemon 6d1d626d87 Email accounts strip: swap default-star for a dot, nudge up 2px
Replace the star polygon with a small 8px circle dot — filled +
accent-tinted on the default account, hollow + muted on others.
Vertical position bumped up 2px via top: calc(50% - 2px) so it
visually centers against the chip's text baseline instead of
geometric center.
2026-06-11 09:17:04 +09:00
pewdiepie-archdaemon 8632072ce0 Contacts: postal-address support via vCard ADR, keep tool prompt minimal
Closes the gap that pushed the agent into manage_memory when the user
pasted an address and said 'save this for X'. manage_contact now
accepts an optional address arg end-to-end:

- routes/contacts_routes.py:
  - _normalize_contact carries an 'address' field
  - _build_vcard emits ADR:;;<address>;;;; (street component of the
    RFC-6350 7-part ADR), only when address is non-empty
  - _parse_vcards reads ADR, joins non-empty components with ', '
  - _create_contact and _update_contact thread address through;
    update preserves existing address when caller passes empty
- src/tool_implementations.py do_manage_contact:
  - add accepts address; require at least name+address or email
    (was: email required) so address-only contacts are addable
  - update accepts address; require name OR emails OR address
- src/tool_schemas.py: schema gets a single 'address' string field
- src/tool_index.py + src/agent_loop.py: descriptions get one
  'address' arg mention and a 'use this for save-X-for-person /
  address pastes / phone-with-name' steering line. Net: a few
  bytes added, not a paragraph.

Also: removed a stray name from the schema's manage_contact example
strings ('save Jonathan's email…') — no real names in the codebase.
2026-06-11 09:14:52 +09:00
pewdiepie-archdaemon c637b5057b Email accounts strip: rename 'All (default)' → 'Default', add star toggle
- The 'All (default)' chip showed only the default account, so the
  label was misleading. Rename to just 'Default' to match behavior.
- Each user account chip gets a star button (filled if it IS the
  default, hollow otherwise). Clicking calls the existing
  POST /api/email/accounts/{id}/set-default and refreshes the strip.

Cross-account aggregation (a true 'All') is a separate bigger lift
that needs UID namespacing and merge/sort in _list_emails_sync;
flagged for follow-up rather than smuggled into this change.
2026-06-11 09:12:37 +09:00
pewdiepie-archdaemon 153b788134 Tool retrieval: surface manage_contact for 'save X for <person>' patterns
When the user dumps a postal address or phone number alongside a
person's name and says 'save this for X', the vector retriever was
missing manage_contact because its description only mentioned the
literal word 'contact'. The model defaulted to manage_memory (which is
in ALWAYS_AVAILABLE), so the saved fact ended up as un-named memory
that wouldn't surface on a later 'what's X's address?' search.

- Rewrite manage_contact's index description to anchor on the
  semantics: 'save info about another person', including postal/
  mailing address, ZIP, phone, etc. Now it embeds close to address-
  paste queries.
- Extend the keyword intent-map with 'save this for', 'save it for',
  'mailing address', 'postal code', 'their address', etc. — common
  ways users say 'this belongs to a contact' without the literal word
  'contact'.
2026-06-11 08:56:42 +09:00
pewdiepie-archdaemon bc2d934b94 Agent email safety: stage drafts for user approval instead of auto-send
Closes the auto-send hole that let earlier models invent signatures
(e.g. signing 'David' for a user named Felix) and SMTP them to real
recipients before the user could review.

New setting: agent_email_confirm (default True).

When on, the MCP send_email and reply_to_email tools no longer SMTP
directly — they write the composed email to scheduled_emails with a new
status 'agent_draft' (far-future send_at so the scheduled-send poller
ignores them) and return a {pending: true, pending_id, to, subject,
body, message: ...} payload. The model surfaces that to the user.

Backend endpoints to approve / cancel:
- GET    /api/email/pending          → list staged drafts for the owner
- POST   /api/email/pending/{id}/approve → flip status to 'pending' +
                                           backdate send_at so the
                                           existing scheduled-send
                                           poller delivers immediately
- DELETE /api/email/pending/{id}     → status = 'cancelled'

UI:
- Settings / AI Defaults gets a new 'Email Safety' card with the
  toggle, default on.
- Tool descriptions for send_email and reply_to_email now include the
  pending behavior + an explicit 'DO NOT invent a signature, do not
  type a person's name' guardrail.

Pass 2 (next): inline chat card with Send / Discard buttons so the user
doesn't have to type a confirmation reply. Today's prompt + the listing
endpoint give the model a clean path to surface drafts.
2026-06-11 08:50:06 +09:00
pewdiepie-archdaemon 2b1e2e9e20 Email library: center the loading whirlpool over the full grid
Old rule fixed the loading wrap at min-height:180px so the spinner
landed near the top of the email-list section. Switch to
position:absolute inset:0 over the grid (with #email-lib-grid set to
position:relative) so the whirlpool + 'Loading emails' label center
within the entire visible email area regardless of section height.
2026-06-11 08:46:34 +09:00
pewdiepie-archdaemon b5b96980e3 Email bulk bar: nudge 'Marking…' label up 2px + 'All' checkbox up 2px
- 'Marking done' / 'Marking read' / 'Marking unread' label was 2px low
  vs. the whirlpool spinner inside the Actions button. The existing
  loading-label CSS only scoped to #email-lib-bulk-delete; extend it
  to also cover #email-lib-bulk-actions and bump top from 0 to -2px.
- 'All' checkbox label was inline-styled top:2px so the box + text sat
  lower than the surrounding bulk-action items. Reset to top:0 to
  match memory + skills select-all rows.
2026-06-11 08:41:59 +09:00
pewdiepie-archdaemon 127745d13b Email search: instant local-cache filter + stop blanking the grid
Two pain points:
- IMAP server search is genuinely slow.
- The grid blanked to a whirlpool on every keystroke, so even fast
  searches felt dead because you couldn't see your own results.

Fix:
- _localSearchFilter runs synchronously on every keystroke, filtering
  the pre-search snapshot by subject / from-name / from-address /
  snippet so the grid responds immediately. Snapshot is taken on the
  first non-empty keystroke and restored when the input is cleared.
- _doSearch no longer renders the loading-whirlpool spinner into the
  grid. The local filter already shows useful results; surface
  'Searching…' in the stats badge to indicate the server search is in
  flight.
- When server results land, they replace the grid; if the user has
  already typed past them, the seq guard skips the stale render.
2026-06-11 08:28:25 +09:00
pewdiepie-archdaemon 5ec1e12a50 Email bulk actions: loading state for every action + 6-way parallel fetches
Before: only delete showed a spinner/disabled buttons. Picking Done on
92 selected emails fired off 184 sequential HTTP calls (mark-answered
+ mark-read) with zero UI feedback, so it looked like the click did
nothing for the ~20-30 seconds it took to grind through.

- All five bulk actions (delete / archive / done / read / unread) now
  swap the target button into a whirlpool+verb-ing state, dim siblings,
  and show 'N/M…' progress in the count label that ticks as each
  request resolves.
- Per-uid work runs in parallel with a hard cap of 6 in flight, so a
  90-email Done finishes in ~3 server round-trips of latency instead
  of 90, but we still don't open 90 simultaneous IMAP-backed connections.
2026-06-11 07:41:36 +09:00
pewdiepie-archdaemon 7c1af0385a Email reader More menu: reorder + separators into three groups
Group 1 — per-email view actions:
  Open in new tab → Mark Unread/Read → Remind to reply
Group 2 — non-destructive state changes:
  Save sender to contacts → Done/Not Done → Archive
Group 3 — destructive (own divider):
  Move to Spam → Move to Trash → Delete Permanently

Adds support for { separator: true } items in the actions array,
rendered as .dropdown-divider rows.
2026-06-11 07:40:11 +09:00
pewdiepie-archdaemon dde2d25804 Email library bulk Done: animate-out + drop when filter='undone'
Repro: filter Undone → Select All → uncheck a few → Actions → Done →
nothing visible happens. Reason: the bulk-Done branch only flipped
em.is_answered on the in-memory entries; the cards stayed in
state._libEmails so they kept rendering, but now with the done check
ticked. From the user's POV — still 'undone' filter, cards still
there — it looked like the action was a no-op.

When the filter is 'undone' specifically, treat marking done as a
view-removal (same animate-then-prune step archive/delete uses).
2026-06-11 07:37:38 +09:00
pewdiepie-archdaemon 7f71fbc3ea Email list: scroll an expanded card into view after click
When clicking an email higher up in the list, its top edge can be hiding
behind the modal header or off-screen. After applying the
.email-card-expanded class + the new minHeight, scrollIntoView(block:start)
on the next animation frame so the user sees the whole card.
2026-06-11 07:35:32 +09:00
pewdiepie-archdaemon 7017127a11 Email card: drop redundant header kebab; keep bottom '...' menu in expanded state
The expanded email card painted a kebab menu in its title row because
the per-card .memory-item-actions menu at the bottom was hidden while
expanded. Both pointed at _showCardMenu(em). Remove the duplicate:

- Drop the email-card-header-menu button (and its rightCluster
  wrapper) — title row now just holds the nav arrows.
- Remove the CSS rule that hid .memory-item-actions on
  .email-card-expanded so the bottom kebab stays visible.
- Unread-dot insert point retargets to .email-card-nav-arrows now
  that the rightCluster is gone.
2026-06-11 07:33:04 +09:00
pewdiepie-archdaemon 00643b5a4b Email library New (compose): envelope icon takes the accent color 2026-06-11 07:30:26 +09:00
pewdiepie-archdaemon e25c279e4b Email Library: drop redundant 'All emails. Click to open as a document' subtitle 2026-06-11 07:28:21 +09:00
pewdiepie-archdaemon df54d8d2bf Email library: bulk 'Done' actually marks selected emails done
state._selectedUids holds whatever the server returns for em.uid (string
or number); the bulk action looped Array.from(...) and did strict ===
against state._libEmails entries. When the types disagreed, the find()
returned undefined, the in-memory is_answered flip never happened, and
the post-loop _renderGrid() painted the cards back into their original
not-done state — looking like 'mark done' did nothing even though the
server-side call had succeeded.

- Compare via String() on both sides so the in-memory state actually
  flips.
- Surface HTTP failure from mark-answered/mark-read so the existing
  failedReadSync toast can fire if the calls don't go through.
2026-06-11 07:27:25 +09:00
pewdiepie-archdaemon 8ae31aeb13 Email library compose button: scope taller+lower variant to desktop only
Wrap the height:28px / top:0 rule in @media (min-width:769px) so it
can't leak into mobile, where a different touch-friendly variant
already sets min-height:36px + top:-2px.
2026-06-11 07:24:25 +09:00
pewdiepie-archdaemon cc86760a26 Email library: drop compose button another 2px (top -2→0) 2026-06-11 07:23:49 +09:00
pewdiepie-archdaemon 2e7cfbe1fa Email library: New (compose) button 4px taller + 2px lower
Base .memory-toolbar-btn is 24px tall at top:-4px. Bump the compose
button alone to 28px (4px taller) and top:-2px (moves down 2px) so
it reads as the primary action in the toolbar without affecting
Select/Refresh.
2026-06-11 07:22:38 +09:00
pewdiepie-archdaemon 9dbe31bfb0 Email/doc split: stop auto-tab-down when there's no room
Previously _prepareEmailWindowForDocument would:
  1. Check if there was horizontal room for both email + doc.
  2. If not, try collapsing the sidebar to recover space.
  3. If even that wasn't enough, _clearEmailDocumentSplit() — the
     email tab-down the user has been disliking.

Drop step 3. We still try collapsing the sidebar (free easy room),
but if the layout is still cramped, just dock anyway and let the
user manage their layout. _clearEmailDocumentSplit() is still
called on the legitimate close paths.
2026-06-11 07:17:26 +09:00
pewdiepie-archdaemon 2bf372b41c Tasks: optional persona for LLM + research tasks (biases output voice)
Wire the existing built-in PERSONAS catalog through to scheduled tasks
the same way I wired it to reminder synthesis. Repurposes the
dormant scheduled_tasks.character_id column.

UI (static/js/tasks.js)
- New 'Persona' select in the LLM / Research task form, with the five
  built-in characters (socrates/razor/nietzsche/spark/odysseus) plus a
  default 'no persona' option. Pre-populates from existing.character_id
  on edit. Non-llm/research types explicitly clear it on save.

API (routes/task_routes.py)
- TaskCreate + TaskUpdate gain character_id: Optional[str].
- _task_to_dict echoes character_id back so the form can hydrate on
  edit. Update endpoint stores '' as None to allow clearing.

Runner (src/task_scheduler.py)
- When task.character_id is set and matches a built-in persona, prepend
  the persona prompt to the task system prompt so the model speaks in
  that voice while still knowing it's running a scheduled task.
- crew_member.personality still wins as the base; character_id stacks
  on top.
2026-06-10 23:36:18 +09:00
pewdiepie-archdaemon a86990fc58 Email row: fix crash from leftover menu-wrap wiring after button removal
I removed the .email-menu-wrap markup from email rows earlier but
left the JS that queries it and calls .addEventListener on the
result. Since the query returns null, every _createEmailItem call
threw and the row never made it into the list — most visibly:
clicking a sender name to filter by them didn't appear to work,
because the row wiring (including the sender click handler) was
ripped out mid-construction.

- Drop the unconditional menuWrap.addEventListener('click', ...)
  block — there's no menu to open.
- Drop the early-return guard on touchstart that referenced the
  removed wrap.
- The two remaining .email-menu-wrap queries are already guarded
  with 'if (menuWrap)' so they safely no-op.
2026-06-10 23:31:23 +09:00
pewdiepie-archdaemon f4c1b264c6 Email reminder bell: re-evaluate visibility live on settings change
The bell is already gated on settings.reminder_channel === 'email', but
the check only ran at email-library init — so switching the reminder
channel in Settings didn't update the bell until you reopened Email.

- Settings/Reminders channel-change handler now dispatches
  odysseus-reminder-channel-changed { channel } after saving.
- emailLibrary listens for it and re-runs _syncEmailReminderBellVisibility
  with the new channel value.
2026-06-10 23:26:53 +09:00
pewdiepie-archdaemon 031a600725 Email accounts strip: drop redundant 'Accounts' label during load — whirlpool alone
The strip already lives where account chips render, so the text label
beside the whirlpool was redundant. Strip the label + the fallback
'Accounts...' text — the spinner alone tells the user accounts are
loading.
2026-06-10 23:22:59 +09:00
pewdiepie-archdaemon b385b25d5f Email row: remove the three-dot actions menu button
Dropped the .email-menu-wrap / .email-menu-btn from each row. Other
handlers that check 'if (e.target.closest(.email-menu-wrap)) return;'
safely no-op when the element doesn't exist. Row click + swipe still
open the email and its in-reader actions.
2026-06-10 23:21:17 +09:00
pewdiepie-archdaemon 49b72bd09c Email attachments: nudge download spinner up 2px to sit on icon baseline 2026-06-10 23:19:19 +09:00
pewdiepie-archdaemon 0a3333b961 Edge-dock resize handle: fade accent stripe in on hover
Transparent at rest, accent gradient animates in on hover with a 0.18s
ease transition. Drag affordance + col-resize cursor still work; the
stripe just stops bothering you when not touched.

Right-side handle mirrors the gradient direction (left-to-right
gradient flipped to right-to-left).
2026-06-10 23:18:07 +09:00
pewdiepie-archdaemon 1638db9c86 Email reader: theme-aware override for Gmail drive/attachment chips
Gmail composer chips arrive with inline border:1px solid #ddd + an
assumed white background, so on dark themes they read as a barely-
visible white box with the filename invisible. Override .gmail_chip /
.gmail_drive_chip inside .email-reader-body:

- Strip inline width:386px / height:20px (use auto + max-width:100%),
- Re-flow as inline-flex with a 6px gap so icon + filename align.
- Background tinted with var(--fg) 4%, border = var(--border).
- Anchor uses var(--accent) and the filename span uses var(--fg) so
  text is always legible regardless of theme.
- Icon img clamped to 16x16.
2026-06-10 23:17:18 +09:00
pewdiepie-archdaemon cd9ad1a7f2 Email attachments: swap paperclip for whirlpool spinner during download
Before: the attachment chip just dimmed (opacity 0.6) while the file
downloaded — easy to miss on a large attachment.

Now: replace the paperclip SVG with a 12px whirlpool spinner for the
duration of the fetch, restoring the original icon when the download
finishes (or errors out). Same loading vocabulary as Test / Scan /
Probe / Send buttons elsewhere in the UI.
2026-06-10 23:15:52 +09:00
pewdiepie-archdaemon 023f1ba575 Email inbox: visual flash when an email is auto-marked done after sending
When the email-answered event fires (user just sent a reply, so the
source email auto-marks as done), the row was getting the .active
class instantly with no visible cue beyond the checkbox tick. Add a
brief .email-auto-done-flash class on the row that runs two keyframe
animations:
- email-auto-done-row: tints the row background with the accent for
  ~1.2s then fades to transparent.
- email-auto-done-check: pops the done checkbox to 1.4× with an
  accent ring that expands outward over 0.6s.

Class self-removes after 1.2s so it doesn't replay on re-renders.
2026-06-10 23:06:42 +09:00
pewdiepie-archdaemon 1a4659b7fc Edge-dock resize handle: drop the visible accent stripe
The drag handle painted a 35% accent gradient strip on the page edge
of any docked panel. The col-resize cursor on hover is enough to
surface the affordance; the stripe felt like a stray UI element.
2026-06-10 23:05:39 +09:00
pewdiepie-archdaemon 965b0e143c Email accounts strip: wheel + grab-drag horizontal scroll
The single-row chip strip relied on native horizontal scroll, which is
hard to reach without a horizontal wheel. Wire two scroll mechanisms
on the strip once it's rendered:

- Vertical wheel → horizontal scroll (intercept only when overflow
  exists and the wheel motion is primarily vertical, so normal page
  scroll still works elsewhere).
- Mouse grab-and-drag: cursor goes grab/grabbing, mousedown→move
  bumps scrollLeft by the cursor delta. A 5px drag threshold cancels
  the chip click so the user can drag-scroll without accidentally
  switching accounts.
2026-06-10 23:00:29 +09:00
pewdiepie-archdaemon 1eca28e588 Email: revert single-row email-item; account chips single-row at all widths
- Revert the email row layout — sender/date stay above subject again,
  matching the original two-line item that the user actually wanted.
- The account filter chips (#email-lib-accounts) wrapped onto multiple
  rows on desktop. Promote the mobile-only horizontal-scroll rule to
  apply at every breakpoint so the chips always sit on one row with
  overflow scroll, regardless of screen size.
2026-06-10 22:55:10 +09:00
pewdiepie-archdaemon a80421efb6 Sessions sort dropdown: nudge all items 2px more left
Group row's auto-sort-sessions-btn padding-left 6→4, and
.sort-dropdown-item left padding 8→6 so 'Last Active', 'Newest First',
'By Folder', '↑↓ Rearrange', '● Select' all shift in by the same
amount, matching the Group nudge.
2026-06-10 22:49:53 +09:00
pewdiepie-archdaemon 89efd7d44b Email list: collapse to a single row — [sender] [subject] [date]
Subject was on its own line below sender/date. Move it inline so each
email occupies one row: sender capped at 35% width (ellipsis), subject
takes the remaining space (ellipsis), date pins to the right. Tighter
list density at the cost of dropping the spare line for snippet text
(none was being rendered anyway).
2026-06-10 22:45:19 +09:00
pewdiepie-archdaemon 41980df6f1 Sidebar/Chats manage button: parent-hover reveal + clearer 'manage' text
Two related fixes for the Chats section header:
- The 'manage' label only slid out when the button itself was hovered.
  Add section-header-flex:hover to the reveal rule so hovering the sort
  icon (or anywhere in the section header) also opens the label.
- Parent-hover opacity bumped 0.45 → 0.85 so the 'manage' text reads
  much more clearly when revealed. Direct hover on the button still
  pushes to full opacity 1.
2026-06-10 22:44:50 +09:00
pewdiepie-archdaemon baa4449a03 Sidebar/Chats manage button: drop hover background tint
The shared .section-header-btn:hover rule paints a tinted background
across all section header buttons. On the Chats manage button this
showed as a box behind the sliding 'manage' label, which the user
didn't want. Override background to transparent for that one button.
2026-06-10 22:42:06 +09:00
pewdiepie-archdaemon 1ee51be420 Sessions: Esc + outside-click also close the Move-to-folder submenu
The session-dropdown Esc handler only closed .session-dropdown-menu,
leaving the .session-folder-submenu (Move to folder → folder list)
orphaned on screen. Same gap on the click-away path. Extend both
selectors to cover the submenu so a single Esc / outside-click
dismisses the whole stack.
2026-06-10 22:39:23 +09:00
pewdiepie-archdaemon 94931ba59f Chats sidebar: 'manage' label sits in flex flow so its area is clickable
Email's 'new' label is absolutely positioned to the LEFT of the '+'
icon, which works there because the '+' is the visible/clickable
anchor. The chats manage button has no visible glyph at rest, so the
label was rendered outside the button's bounding box — hovering
'manage' lost the :hover state and clicking it missed.

Override list-item-plus-label inside chats-manage-btn:
  position: static (in flex flow) + max-width:0 / max-width:80px
expand-on-hover so the button's clickable rect grows alongside the
text. Hover stays sticky; click hits.
2026-06-10 22:39:05 +09:00
pewdiepie-archdaemon 49ecd806a2 Chats sidebar: 'manage' slides in from the side like email's 'new'
The list-item-plus-label slide-in needs a visible anchor element so
the button takes up consistent width and the absolutely-positioned
label can fly in to the left of it. Email uses the '+' SVG as that
anchor; here we use an empty 13x13 spacer span instead — same
footprint, no glyph. Result: empty button at rest (still visible per
the chats-manage-btn fade rules), 'manage' slides in from the left
on direct hover.
2026-06-10 22:37:06 +09:00
pewdiepie-archdaemon 1eaa5c2a81 Sessions sort: nudge auto-sort icon + 'Group' text 4px left (10→6 left padding) 2026-06-10 22:35:21 +09:00
pewdiepie-archdaemon e107c5876e Chats sidebar: drop library SVG from manage button — text-only 'manage'
Removed the book/library SVG and list-item-plus-btn/-label classes.
The button is now a plain text button styled like email's 'new' label
(9.5px, 0.02em letter-spacing), reusing the existing chats-manage-btn
opacity hover-reveal rules so it still fades until you hover the
section.
2026-06-10 22:35:06 +09:00
pewdiepie-archdaemon 4f7061fd61 Settings overhaul + UI polish pass
Two months of iteration on the Settings panel, integration forms, and
small visual nudges across the app. Highlights:

Settings restructure
- Add Models: split into separate Local + API cards (no more in-card
  tabs); each fuses Type/Provider with the URL input.
- Added Models: new dedicated sidebar tab, with Probe + Clear-offline
  pulled into its header; Local/API sub-section icons accent-tinted.
- Search: Web Search and a new Deep Research card (Model + tuning),
  with a cross-link to AI Defaults. Provider hints use real clickable
  anchors; Web Search Test button shows a whirlpool spinner.
- AI Defaults: Image Generation card returns; Research Model card
  carries only Endpoint+Model with a cross-link to Search; Vision /
  Default / Utility fallbacks unified under one numbered-row design
  matching Search's chain.
- API Permissions (was 'API Tokens'): per-row rename, inline
  Permissions toggle that expands the scope-edit panel, in-field
  copy icons (icon→check on success). Empty state accent-tinted.
- Integrations: + Add Integration drops a type-picker menu directly
  under the button (drop-up on tight viewports); each integration
  form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses
  the same accent-outlined Save/Test/Cancel buttons right-aligned.
- Danger Zone: Wipe→Delete with trash icons; new 'Delete everything'
  row at the bottom that loops every category.

AI Synthesis (Reminders)
- Persona dropdown sourced from PROMPT_TEMPLATES + custom preset.
- src/reminder_personas.py mirrors the five built-ins for the
  server-side synthesis path.
- dispatch_reminder() reads reminder_llm_persona and uses the
  persona's system prompt; empty/unknown falls back to warm-neutral.

Esc handling
- Kebab menus and the provider picker intercept Esc in capture phase
  so dismissing a popup no longer closes the whole Settings modal.

Accent tinting
- Scoped CSS rule across data-settings-panel=ai/services/added-models/
  search/integrations/reminders for card h2 icons + the Added Models
  sub-section icons.

Codex/Claude integration form
- No more auto-creation on form open — explicit Create token button.
- New tokens start with every scope granted; existing tokens move out
  of the integration form into the API Permissions card.
- Setup reveal: copy buttons inline inside the token + setup code
  blocks; shorter subtitle wording.

Misc visual polish
- Save/Test/Cancel uniformly accent-outlined and right-aligned on
  every integration form.
- Provider logos render inline next to the search fallback selects
  and the Deep Research Search dropdown.
- Trash icons in fallback rows bumped to 20x20 so they fill the 32px
  button.
- Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
pewdiepie-archdaemon 7690860ab1 Settings/Add Models: bump Local Type select width 57→62px 2026-06-09 15:12:57 +09:00
pewdiepie-archdaemon b6366e9da5 Settings/Add Models: fuse Local Type select + URL input into one bordered group 2026-06-09 15:12:12 +09:00
pewdiepie-archdaemon 64122269e9 Settings/Add Models: shrink Local Type select by 15px (72→57) 2026-06-09 15:11:07 +09:00
pewdiepie-archdaemon 1bdd515941 Settings/Add Models: drop 'Type:' label, keep the LLM/Image select 2026-06-09 15:10:48 +09:00
pewdiepie-archdaemon 8ac0ae72dc Settings/Add Models: Local card — Type and Add inline with URL field
Lift the LLM/Image Type select to the left of the URL input and the Add
button to its right, so the primary action (URL + Add) sits on one row.
Scan / Ollama / Key / Test stay on the action row below.
2026-06-09 15:09:28 +09:00
pewdiepie-archdaemon b2458f9891 Settings/Add Models: split Local and API into separate cards, always show API key
Drop the in-card Local/API tab strip — each is now its own admin card with
a normal h2 heading (Local on top, API below). The API key input is
always visible (no more click-to-reveal toggle), matching how cloud
providers actually work. Local keeps the optional key reveal since
local servers usually don't need one.

Dead code removed: wireModelsTabs IIFE and the adm-epApiKeyBtn toggle wire.
2026-06-09 14:57:42 +09:00
pewdiepie-archdaemon 2252776a97 Settings: promote Added Models to its own sidebar menu
Move the Added Models endpoint lists out of the Add Models card into a
dedicated sidebar tab between Add Models and AI Defaults. The card now
focuses purely on adding (Local / API tabs), while the new panel owns
the existing endpoints + Probe and Clear-offline controls.

admin.js: defensive fallback so a stale 'added' value in localStorage
falls back to 'local' instead of leaving both panes hidden.
2026-06-09 14:52:48 +09:00
pewdiepie-archdaemon c9fecd53dc Settings: third 'Added Models' tab in Add Models card
Move the Added Local + Added API lists out of the per-type tabs into
a dedicated third tab. Each Add tab is now just the form; the new tab
collects both lists together with Local / API subheadings.

Card layout:
  Add Models  [Probe] [Clear offline]
    [Local]  [API]  [Added Models]

Tab content:
  Local         → Add Local form
  API           → Add API form
  Added Models  → Local list + API list (subheadings)

All endpoint list/form IDs preserved. Tab switcher JS is generic so
the new 'added' tab works without code changes.
2026-06-09 14:47:21 +09:00
pewdiepie-archdaemon 75268e7f43 Fix Cookbook serve server selection 2026-06-09 14:45:22 +09:00
pewdiepie-archdaemon 8ef9b8b215 Settings: tabbed Add Models card with Local / API tabs
Earlier split into 4 flat cards wasn't what was asked for. Restore to
a single 'Add Models' card with two tabs at the top:

  Local  → Add form + Added Local Models list
  API    → Add form + Added API Endpoints list

Probe / Clear-offline live on the card header and act on both lists.
Active tab is remembered in localStorage so the user lands back where
they were. All form/list IDs preserved (adm-epLocalUrl, adm-epList-local,
adm-epList-api, etc.) so admin.js continues to work unchanged.

Replaces the .adm-section-toggle fold-open JS with a tab-switcher; the
fold elements no longer exist so the old handler was already a no-op.
2026-06-09 14:43:28 +09:00
pewdiepie-archdaemon 459b825daa Settings: split Add/Added Models into 4 flat cards (no folds)
The previous 'Add Models' card had two collapsible folds (Local + API)
inside it and 'Added Models' had two inline subsections. Both folded
states added a click-to-expand step that wasn't earning its keep —
users coming to Settings to add a model don't want a fold, they want
the form.

Reshape: four flat admin-cards in the Services panel, each with its
own h2 title matching the rest of Settings:
  Add Local Model       (was Add Models → Local fold)
  Add API               (was Add Models → API fold)
  Added Local Models    (was Added Models → Local subsection)
  Added API Endpoints   (was Added Models → API subsection)

The collapsible JS hook in admin.js already guards on
'if (!head) return' so removing the .adm-section-toggle headers
turns it into a clean no-op — no breakage.

All input/list IDs preserved (adm-epLocalUrl, adm-epList-local,
adm-epList-api, etc.) so the rest of admin.js continues to work
unchanged. Probe / Clear-offline live on the Local card and act on
both lists together (existing behavior).
2026-06-09 14:36:44 +09:00
pewdiepie-archdaemon 3247773447 Hide Teacher Model settings card (2.0 'harden the core' deferral)
The Teacher Mode feature stays out of the default UI per the 2.0
roadmap — backend escalation is already dormant when teacher_model is
unset (its default) and we want to focus on core reliability before
surfacing escalation as a feature.

Nothing removed from the backend:
- src/teacher_escalation.py still gates on get_setting('teacher_model')
- agent_loop.py's run_teacher_inline hook is a no-op without the setting
- settings backup/restore round-trips the teacher_model key unchanged
- power users can still set it via manage_settings or the JSON backup

settings.js's initTeacherModel already early-returns when the card's
DOM ids are missing, so the JS side is clean.

To re-surface the card, revert this commit.
2026-06-09 14:31:04 +09:00
pewdiepie-archdaemon 013beab861 Add Codex and Claude document draft integration 2026-06-09 14:27:53 +09:00
pewdiepie-archdaemon c5230e85a9 Change project license to AGPL-3.0-or-later 2026-06-09 14:25:04 +09:00
148 changed files with 13481 additions and 2092 deletions
+7
View File
@@ -190,3 +190,10 @@ SEARXNG_INSTANCE=http://localhost:8080
# These overlays only expose the GPU devices. The slim Odysseus image
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
# llama-cpp-python, etc.) before models can actually serve on GPU.
# ============================================================
# Storage Paths (Docker Compose)
# ============================================================
# APP_DATA_DIR=./data
# APP_LOGS_DIR=./logs
+123
View File
@@ -0,0 +1,123 @@
# Pull Request Review Template
Use this shape as a copyable reference for substantive PR reviews; GitHub does
not auto-apply this file to review comments. Omit sections that do not add
useful signal. Lead with confirmed findings; keep speculative notes out of the
public review unless they are framed as a concrete open question.
## Small PR Path
For narrow docs, typo, test-only, or obvious local fixes, a short review is
enough:
```md
LGTM after checking:
- scope:
- validation:
- residual risk:
```
Use the fuller structure below for larger, risky, multi-finding, or
security-sensitive reviews.
## Findings
**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> issue (test): Short issue title**
- **Problem:** Concrete broken flow, contract, input, or risk.
- **Impact:** Why this matters to users, CI, maintainers, data, security, or scale.
- **Ask:** Smallest practical correction or decision the author should make.
- **Location:** `path:line`
## Open Questions
- **question (scope, non-blocking): Short author question** Ask the concrete
intent, scope, or tradeoff question.
## Validation
- Ran:
- Not run:
- Residual risk:
## PR Hygiene
- Target/template/checks:
- Related, duplicate, or superseding context:
## No Findings Variant
```md
## Findings
none confirmed
## Validation
- Ran:
- Not run:
- Residual risk:
```
## Legend
- **Findings:** Verified, author-actionable issues that should be fixed or
consciously accepted before merge.
- **Priority badges:** The shields.io badges below are optional formatting for
priority labels. Plain `P0`, `P1`, `P2`, or `P3` text is also acceptable when
an external image dependency is undesirable or may not render.
- **P0:** `![P0 Badge](https://img.shields.io/badge/P0-red?style=flat)` -
release-blocking or actively dangerous.
- **P1:** `![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)` -
serious bug, security risk, data-loss risk, or broken primary flow.
- **P2:** `![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)` -
meaningful correctness, test, maintainability, or edge-case issue.
- **P3:** `![P3 Badge](https://img.shields.io/badge/P3-lightgrey?style=flat)` -
minor polish or low-risk cleanup.
- **Intent labels:**
- **`issue`:** A confirmed defect, regression, broken contract, or concrete
risk.
- **`suggestion`:** A non-blocking improvement that would make the PR clearer,
safer, or easier to maintain.
- **`nit`:** A tiny, non-blocking cleanup or style note. Use it only when the
author can safely ignore it without changing the review outcome.
- **`question`:** A real author-facing clarification about intent, scope, or
tradeoffs. Do not use questions to hide an issue that should be stated
directly.
- **`LGTM`:** "Looks good to me." Use only when the review found no blocking
issues, or when any remaining notes are clearly optional.
- **Decorations:** Optional labels in parentheses that clarify the finding type,
scope, or merge impact.
- **`security`:** Auth, authorization, ownership, secrets, SSRF, injection,
unsafe external input, or other trust-boundary concerns.
- **`test`:** Missing, failing, misleading, brittle, or insufficient tests.
- **`scope`:** PR scope, feature boundaries, unrelated churn, or work that
should be split into a separate issue or PR.
- **`ci`:** CI configuration, workflow failures, flaky checks, or validation
signal quality.
- **`api`:** Route, request/response, public function, schema, persistence, or
integration contract changes.
- **`docs`:** User-facing docs, contributor docs, examples, or comments that
need to change with the code.
- **`non-blocking`:** Useful feedback that should not prevent merge by
itself.
- **Finding fields:**
- **Problem:** What is wrong, what contract is ambiguous, or what risk the PR
introduces.
- **Impact:** Why the problem matters in practical terms.
- **Ask:** The smallest concrete fix, test, or decision requested from the PR
author.
- **Location:** The most useful repo-relative file and line reference for the
finding, using `path:line`.
- **Optional sections:**
- **Open Questions:** Genuine scope or intent questions; omit when there are
no real questions.
- **Validation:** What the reviewer ran, what was intentionally not run, and
what risk remains after review.
- **PR Hygiene:** Target-branch, template, CI/check, duplicate, related-work,
or superseding-PR notes.
- **`none confirmed`:** Use only when no review-worthy findings were confirmed;
still list validation gaps or residual risk when relevant.
+6 -6
View File
@@ -19,10 +19,10 @@ jobs:
name: Python syntax (compileall)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps.
@@ -32,10 +32,10 @@ jobs:
name: JS syntax (node --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib).
@@ -54,7 +54,7 @@ jobs:
# ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@@ -81,7 +81,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
-61
View File
@@ -1,61 +0,0 @@
# CodeQL code scanning
#
# Purpose: GitHub's own static analysis engine reads the application source
# (Python backend + the JavaScript frontend) and looks for real
# vulnerabilities -- SQL/command injection, path traversal, auth mistakes,
# unsafe deserialization. Findings appear in the repo's Security tab. This is
# the deepest check in the suite and the most valuable for a high-profile
# target.
#
# It runs on every push to main and on a weekly schedule (to catch newly
# disclosed query patterns against unchanged code). It deliberately does NOT
# run on pull requests: most PRs here come from forks, whose read-only token
# cannot publish results, which would produce confusing failures. To scan pull
# requests too, a maintainer can instead enable CodeQL "default setup" in
# Settings -> Security -> Code scanning (one toggle, no file needed) -- see
# docs/security-ci.md.
name: CodeQL
on:
push:
branches: [main]
schedule:
# Weekly, Monday 06:00 UTC.
- cron: '0 6 * * 1'
workflow_dispatch:
permissions: {}
concurrency:
group: codeql-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # publish results to the Security tab
strategy:
fail-fast: false
matrix:
# Both are interpreted, so CodeQL needs no build step (build-mode none).
language: [python, javascript-typescript]
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+3 -3
View File
@@ -52,7 +52,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
security-events: write # upload SARIF to the Security tab
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -119,7 +119,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-image
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -55,7 +55,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git.
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+3
View File
@@ -70,6 +70,9 @@ output.txt.txt
*.tiff
*.pdf
# …except shipped static assets
!static/icons/*.png
# …except shipped demo assets in docs/ that the README links to.
!docs/*.jpg
!docs/*.jpeg
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM python:3.14-slim
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
+38 -456
View File
@@ -1,469 +1,65 @@
# Odysseus
<p align="center">
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="280">
</p>
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
<p align="center">
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
</p>
```
───────────────────────────────────────────────
⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
───────────────────────────────────────────────
```
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="docs/setup.md">Setup Guide</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="ROADMAP.md">Roadmap</a>
</p>
![Odysseus](docs/odysseus.jpg)
<p align="center">
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
</p>
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
<p align="center">
<img src="docs/odysseus.jpg" alt="Odysseus interface">
</p>
## Features
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
- **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!<br> <sub>built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving</sub>
- **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.<br> <sub>adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)</sub>
- **Compare** -- a fun tool to compare models side by side. Test completely blind, no bias!<br> <sub>multi-model · blind test · synthesis</sub>
- **Documents** -- YOU write the text, AI is there to assist, not the opposite.<br> <sub>multi-tab editor · markdown · HTML · CSV · syntax highlighting · AI edits · suggestions</sub>
- **Memory / Skills** -- Persistent memory and skills, your agent evolves over time as it better understands you and your tasks!<br> <sub>ChromaDB · fastembed (ONNX) · vector + keyword retrieval · import/export</sub>
- **Email** -- IMAP/SMTP inbox with AI triage built in: urgency reminders, auto-tag, auto-summary, auto-reply drafts, auto-spam.<br> <sub>IMAP · SMTP · per-account routing · CalDAV-aware</sub>
- **Notes & Tasks** -- Quick notes with reminders, a todo list, and scheduled tasks the agent can act on.<br> <sub>note pings · checklist · cron-style tasks · ntfy / browser / email channels</sub>
- **Calendar** -- Local-first calendar with CalDAV sync to Radicale / Nextcloud / Apple / Fastmail.<br> <sub>CalDAV pull · .ics import/export · per-calendar colors · agent-aware</sub>
- **Works on mobile** -- looks and runs great on your phone, not just desktop.<br> <sub>responsive · installable (PWA) · touch gestures</sub>
- **Extras** -- more to explore, happy if you give it a go!<br> <sub>image editor · theme editor · file uploads (vision + PDF) · web search · presets · sessions · 2FA</sub>
## Demo
A full, hover-to-play tour lives on the landing page (`docs/index.html`).
<details>
<summary>Screenshots / clips</summary>
### Chat & Agents
![Chat & Agents](docs/chat.gif)
### Deep Research
![Deep Research](docs/research.gif)
### Compare
![Compare](docs/compare.gif)
### Documents
![Documents](docs/document.gif)
### Notes & Tasks
![Notes & Tasks](docs/notes.gif)
</details>
---
## Quick Start
Defaults work out of the box: clone, run, then configure models/search/email
inside **Settings**. Only edit `.env` for deployment-level overrides like
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main) if you want the more curated branch.
On first setup, Odysseus creates an admin account (`admin` unless
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**.
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
pull request guidelines.
### Docker (recommended)
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
cp .env.example .env # optional, but recommended for explicit defaults
cp .env.example .env
docker compose up -d --build
```
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
Open `http://localhost:7000` when the containers are healthy. Docker Compose
binds the web UI to `127.0.0.1` by default. If the port is taken, set
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
only when you intentionally want LAN/reverse-proxy access.
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
> run natively instead — see [Apple Silicon](#apple-silicon) below.
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
### Native Linux / macOS
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
downloads and serves. The app itself is lightweight; local model serving is the
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
## Features
### Apple Silicon
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
M-series Mac, run Odysseus natively:
- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory.
- **Cookbook** — hardware-aware model recommendations, downloads, and serving.
- **Deep Research** — multi-step web research with source reading and report generation.
- **Compare** — blind side-by-side model testing and synthesis.
- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting.
- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts.
- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync.
- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA.
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
./start-macos.sh
```
## Demo
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
```bash
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
# then open http://<tailscale-ip>:7860
```
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
set there are picked up automatically without a command-line override each run.
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
expose this port directly to the public internet. To build a clickable app wrapper:
```bash
./build-macos-app.sh
```
<details>
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
they are reachable from the host but not exposed to your LAN/public internet
unless you opt in.
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
serve engines live in `./data/local` (`~/.local` in the container), so they
survive container recreation.
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
Odysseus SSH key and add the public key to the remote server's
`~/.ssh/authorized_keys`. From the host you can also run:
```bash
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
```
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
only detect GPUs that Docker exposes to the container — if the host runtime or
device passthrough is not configured, Cookbook sees the iGPU, another card, or
CPU instead of your intended GPU.
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
optionally install the host runtime or update `.env`.
```bash
# Read-only diagnostic (default — installs nothing, never edits .env):
scripts/check-docker-gpu.sh
# Print OS-specific install commands without running them:
scripts/check-docker-gpu.sh --print-install-commands
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
scripts/check-docker-gpu.sh --install-nvidia-toolkit
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
scripts/check-docker-gpu.sh --enable-nvidia-overlay
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
```
Safety notes:
- The app never installs host GPU runtime automatically.
- The app never edits `.env` automatically.
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
bypass the passthrough gate.
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
Git and the Docker build context.
To enable manually without the script, add this to `.env`:
```bash
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
```
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
```bash
scripts/check-docker-amd-gpu.sh
```
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
numeric render group id:
```bash
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
RENDER_GID=989
```
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
overlay workflow above. For stack UIs, point the stack at one of the standalone
files instead, which bundle the base stack plus the GPU settings:
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
on the host.
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
`video`/`render` group membership, and `RENDER_GID` when needed.
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
source of truth; the standalone files mirror them for single-file deployments.
Verify after enabling either overlay:
```bash
docker compose exec odysseus nvidia-smi -L # NVIDIA
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
```
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
> not a Docker passthrough failure. Reinstall the serve engine via
> **Cookbook → Dependencies** to get a CUDA-enabled build.
>
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
> the container confirms device passthrough, not ROCm userspace or a
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
> inside the slim Odysseus image.
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
Settings:
```text
http://host.docker.internal:11434/v1
```
Ollama must listen outside its own loopback interface:
```bash
OLLAMA_HOST=0.0.0.0:11434 ollama serve
```
This connects Odysseus in Docker to an Ollama server that is already running on
your host machine; it does not start Ollama inside the container.
`host.docker.internal` is Docker's hostname for the host machine from inside the
container. Cookbook **Serve** is a separate workflow for serving downloaded
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
install usually only need to add the endpoint in Settings.
**Useful checks.**
```bash
docker compose ps
docker compose logs --tail=120 odysseus
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
```
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
do not run on macOS. MLX-only models are not served by Odysseus.
</details>
### Native Windows
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
server; safe to re-run):
```powershell
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
```
Or do it by hand:
```powershell
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
py -3.11 -m venv venv
venv\Scripts\Activate.ps1
pip install -r requirements.txt
python setup.py
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
If `python` points at an older interpreter, use `py -3.12` (or another installed
3.11+ version) for the venv step.
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
email, calendar, deep research) runs fully native. For full **Cookbook** background
model downloads and the agent shell tool, also install
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
`http://localhost:11434/v1` in Settings.
Open `http://localhost:7000`, log in with the generated admin password,
and configure everything else inside **Settings**.
## Troubleshooting & Advanced Setup
### `chromadb-client` conflicts with embedded ChromaDB
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
```bash
./venv/bin/pip uninstall chromadb-client -y
./venv/bin/pip install --force-reinstall chromadb
```
### HTTPS + LAN/Tailscale exposure
To expose Odysseus on a local network or Tailscale with HTTPS:
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
```bash
mkcert -install
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
```
3. Run `uvicorn` with the generated certs:
```bash
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
```
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
### Optional Dependencies
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
| Package | Feature unlocked |
|---------|-----------------|
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
| `ddgs` | DuckDuckGo as a search provider option. |
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
### Faster, reproducible installs with uv (optional)
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
```bash
uv venv venv --python 3.13
uv pip install -r requirements.txt
# then continue as usual: python setup.py, uvicorn, ...
```
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
```bash
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
uv pip sync requirements.lock # reproduce it exactly later
```
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
### Outlook / Office 365 email
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
current limitation and the planned integration direction.
## Security Notes
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
### Private or proxied deployments
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
4. Keep raw service and model ports internal-only.
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
Common internal-only ports from the default docs/compose setup:
| Port | Service |
|---|---|
| `7000` | Odysseus raw app port |
| `8080` | SearXNG |
| `8091` | ntfy |
| `8100` | ChromaDB host port for manual/compose access |
| `11434` | Ollama |
| `8000-8020` | Common local model/provider APIs |
A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
## Contributing
Help is welcome. The best entry points are fresh-install testing, provider setup
bugs, mobile/editor polish, docs, and small focused refactors. See
[ROADMAP.md](ROADMAP.md) for the current help-wanted list.
## Configuration
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
for deployment-level defaults and secrets you want present before first boot.
Key settings:
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
| Variable | Default | Description |
|---|---|---|
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
| `AUTH_ENABLED` | `true` | Enable/disable login |
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
## Security
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
### Built-in MCP servers (optional setup)
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
To enable the browser MCP (page navigation, screenshots, vision), run once:
```bash
npx -y @playwright/mcp@latest --version
```
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
## Architecture
```
app.py # FastAPI entry point
core/ auth, database, middleware, constants
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end)
docs/ landing page (index.html) + preview clips
```
## Data
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
## Star History
@@ -476,19 +72,5 @@ All user data lives in `data/` (gitignored): `app.db` (sessions, messages, docum
</a>
## License
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
```
|
|||
|||||
| | | |||||||
)_) )_) )_) ~|~
)___))___))___)\ |
)____)____)_____)\\|
_____|____|____|_____\\\__
\ /
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
~^~ all aboard! ~^~
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
```
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
+32 -4
View File
@@ -69,10 +69,37 @@ from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_imag
from starlette.responses import RedirectResponse
# ========= LOGGING =========
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
import logging.handlers
from core.constants import DATA_DIR
_root_logger = logging.getLogger()
_root_logger.setLevel(logging.INFO)
_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Clear existing handlers to avoid duplicates
for _h in list(_root_logger.handlers):
_root_logger.removeHandler(_h)
_console_h = logging.StreamHandler()
_console_h.setFormatter(_formatter)
_root_logger.addHandler(_console_h)
try:
_log_dir = os.path.join(DATA_DIR, "logs")
os.makedirs(_log_dir, exist_ok=True)
_log_file = os.path.join(_log_dir, "app.log")
# RotatingFileHandler is not multi-process safe (e.g. if uvicorn is run with --workers N).
# Odysseus is single-process by convention, so this is acceptable, but be aware that
# concurrent log rotation issues can arise if multiple workers are configured.
_file_h = logging.handlers.RotatingFileHandler(
_log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
_file_h.setFormatter(_formatter)
_root_logger.addHandler(_file_h)
except Exception as e:
_root_logger.warning(f"Failed to initialize file logging handler (falling back to console-only): {e}")
logger = logging.getLogger(__name__)
# ========= APP =========
@@ -140,6 +167,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
"/api/cookbook/setup", # remote pacman/apt installs
"/api/upload", # large files
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
"/api/memory/audit", # retains own 120s LLM inactivity timeout
)
+73
View File
@@ -3,6 +3,7 @@ Authentication module — multi-user password hashing, session tokens, config pe
Config stored in data/auth.json. Uses bcrypt directly.
"""
import enum
import json
import os
import secrets
@@ -83,6 +84,15 @@ def _verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
class SetAdminResult(enum.Enum):
"""Outcome of AuthManager.set_admin, so callers can map each case to a
precise response instead of guessing from a bare bool."""
OK = "ok"
USER_NOT_FOUND = "user_not_found"
NOT_AUTHORIZED = "not_authorized" # requester is not an admin
LAST_ADMIN = "last_admin" # would remove the last remaining admin
class AuthManager:
"""Manages multi-user password + session-token auth system."""
@@ -387,6 +397,69 @@ class AuthManager:
logger.info(f"Updated privileges for '{username}': {current}")
return True
def set_admin(self, username: str, is_admin: bool,
requesting_user: str) -> SetAdminResult:
"""Promote/demote an existing user to/from admin. Admin only.
Refuses to remove the last remaining admin so the instance can never
be locked out of admin access; self-demotion is allowed as long as
another admin remains. Admin status is re-checked live on every
request, so unlike delete/rename no session or token revocation is
needed — a demoted admin simply fails the next is_admin() gate.
Promotion stashes the user's current privilege map and demotion
restores it, so a temporary admin stint can't silently broaden a
user's non-admin access; users without a stash (created as admin,
or promoted before stashing existed) demote to DEFAULT_PRIVILEGES.
Counting admins and flipping the flag happen in one critical section
so two concurrent demotions can't race the admin count to zero.
"""
username = (username or "").strip().lower()
requesting_user = (requesting_user or "").strip().lower()
is_admin = bool(is_admin)
with self._config_lock:
target = self._config.get("users", {}).get(username)
if target is None:
return SetAdminResult.USER_NOT_FOUND
if not self.users.get(requesting_user, {}).get("is_admin"):
return SetAdminResult.NOT_AUTHORIZED
currently_admin = bool(target.get("is_admin"))
if currently_admin == is_admin:
return SetAdminResult.OK # no-op; leave privileges untouched
if currently_admin and not is_admin:
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
if admin_count <= 1:
return SetAdminResult.LAST_ADMIN
# Write order matters for lock-free readers: get_privileges()
# reads without _config_lock and trusts is_admin, so the admin
# flag must be flipped while the stored map is safe to expose —
# before writing admin privileges on promote, after restoring
# the pre-admin map on demote.
if is_admin:
target["is_admin"] = True
# Stash the pre-admin map so a later demotion can restore it.
# While is_admin is set the stored map is inert: get_privileges
# short-circuits to ADMIN_PRIVILEGES and set_privileges refuses
# admins, so only set_admin ever touches the stash.
target["privileges_before_admin"] = dict(
target.get("privileges") or DEFAULT_PRIVILEGES
)
target["privileges"] = dict(ADMIN_PRIVILEGES)
else:
# Restore the stashed pre-admin map. Fall back to defaults for
# users created as admins (their stored map is ADMIN_PRIVILEGES,
# which must not leak past demotion — e.g. can_use_bash) and
# for admins promoted before the stash existed.
target["privileges"] = dict(
target.pop("privileges_before_admin", None)
or DEFAULT_PRIVILEGES
)
target["is_admin"] = False
self._save()
logger.info("Set is_admin=%s for '%s' (by '%s')", is_admin, username, requesting_user)
return SetAdminResult.OK
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
username = username.strip().lower()
if username not in self.users:
+44
View File
@@ -1602,6 +1602,7 @@ class CalendarCal(TimestampMixin, Base):
# NULL for local calendars and for CalDAV calendars created before
# multi-account support was added (treated as "use any configured account").
account_id = Column(String, nullable=True, index=True)
caldav_base_url = Column(String, nullable=True)
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -1632,10 +1633,27 @@ class CalendarEvent(TimestampMixin, Base):
# vanishes upstream). NULL/local = created locally (agent, email triage, or
# a UI event whose write-back failed) and must NOT be pruned by the sync.
origin = Column(String, nullable=True, index=True)
remote_href = Column(String, nullable=True) # CalDAV object URL for updates/deletes
remote_etag = Column(String, nullable=True) # Last seen CalDAV ETag, when available
caldav_sync_pending = Column(String, nullable=True) # create | update | delete retry marker
calendar = relationship("CalendarCal", back_populates="events")
class CalendarDeletedEvent(TimestampMixin, Base):
"""Hidden CalDAV delete tombstone retained until remote delete succeeds."""
__tablename__ = "caldav_deleted_events"
uid = Column(String, primary_key=True, index=True)
owner = Column(String, nullable=True, index=True)
calendar_id = Column(String, nullable=True, index=True)
remote_href = Column(String, nullable=True)
remote_etag = Column(String, nullable=True)
caldav_base_url = Column(String, nullable=True)
summary = Column(String, nullable=True)
last_error = Column(Text, nullable=True)
class Integration(TimestampMixin, Base):
"""An external service connection (email, RSS, webhook, etc.)."""
__tablename__ = "integrations"
@@ -1767,6 +1785,7 @@ def init_db():
_migrate_add_calendar_is_utc()
_migrate_add_calendar_origin()
_migrate_add_calendar_account_id()
_migrate_add_caldav_sync_columns()
_migrate_chat_messages_fts()
_migrate_encrypt_email_passwords()
_migrate_encrypt_signatures()
@@ -2067,6 +2086,31 @@ def _migrate_add_calendar_account_id():
pass
def _migrate_add_caldav_sync_columns():
"""Add remote CalDAV metadata used for bidirectional sync."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
ev_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()]
if ev_columns and "remote_href" not in ev_columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_href TEXT")
if ev_columns and "remote_etag" not in ev_columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT")
if ev_columns and "caldav_sync_pending" not in ev_columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT")
cal_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendars)").fetchall()]
if cal_columns and "caldav_base_url" not in cal_columns:
conn.execute("ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT")
conn.commit()
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"CalDAV sync metadata migration failed: {e}")
def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3
+5 -5
View File
@@ -16,18 +16,18 @@ services:
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
- ./data:/app/data:z
- ./logs:/app/logs:z
- ${APP_DATA_DIR:-./data}:/app/data:z
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ./data/ssh:/app/.ssh:z
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ./data/huggingface:/app/.cache/huggingface:z
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
+5 -5
View File
@@ -15,18 +15,18 @@ services:
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
- ./data:/app/data:z
- ./logs:/app/logs:z
- ${APP_DATA_DIR:-./data}:/app/data:z
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ./data/ssh:/app/.ssh:z
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ./data/huggingface:/app/.cache/huggingface:z
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
+5 -5
View File
@@ -4,18 +4,18 @@ services:
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
- ./data:/app/data:z
- ./logs:/app/logs:z
- ${APP_DATA_DIR:-./data}:/app/data:z
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ./data/ssh:/app/.ssh:z
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ./data/huggingface:/app/.cache/huggingface:z
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
+194
View File
@@ -0,0 +1,194 @@
# Agent migration manifests
Odysseus should be able to learn from another agent without blindly trusting
that agent's whole state. The safe migration path is:
```text
source agent export -> source adapter -> agent-migration.v1 manifest -> preview -> apply
```
The manifest is intentionally source-neutral. OpenClaw, Hermes, a folder of
Markdown notes, or any other agent can have its own adapter, but Odysseus only
needs to understand the normalized manifest.
## Why not import everything as memory?
Durable memory should stay compact and useful. Long notes, logs, session
transcripts, and project archives are useful context, but they are not all
memories. A good migration keeps two layers separate:
- **Archive documents** preserve source material for search, reading, and later
extraction.
- **Memory candidates** are short facts or preferences that can be reviewed
before being saved into Odysseus memory.
This keeps Odysseus' existing memory-review flow intact while giving it better
source material to review.
## Manifest shape
`agent-migration.v1` is a JSON object:
```json
{
"schema_version": "agent-migration.v1",
"generated_at": "2026-06-06T00:00:00Z",
"source": {
"name": "example-agent",
"kind": "generic"
},
"summary": {
"item_count": 3,
"counts_by_kind": {
"memory": 1,
"skill": 1,
"conversation_thread": 1,
"archive_document": 1
},
"warning_count": 0
},
"items": [],
"warnings": []
}
```
Each item has a stable `id`, a `kind`, source metadata, and enough content for a
future importer to preview it before applying.
Supported item kinds in the first pass:
- `memory` — a candidate memory with `text`, `category`, `source`, and
provenance metadata.
- `skill` — a `SKILL.md` file with content and parsed frontmatter metadata.
- `conversation_thread` — a normalized transcript thread from an exported chat
history. Message content is optional; adapters can preserve only thread
metadata, message counts, timestamps, and hashes when a manifest should stay
small or avoid embedding private transcript text.
- `archive_document` — long-form source material. Content is optional; adapters
can preserve only path/hash/size metadata when a manifest should stay small.
## Build a manifest
Use the read-only helper:
```bash
python3 scripts/agent_migration_manifest.py \
--source-name old-agent \
--source-kind generic \
--memory-json /path/to/memories.json \
--skills-dir /path/to/skills \
--conversation-json /path/to/conversations.json \
--archive /path/to/notes \
--output /tmp/agent-migration.json
```
The helper does not write to `data/`, call an LLM, import Odysseus modules, or
modify the source. It only writes JSON.
Memory JSON may be:
```json
[
"A plain memory string",
{
"text": "A categorized memory",
"category": "preference",
"source": "old-agent"
}
]
```
or an object containing a list under `memories`, `memory`, `items`, or `data`.
Skills are scanned recursively for `SKILL.md`:
```bash
python3 scripts/agent_migration_manifest.py \
--source-name hermes \
--source-kind hermes \
--skills-dir ~/.hermes/skills \
--output /tmp/hermes-skills-manifest.json
```
Archive documents are metadata-only by default. To embed text content:
```bash
python3 scripts/agent_migration_manifest.py \
--source-name notes-export \
--archive /path/to/markdown-notes \
--include-archive-content \
--output /tmp/notes-manifest.json
```
Conversation exports are also metadata-only by default:
```bash
python3 scripts/agent_migration_manifest.py \
--source-name chatgpt-export \
--source-kind chatgpt \
--conversation-json /path/to/conversations.json \
--output /tmp/chatgpt-conversations-manifest.json
```
The first pass supports generic conversation JSON such as:
```json
[
{
"id": "thread-1",
"title": "Project plan",
"messages": [
{"role": "user", "content": "Can we design this?"},
{"role": "assistant", "content": "Yes, start with a narrow slice."}
]
}
]
```
It also recognizes ChatGPT-style `mapping` exports from `conversations.json`.
To embed normalized messages:
```bash
python3 scripts/agent_migration_manifest.py \
--source-name chatgpt-export \
--source-kind chatgpt \
--conversation-json /path/to/conversations.json \
--include-conversation-content \
--max-conversation-messages 2000 \
--output /tmp/chatgpt-conversations-with-content.json
```
Content embedding is explicit because exported chat histories can be huge and
private. A future source-specific adapter can add ZIP traversal, attachment
metadata, and provider-specific project/workspace fields while still emitting
the same `conversation_thread` manifest item.
## Recommended apply behavior
A future Odysseus importer should treat the manifest as untrusted user-provided
data and apply it in stages:
1. Show a dry-run summary with counts, warnings, duplicates, and sample items.
2. Back up current `data/` state before writing anything.
3. Import archive documents as documents or another searchable source, not as
memory.
4. Import conversation threads as searchable archived context first, with
citations back to the source thread. Do not turn whole transcripts into
memory.
5. Show memory candidates for review before saving through the normal memory
path.
6. Import skills only after name/category conflict checks.
7. Skip secrets by default. Credentials need explicit, provider-specific flows.
## What belongs in source adapters?
Adapters can be source-specific. The core manifest should not be.
For example, an OpenClaw adapter may know about OpenClaw's workspace files. A
Hermes adapter may know about `~/.hermes/config.yaml` and `~/.hermes/skills`.
A ChatGPT adapter may know about `conversations.json`, uploaded-file metadata,
and image attachment directories. A Claude adapter may know about Claude's
export shape and project boundaries. A generic adapter may only know about
memory JSON, conversation JSON, `SKILL.md`, and Markdown folders.
Nonstandard folders should be adapter details, not required Odysseus concepts.
+129
View File
@@ -0,0 +1,129 @@
# Backup & Restore
Odysseus keeps all of your state in the `data/` directory — the SQLite database
(`app.db`), the Fernet encryption key (`data/.app_key`), the vault, memory, RAG
indexes, personal documents, and uploads. The `scripts/odysseus-backup` tool
snapshots that directory into a single gzip tarball and restores it later.
Snapshots are safe to take while the app is running: SQLite databases are copied
through SQLite's own `.backup` API rather than a raw file copy, so an in-flight
write can't corrupt the snapshot.
> **A snapshot contains your secrets.** The tarball includes the Fernet
> encryption key (`data/.app_key`), the vault, sessions, and any stored
> provider/API tokens — so treat it like a password. Store backups somewhere
> private, never commit them to Git, and prefer an encrypted destination when
> copying them offsite.
## Quick start
Run the tool from the repository root:
```bash
# Create a snapshot → backups/odysseus-backup-<YYYYMMDD-HHMMSS>.tar.gz
./scripts/odysseus-backup snapshot
# List existing snapshots (most recent first)
./scripts/odysseus-backup list
# Check a tarball's integrity without extracting it
./scripts/odysseus-backup verify backups/odysseus-backup-20260101-120000.tar.gz
# Restore (destructive — see the warning below)
./scripts/odysseus-backup restore backups/odysseus-backup-20260101-120000.tar.gz --yes
```
The script depends only on the Python standard library, so any `python3` on your
`PATH` will run it — you don't need the app's virtualenv active.
Every command prints a JSON result. Add `--pretty` for indented output.
## Commands
### `snapshot`
Writes a `tar.gz` of `data/` to `backups/<timestamp>.tar.gz`.
| Flag | Effect |
| --- | --- |
| `--out PATH` | Write to a specific path instead of the default `backups/` location. Must be **outside** `data/`. |
| `--include-research` | Include `data/deep_research/` (skipped by default — research runs are large). |
| `--include-attachments` | Include `data/mail-attachments/` (skipped by default — cached IMAP extractions, re-derivable). |
By default the snapshot includes everything under `data/` **except**
`deep_research/` and `mail-attachments/`. Personal uploads and documents are
included.
```bash
# Snapshot straight to a mounted NAS path
./scripts/odysseus-backup snapshot --out /mnt/nas/odysseus-$(date +%F).tar.gz
# Full snapshot including research runs and mail attachments
./scripts/odysseus-backup snapshot --include-research --include-attachments
```
### `list`
Lists the tarballs in `backups/`, most recent first, with size and modification
time.
### `verify PATH`
Opens the tarball read-only and walks every member to confirm it is intact and
safe to restore. Nothing is extracted. Use this before relying on an old backup
or after copying one across machines.
### `restore PATH --yes`
Overwrites `data/` from a tarball.
> **Restore is destructive.** It replaces the current `data/` directory. `--yes`
> is required so a mistyped command can't wipe your live state.
Restore is not a blind delete: before extracting, the tool **renames your current
`data/` to `data.before-restore-<timestamp>`** in the repository root. If a
restore turns out to be wrong, your previous state is still there — delete the
restored `data/` and rename the stashed directory back. The restore path is also
validated entry-by-entry: archives containing absolute paths, `..` segments,
symlinks, or anything outside `data/` are rejected.
## Scheduling offsite backups
The tarball output composes cleanly with cron and any copy tool. For example, a
nightly snapshot copied offsite:
```cron
0 3 * * * cd /path/to/odysseus && ./scripts/odysseus-backup snapshot --out "/mnt/nas/odysseus-$(date +\%F).tar.gz"
```
Swap the `--out` target for `scp`, `rclone`, `s3cmd`, or similar to push the
snapshot to remote storage.
## Docker vs native installs
The tool reads `data/` and writes `backups/` relative to the repository root, so
where you run it matters:
- **Native installs** — run it from the repo root as shown above. `data/` and
`backups/` are both in the repo directory.
- **Docker** — `docker-compose.yml` bind-mounts the host's `./data` to
`/app/data`, so the live data is also present on the host. **Run the tool on
the host** from the repo root; the snapshot reads the bind-mounted `./data` and
writes to `./backups` on the host. Running it *inside* the container is not
recommended, because `backups/` is not a mounted volume and the tarball would
be lost when the container is recreated.
> **ChromaDB caveat (Docker only).** In the Docker setup, ChromaDB stores its
> vectors in a separate Compose-managed volume (declared as `chromadb-data`),
> **not** under `./data`. `odysseus-backup` therefore does not capture the Docker
> ChromaDB store. Back it up separately if you need it. Compose prefixes the
> volume with the project name, so find the real name first
> (`docker volume ls | grep chromadb`), then archive it — for example:
>
> ```bash
> docker run --rm -v <project>_chromadb-data:/data -v "$PWD":/backup \
> alpine tar czf /backup/chromadb.tar.gz -C /data .
> ```
>
> On native installs ChromaDB lives at `data/chroma/` and is included in the
> snapshot normally.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 52 KiB

+425
View File
@@ -0,0 +1,425 @@
# Odysseus Setup Guide
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
## Quick Start
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
Defaults work out of the box: clone, run, then configure models/search/email
inside **Settings**. Only edit `.env` for deployment-level overrides like
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
On first setup, Odysseus creates an admin account (`admin` unless
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**.
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
pull request guidelines.
### Docker (recommended)
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
cp .env.example .env # optional, but recommended for explicit defaults
docker compose up -d --build
```
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
Open `http://localhost:7000` when the containers are healthy. Docker Compose
binds the web UI to `127.0.0.1` by default. If the port is taken, set
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
only when you intentionally want LAN/reverse-proxy access.
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
> run natively instead — see [Apple Silicon](#apple-silicon) below.
### Native Linux / macOS
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
downloads and serves. The app itself is lightweight; local model serving is the
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
### Apple Silicon
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
M-series Mac, run Odysseus natively:
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
./start-macos.sh
```
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
```bash
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
# then open http://<tailscale-ip>:7860
```
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
set there are picked up automatically without a command-line override each run.
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
expose this port directly to the public internet. To build a clickable app wrapper:
```bash
./build-macos-app.sh
```
<details>
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
they are reachable from the host but not exposed to your LAN/public internet
unless you opt in.
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
serve engines live in `./data/local` (`~/.local` in the container), so they
survive container recreation.
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
Odysseus SSH key and add the public key to the remote server's
`~/.ssh/authorized_keys`. From the host you can also run:
```bash
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
```
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
only detect GPUs that Docker exposes to the container — if the host runtime or
device passthrough is not configured, Cookbook sees the iGPU, another card, or
CPU instead of your intended GPU.
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
optionally install the host runtime or update `.env`.
```bash
# Read-only diagnostic (default — installs nothing, never edits .env):
scripts/check-docker-gpu.sh
# Print OS-specific install commands without running them:
scripts/check-docker-gpu.sh --print-install-commands
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
scripts/check-docker-gpu.sh --install-nvidia-toolkit
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
scripts/check-docker-gpu.sh --enable-nvidia-overlay
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
```
Safety notes:
- The app never installs host GPU runtime automatically.
- The app never edits `.env` automatically.
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
bypass the passthrough gate.
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
Git and the Docker build context.
To enable manually without the script, add this to `.env`:
```bash
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
```
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
```bash
scripts/check-docker-amd-gpu.sh
```
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
numeric render group id:
```bash
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
RENDER_GID=989
```
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
overlay workflow above. For stack UIs, point the stack at one of the standalone
files instead, which bundle the base stack plus the GPU settings:
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
on the host.
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
`video`/`render` group membership, and `RENDER_GID` when needed.
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
source of truth; the standalone files mirror them for single-file deployments.
Verify after enabling either overlay:
```bash
docker compose exec odysseus nvidia-smi -L # NVIDIA
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
```
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
> not a Docker passthrough failure. Reinstall the serve engine via
> **Cookbook → Dependencies** to get a CUDA-enabled build.
>
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
> the container confirms device passthrough, not ROCm userspace or a
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
> inside the slim Odysseus image.
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
Settings:
```text
http://host.docker.internal:11434/v1
```
Ollama must listen outside its own loopback interface:
```bash
OLLAMA_HOST=0.0.0.0:11434 ollama serve
```
This connects Odysseus in Docker to an Ollama server that is already running on
your host machine; it does not start Ollama inside the container.
`host.docker.internal` is Docker's hostname for the host machine from inside the
container. Cookbook **Serve** is a separate workflow for serving downloaded
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
install usually only need to add the endpoint in Settings.
**Useful checks.**
```bash
docker compose ps
docker compose logs --tail=120 odysseus
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
```
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
do not run on macOS. MLX-only models are not served by Odysseus.
</details>
### Native Windows
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
server; safe to re-run):
```powershell
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
```
Or do it by hand:
```powershell
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
py -3.11 -m venv venv
venv\Scripts\Activate.ps1
pip install -r requirements.txt
python setup.py
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
If `python` points at an older interpreter, use `py -3.12` (or another installed
3.11+ version) for the venv step.
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
email, calendar, deep research) runs fully native. For full **Cookbook** background
model downloads and the agent shell tool, also install
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
`http://localhost:11434/v1` in Settings.
Open `http://localhost:7000`, log in with the generated admin password,
and configure everything else inside **Settings**.
## Troubleshooting & Advanced Setup
### `chromadb-client` conflicts with embedded ChromaDB
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
```bash
./venv/bin/pip uninstall chromadb-client -y
./venv/bin/pip install --force-reinstall chromadb
```
### HTTPS + LAN/Tailscale exposure
To expose Odysseus on a local network or Tailscale with HTTPS:
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
```bash
mkcert -install
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
```
3. Run `uvicorn` with the generated certs:
```bash
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
```
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
### Optional Dependencies
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
| Package | Feature unlocked |
|---------|-----------------|
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
| `ddgs` | DuckDuckGo as a search provider option. |
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
### Faster, reproducible installs with uv (optional)
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
```bash
uv venv venv --python 3.13
uv pip install -r requirements.txt
# then continue as usual: python setup.py, uvicorn, ...
```
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
```bash
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
uv pip sync requirements.lock # reproduce it exactly later
```
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
### Outlook / Office 365 email
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
current limitation and the planned integration direction.
## Security Notes
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
### Private or proxied deployments
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
4. Keep raw service and model ports internal-only.
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
Common internal-only ports from the default docs/compose setup:
| Port | Service |
|---|---|
| `7000` | Odysseus raw app port |
| `8080` | SearXNG |
| `8091` | ntfy |
| `8100` | ChromaDB host port for manual/compose access |
| `11434` | Ollama |
| `8000-8020` | Common local model/provider APIs |
## Configuration
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
for deployment-level defaults and secrets you want present before first boot.
Key settings:
| Variable | Default | Description |
|---|---|---|
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. |
| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. |
| `AUTH_ENABLED` | `true` | Enable/disable login |
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
### Built-in MCP servers (optional setup)
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
To enable the browser MCP (page navigation, screenshots, vision), run once:
```bash
npx -y @playwright/mcp@latest --version
```
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
## Architecture
```
app.py # FastAPI entry point
core/ auth, database, middleware, constants
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end)
docs/ landing page (index.html) + preview clips
```
## Data
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
To back up or restore everything in `data/`, see the
[Backup & Restore guide](docs/backup-restore.md).
@@ -102,6 +102,7 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
## Email draft + send
- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
@@ -17,6 +17,11 @@ def _usage() -> int:
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
print(" odysseus_api.py emails read UID", file=sys.stderr)
print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
print(" odysseus_api.py cookbook servers", file=sys.stderr)
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
@@ -79,6 +84,33 @@ def main() -> int:
method = "GET"
path = f"/api/codex/emails/{sys.argv[3]}"
body = None
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/emails/draft-document"
body = " ".join(sys.argv[3:])
else:
return _usage()
elif command in ("documents", "docs"):
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "list":
method = "GET"
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
path = f"/api/codex/documents?limit={limit}"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
elif action == "create" and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/documents"
body = " ".join(sys.argv[3:])
elif action == "delete" and len(sys.argv) >= 4:
method = "DELETE"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
else:
return _usage()
elif command == "cookbook":
@@ -17,6 +17,11 @@ def _usage() -> int:
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
print(" odysseus_api.py emails read UID", file=sys.stderr)
print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
print(" odysseus_api.py cookbook servers", file=sys.stderr)
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
@@ -79,6 +84,33 @@ def main() -> int:
method = "GET"
path = f"/api/codex/emails/{sys.argv[3]}"
body = None
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/emails/draft-document"
body = " ".join(sys.argv[3:])
else:
return _usage()
elif command in ("documents", "docs"):
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "list":
method = "GET"
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
path = f"/api/codex/documents?limit={limit}"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
elif action == "create" and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/documents"
body = " ".join(sys.argv[3:])
elif action == "delete" and len(sys.argv) >= 4:
method = "DELETE"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
else:
return _usage()
elif command == "cookbook":
@@ -102,6 +102,7 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
## Email draft + send
- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
+14 -1
View File
@@ -141,7 +141,20 @@ if (-not (Find-GitBash)) {
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
}
# 6. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
# 6. Point CUDA_PATH at a real CUDA toolkit so GPU llama-cpp-python can import.
$cudaBase = "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA"
if (Test-Path $cudaBase) {
$cudaBest = Get-ChildItem $cudaBase -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName "bin") } |
Sort-Object { try { [version]($_.Name -replace "^v", "") } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($cudaBest) {
$env:CUDA_PATH = $cudaBest.FullName
Write-Host ("Using CUDA_PATH = " + $cudaBest.FullName) -ForegroundColor Cyan
}
}
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
Write-Host "Press Ctrl+C to stop."
Write-Host ""
+102 -1
View File
@@ -885,8 +885,109 @@ def _smtp_connect(account=None, cfg=None):
return conn
def _read_agent_email_confirm_setting() -> bool:
"""True if the user wants agent send_email/reply_to_email calls to be
queued for manual approval instead of SMTPed immediately. Defaults to
True so a fresh install is safe — agents have been observed inventing
signatures and sending to real recipients without the user's review."""
try:
from src.settings import get_setting
return bool(get_setting("agent_email_confirm", True))
except Exception:
return True
def _stash_agent_draft(*, to, subject, body, in_reply_to=None, references=None,
cc=None, bcc=None, account=None) -> dict:
"""Insert the composed email into scheduled_emails with status
'agent_draft' and a far-future send_at so the scheduled-send poller
never picks it up. Returns the pending payload the model surfaces to
the user (and that the chat UI can render as an approval card)."""
try:
from src.constants import SCHEDULED_EMAILS_DB
except Exception:
return {"success": False, "error": "Pending-email storage unavailable"}
pending_id = uuid.uuid4().hex[:16]
far_future = "9999-12-31T00:00:00"
now = datetime.utcnow().isoformat()
try:
conn = sqlite3.connect(SCHEDULED_EMAILS_DB)
# Touch the schema in case the email-routes init hasn't run yet
# (MCP server can boot independently).
conn.execute("""
CREATE TABLE IF NOT EXISTS scheduled_emails (
id TEXT PRIMARY KEY,
to_addr TEXT NOT NULL,
cc TEXT,
bcc TEXT,
subject TEXT,
body TEXT NOT NULL,
in_reply_to TEXT,
references_hdr TEXT,
attachments TEXT,
send_at TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
owner TEXT DEFAULT '',
account_id TEXT,
odysseus_kind TEXT
)
""")
conn.execute("""
INSERT INTO scheduled_emails
(id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr,
attachments, send_at, created_at, status, account_id, odysseus_kind, owner)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'agent_draft', ?, ?, ?)
""", (
pending_id,
to if isinstance(to, str) else ", ".join(to),
cc if isinstance(cc, str) else (", ".join(cc) if cc else None),
bcc if isinstance(bcc, str) else (", ".join(bcc) if bcc else None),
subject or "",
body or "",
in_reply_to or None,
references if isinstance(references, str) else (" ".join(references) if references else None),
"[]",
far_future,
now,
account or None,
"agent_draft",
"",
))
conn.commit()
conn.close()
except Exception as e:
return {"success": False, "error": f"Failed to stash draft: {e}"}
return {
"success": True,
"pending": True,
"pending_id": pending_id,
"to": to if isinstance(to, str) else ", ".join(to),
"subject": subject or "",
"body": body or "",
"message": (
"✋ Draft staged for your approval — nothing has been sent yet.\n"
"Review the To/Subject/Body above. Reply 'send' to deliver, or "
"'cancel' to discard."
),
}
def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, bcc=None, account=None):
"""Send an email via SMTP. Returns dict with status."""
"""Send an email via SMTP. Returns dict with status.
When the `agent_email_confirm` setting is on (the default), the email
is NOT SMTPed — instead it lands in scheduled_emails as an
`agent_draft` row and the user reviews + approves it from the chat
UI. This closes the auto-send hole that let earlier models invent
signatures and ship them to real recipients without confirmation."""
if _read_agent_email_confirm_setting():
return _stash_agent_draft(
to=to, subject=subject, body=body,
in_reply_to=in_reply_to, references=references,
cc=cc, bcc=bcc, account=account,
)
send_account, cfg = _resolve_send_config(account)
msg = EmailMessage()
msg["From"] = _clean_header_value(cfg["from_address"])
+2 -3
View File
@@ -93,16 +93,15 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if category_filter:
msg += f" in category '{category_filter}'"
return [TextContent(type="text", text=msg + ".")]
lines = [f"Found {len(memories)} memory entries:\n"]
for m in memories[:100]:
for m in memories:
cat = m.get("category", "fact")
mid = m.get("id", "?")[:8]
text = m.get("text", "")
if len(text) > 150:
text = text[:150] + "..."
lines.append(f"- [{cat}] `{mid}` — {text}")
if len(memories) > 100:
lines.append(f"... and {len(memories) - 100} more")
return [TextContent(type="text", text="\n".join(lines))]
elif action == "add":
+12 -9
View File
@@ -5,16 +5,16 @@
"packages": {
"": {
"dependencies": {
"@anthropic-ai/sdk": "^0.98.0"
"@anthropic-ai/sdk": "^0.104.1"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.3.2"
"@antithesishq/bombadil": "^0.5.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.98.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz",
"integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==",
"version": "0.104.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
@@ -33,11 +33,14 @@
}
},
"node_modules/@antithesishq/bombadil": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.3.2.tgz",
"integrity": "sha512-ATy1w9ZY5gbny1H8DFc7rxZitT7DLLLFDiGcRZe+8TQiUrV5tLO+IJGOVNNLp3RpCqjZqSsxGiKoQsx31ipV1g==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz",
"integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"bin": {
"bombadil": "bin/bombadil.js"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
+2 -2
View File
@@ -4,9 +4,9 @@
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.3.2"
"@antithesishq/bombadil": "^0.5.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.98.0"
"@anthropic-ai/sdk": "^0.104.1"
}
}
+1 -1
View File
@@ -33,4 +33,4 @@ PyMuPDF
# magika (onnxruntime), already a core dep via fastembed. We avoid the
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
# the dependency-age discussion in issue #485.
markitdown[docx,pptx,xlsx,xls]==0.1.5
markitdown[docx,pptx,xlsx,xls]==0.1.6
+2 -2
View File
@@ -3,8 +3,8 @@ uvicorn
python-multipart
python-dotenv
httpx
pydantic>=2.0
pydantic-settings>=2.0
pydantic>=2.13.4
pydantic-settings>=2.14.1
SQLAlchemy
pypdf
beautifulsoup4
+1
View File
@@ -31,6 +31,7 @@ ALLOWED_SCOPES = {
TOKEN_PROFILES = {
"chat": ["chat"],
"codex_todos": ["todos:read", "todos:write"],
"codex_documents": ["documents:read", "documents:write"],
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
}
+31 -1
View File
@@ -12,7 +12,7 @@ import re
from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager
from core.auth import AuthManager, SetAdminResult
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
@@ -73,6 +73,11 @@ class DeleteUserRequest(BaseModel):
class RenameUserRequest(BaseModel):
username: str
class SetAdminRequest(BaseModel):
is_admin: bool
class SetOpenRegistrationRequest(BaseModel):
enabled: bool
@@ -487,6 +492,31 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
invalidator()
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
@router.put("/users/{username}/admin")
async def set_user_admin(username: str, body: SetAdminRequest, request: Request):
"""Promote/demote a user to/from admin. Admin only.
The last remaining admin can't be demoted (no lockout). Self-demotion
is allowed while another admin exists; the `self` flag tells the UI to
reload the acting user into the normal-user view.
"""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
result = auth_manager.set_admin(username, body.is_admin, user)
if result is SetAdminResult.USER_NOT_FOUND:
raise HTTPException(404, "User not found")
if result is SetAdminResult.NOT_AUTHORIZED:
raise HTTPException(403, "Admin only")
if result is SetAdminResult.LAST_ADMIN:
raise HTTPException(400, "Cannot demote the last admin")
target = (username or "").strip().lower()
return {
"ok": True,
"is_admin": body.is_admin,
"self": target == (user or "").strip().lower(),
}
@router.post("/signup-toggle", deprecated=True)
async def toggle_signup(request: Request):
"""
+65 -29
View File
@@ -11,7 +11,7 @@ from pydantic import BaseModel
from sqlalchemy import or_, and_
from dateutil.rrule import rrulestr
from core.database import SessionLocal, CalendarCal, CalendarEvent
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
from src.auth_helpers import require_user
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
@@ -126,6 +126,54 @@ def _resolve_base_uid(uid: str) -> str:
raise ValueError("malformed compound UID: missing base before ::")
return base
async def _push_caldav_event_after_commit(owner: str, uid: str, action: str):
"""Best-effort CalDAV write-through. Local writes stay authoritative if
the remote server is unreachable; pending flags let /sync retry later."""
try:
result = {"ok": True}
if action == "create":
from src.caldav_sync import push_event_create
result = await push_event_create(owner, uid)
elif action == "update":
from src.caldav_sync import push_event_update
result = await push_event_update(owner, uid)
elif action == "delete":
from src.caldav_sync import push_event_delete
result = await push_event_delete(owner, uid)
if result and not result.get("ok") and not result.get("skipped"):
raise RuntimeError(result.get("error") or result)
except Exception as e:
logger.warning("CalDAV %s push failed for uid=%s: %s", action, uid, e)
if action in {"create", "update"}:
db = SessionLocal()
try:
ev = _get_or_404_event(db, uid, owner)
ev.caldav_sync_pending = action
db.commit()
except Exception:
db.rollback()
finally:
db.close()
def _record_caldav_delete_tombstone(db, ev: CalendarEvent, owner: str) -> None:
if not (ev.calendar and ev.calendar.source == "caldav"):
return
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == ev.uid,
CalendarDeletedEvent.owner == owner,
).first()
if not tombstone:
tombstone = CalendarDeletedEvent(uid=ev.uid, owner=owner)
db.add(tombstone)
tombstone.calendar_id = ev.calendar_id
tombstone.remote_href = ev.remote_href
tombstone.remote_etag = ev.remote_etag
tombstone.caldav_base_url = getattr(ev.calendar, "caldav_base_url", None)
tombstone.summary = ev.summary or ""
tombstone.last_error = None
# ── Pydantic models ──
class EventCreate(BaseModel):
@@ -843,13 +891,13 @@ def setup_calendar_routes() -> APIRouter:
return {"ok": False, "error": str(e)[:200]}
@router.post("/sync")
async def sync_caldav_endpoint(request: Request):
"""Pull events from the configured CalDAV server into local DB.
async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
"""Sync events with the configured CalDAV server.
Returns counts + any per-calendar errors. Called by the frontend
on calendar open and by the periodic scheduler loop."""
owner = _require_user(request)
from src.caldav_sync import sync_caldav
return await sync_caldav(owner)
from src.caldav_sync import sync_caldav_direction
return await sync_caldav_direction(owner, direction)
@router.delete("/calendars/{cal_id}")
@@ -1002,19 +1050,12 @@ def setup_calendar_routes() -> APIRouter:
is_utc=_is_utc and not data.all_day,
rrule=data.rrule or "",
color=data.color or None,
caldav_sync_pending="create" if cal.source == "caldav" else None,
)
db.add(ev)
db.commit()
if cal.source == "caldav":
# Push the new event to the remote so it appears on the user's
# other devices — the sync is otherwise pull-only (#800).
from src.caldav_writeback import writeback_event
await writeback_event(owner, cal.source, cal.id, {
"uid": uid, "summary": data.summary, "description": data.description,
"location": data.location, "dtstart": dtstart, "dtend": dtend,
"all_day": data.all_day, "is_utc": _is_utc and not data.all_day,
"rrule": data.rrule or "",
})
await _push_caldav_event_after_commit(owner, uid, "create")
return {"ok": True, "uid": uid}
except HTTPException:
raise
@@ -1060,15 +1101,12 @@ def setup_calendar_routes() -> APIRouter:
ev.rrule = data.rrule
if data.color is not None:
ev.color = data.color if data.color else None
is_caldav = ev.calendar and ev.calendar.source == "caldav"
if is_caldav:
ev.caldav_sync_pending = "update"
db.commit()
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
if cal and cal.source == "caldav":
from src.caldav_writeback import writeback_event
await writeback_event(owner, cal.source, cal.id, {
"uid": ev.uid, "summary": ev.summary, "description": ev.description,
"location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend,
"all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "",
})
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "update")
return {"ok": True}
except HTTPException:
raise
@@ -1089,15 +1127,13 @@ def setup_calendar_routes() -> APIRouter:
db = SessionLocal()
try:
ev = _get_or_404_event(db, base_uid, owner)
# Capture what the remote push needs BEFORE the row is gone.
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
_is_caldav = bool(_cal and _cal.source == "caldav")
_cal_id, _ev_uid = ev.calendar_id, ev.uid
is_caldav = ev.calendar and ev.calendar.source == "caldav"
if is_caldav:
_record_caldav_delete_tombstone(db, ev, owner)
db.delete(ev)
db.commit()
if _is_caldav:
from src.caldav_writeback import writeback_event
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "delete")
return {"ok": True}
except HTTPException:
raise
+33 -2
View File
@@ -505,6 +505,29 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
return None
def _session_is_research_spinoff(sess) -> bool:
"""True if this session was created via research "Discuss" spin-off.
Detected by the primer system message the spin-off endpoint seeds into
history (metadata ``research_spinoff_from``). Such sessions are grounded
on the seeded report, so global memory + personal-doc RAG injection is
suppressed for them (the report is the sole knowledge base). Handles both
ChatMessage objects and plain dicts.
"""
for m in getattr(sess, "history", []) or []:
role = getattr(m, "role", None)
if role is None and isinstance(m, dict):
role = m.get("role")
if role != "system":
continue
md = getattr(m, "metadata", None)
if md is None and isinstance(m, dict):
md = m.get("metadata")
if (md or {}).get("research_spinoff_from"):
return True
return False
async def build_chat_context(
sess,
request,
@@ -570,9 +593,17 @@ async def build_chat_context(
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
)
# Research-spinoff ("Discuss") sessions are grounded on the seeded report:
# the primer system message IS the knowledge base. Injecting global memory
# or personal-doc RAG on every turn pulls in keyword-matched but off-topic
# facts ("wrong data") and competes with the report, so suppress both here.
is_research_spinoff = _session_is_research_spinoff(sess)
if is_research_spinoff:
mem_enabled = False
# Use RAG?
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
if incognito or not allow_tool_preprocessing:
if incognito or not allow_tool_preprocessing or is_research_spinoff:
use_rag_val = False
# If pre-fetched search context was provided (compare mode), skip live web search
@@ -595,7 +626,7 @@ async def build_chat_context(
incognito=incognito,
use_skills=skills_enabled,
)
if use_rag is not None:
if use_rag is not None or is_research_spinoff:
_preface_kwargs["use_rag"] = use_rag_val
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
+97 -9
View File
@@ -6,7 +6,7 @@ import os
import time
import logging
from datetime import datetime
from typing import Dict, Any, AsyncGenerator, List
from typing import Dict, Any, AsyncGenerator, List, Optional
from fastapi import APIRouter, Request, HTTPException, Form, Query
from fastapi.responses import StreamingResponse
@@ -526,6 +526,66 @@ def setup_chat_routes(
active_doc_id = form_data.get("active_doc_id", "").strip()
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
# Active email reader — when the user has an email open in the UI, the
# frontend passes its uid/folder/account so "reply", "summarize this",
# etc. resolve to the real email instead of the agent inventing a
# fake markdown draft.
active_email_uid = form_data.get("active_email_uid", "").strip()
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
active_email_account = form_data.get("active_email_account", "").strip()
active_email_ctx: Optional[Dict[str, str]] = None
# Always reset between requests so a stale active-email pointer from
# a previous turn (different reader closed, different account, etc.)
# can't leak in when the user has no email open this turn.
try:
from src.tool_implementations import clear_active_email
clear_active_email()
except Exception:
pass
if active_email_uid:
active_email_ctx = {
"uid": active_email_uid,
"folder": active_email_folder,
"account": active_email_account,
}
# Try to enrich with subject + from so the agent's system prompt
# block can quote them. Best-effort: a stale cache is fine, a
# missing email just means we pass uid/folder/account only.
try:
from routes.email_routes import _read_cache_get, _read_cache_key
_ck = _read_cache_key(active_email_account or None, active_email_folder, active_email_uid, owner=get_current_user(request))
_cached_email = _read_cache_get(_ck)
if _cached_email and isinstance(_cached_email, dict):
active_email_ctx["subject"] = str(_cached_email.get("subject") or "")
active_email_ctx["from"] = str(
_cached_email.get("from_address")
or _cached_email.get("from")
or _cached_email.get("from_name")
or ""
)
_body_preview = (_cached_email.get("body") or "")[:2000]
if _body_preview:
active_email_ctx["body_preview"] = _body_preview
except Exception as _e:
logger.debug(f"[email-inject] cache enrich skipped: {_e}")
# Stash so email tools can resolve "this email" without UID guessing.
try:
from src.tool_implementations import set_active_email
set_active_email(
uid=active_email_uid,
folder=active_email_folder,
account=active_email_account or None,
subject=active_email_ctx.get("subject"),
sender=active_email_ctx.get("from"),
)
except Exception as _e:
logger.debug(f"[email-inject] set_active_email failed: {_e}")
logger.info(
"[email-inject] active_email uid=%s folder=%s account=%s subject=%r",
active_email_uid, active_email_folder, active_email_account or "(default)",
active_email_ctx.get("subject", ""),
)
try:
# Attachment-only sends: skip the message-required check when the
# user has attached one or more files (the attachment IS the action).
@@ -641,15 +701,27 @@ def setup_chat_routes(
active_doc_id,
)
active_doc = None
elif doc_session and doc_session != session:
logger.warning(
"[doc-inject] ignoring stale active_doc_id %s from session %s while in session %s",
active_doc_id,
doc_session,
session,
)
active_doc = None
else:
# NOTE: previously dropped the doc when doc.session_id
# != current chat session — but that broke the common
# case of "open an email draft from one chat, ask a
# different chat to write into it". The frontend only
# sends active_doc_id for docs currently visible in
# the UI, and we already owner-checked above, so trust
# the explicit signal. We just log the mismatch and
# re-bind the doc to the current session so future
# turns find it via the session-fallback path too.
if doc_session and doc_session != session:
logger.info(
"[doc-inject] cross-session active_doc_id %s (was session %s, now %s) — accepting and rebinding",
active_doc_id, doc_session, session,
)
try:
active_doc.session_id = session
_doc_db.commit()
except Exception as _e:
_doc_db.rollback()
logger.warning(f"[doc-inject] session rebind failed: {_e}")
logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
else:
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
@@ -714,6 +786,21 @@ def setup_chat_routes(
"manage_skills", # skill presets tied to user
})
# Active email reader open → strip the tools that let the agent
# "drift" to a new compose: create_document (writes a fake email-
# shaped .md file) and send_email (sends fresh to a recipient the
# agent invented). With those gone, the only paths left for "write
# email saying X" are ui_control open_email_reply (draft) and
# reply_to_email (immediate send) — both of which use the open
# email's UID. Code-level enforcement instead of relying on a
# prompt rule the model can ignore.
if active_email_ctx and active_email_ctx.get("uid"):
disabled_tools.update({
"create_document",
"send_email",
"mcp__email__send_email",
})
# Enforce per-user privileges
_privs = {}
_user = ctx.user
@@ -1181,6 +1268,7 @@ def setup_chat_routes(
max_rounds=_max_rounds,
context_length=ctx.context_length,
active_document=active_doc,
active_email=active_email_ctx,
session_id=session,
disabled_tools=disabled_tools if disabled_tools else None,
tool_policy=tool_policy,
+66 -2
View File
@@ -91,6 +91,20 @@ def _scope_owner(request: Request, allowed: set[str]) -> str:
return require_user(request)
def _scope_owner_all(request: Request, required: set[str]) -> str:
"""Return owner only when an API token has every required scope."""
if getattr(request.state, "api_token", False):
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
missing = required - scopes
if missing:
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
owner = getattr(request.state, "api_token_owner", None)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_user(request)
def _find_endpoint(router: APIRouter | None, method: str, path: str):
if router is None:
return None
@@ -138,7 +152,7 @@ def setup_codex_routes(
"read": scoped(EMAIL_READ_SCOPES),
"draft": scoped(EMAIL_DRAFT_SCOPES),
"send": scoped(EMAIL_SEND_SCOPES),
"actions": ["list", "read", "draft", "send"],
"actions": ["list", "read", "draft_document", "draft", "send"],
},
"memory": {
"read": scoped(MEMORY_READ_SCOPES),
@@ -262,6 +276,56 @@ def setup_codex_routes(
# Both handlers in routes/email_routes.py already accept `owner=` via
# FastAPI Depends, so we call them directly without patching state.
def _email_draft_document_content(body: dict[str, Any]) -> str:
def clean(v: Any) -> str:
if isinstance(v, list):
return ", ".join(str(x).strip() for x in v if str(x).strip())
return str(v or "").strip()
to = clean(body.get("to"))
cc = clean(body.get("cc"))
bcc = clean(body.get("bcc"))
subject = clean(body.get("subject"))
in_reply_to = clean(body.get("in_reply_to"))
references = clean(body.get("references"))
body_text = str(body.get("body") or body.get("body_html") or "").strip()
lines = [
f"To: {to}",
]
if cc:
lines.append(f"Cc: {cc}")
if bcc:
lines.append(f"Bcc: {bcc}")
lines.append(f"Subject: {subject}")
if in_reply_to:
lines.append(f"In-Reply-To: {in_reply_to}")
if references:
lines.append(f"References: {references}")
lines.extend(["---", body_text])
return "\n".join(lines).rstrip() + "\n"
@router.post("/emails/draft-document")
async def codex_email_draft_document(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
owner = _scope_owner_all(request, {"email:draft", "documents:write"})
if documents_create_endpoint is None:
raise HTTPException(503, "Documents integration is not available")
from routes.document_routes import DocumentCreate
subject = str(body.get("subject") or "Email draft").strip() or "Email draft"
title = str(body.get("title") or subject).strip() or "Email draft"
req = DocumentCreate(
session_id=body.get("session_id"),
title=title,
language="email",
content=_email_draft_document_content(body),
)
result = await _as_owner(request, owner, documents_create_endpoint, request, req)
if isinstance(result, dict):
result = dict(result)
result["draft_type"] = "document"
result["send_required_confirmation"] = True
return result
@router.post("/emails/draft")
async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)
@@ -726,7 +790,7 @@ def setup_codex_routes(
norm = dict(body or {})
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
model = (norm.get("model") or norm.get("repo_id") or "").strip()
host = (norm.get("host") or norm.get("remote_host") or "").strip()
host = validate_remote_host((norm.get("host") or norm.get("remote_host") or "").strip() or None) or ""
port = norm.get("port") or 8000
import re as _re
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
+75 -21
View File
@@ -12,6 +12,7 @@ import json
import csv
import io
import os
import inspect
import httpx
from pathlib import Path
from datetime import datetime
@@ -45,10 +46,14 @@ def _save_settings(settings):
def _get_carddav_config():
import os
settings = _load_settings()
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
if password and "carddav_password" in settings:
from src.secret_storage import decrypt
password = decrypt(password)
return {
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
"password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")),
"password": password,
}
@@ -86,11 +91,13 @@ def _normalize_contact(contact: Dict) -> Dict:
name = str(contact.get("name") or "").strip()
if not name and emails:
name = emails[0].split("@")[0]
address = str(contact.get("address") or "").strip()
return {
"uid": str(contact.get("uid") or uuid.uuid4()),
"name": name,
"emails": emails,
"phones": phones,
"address": address,
}
@@ -146,7 +153,7 @@ def _parse_vcards(text: str) -> List[Dict]:
for block in re.split(r"BEGIN:VCARD", text):
if not block.strip():
continue
contact = {"name": "", "emails": [], "phones": [], "uid": ""}
contact = {"name": "", "emails": [], "phones": [], "uid": "", "address": ""}
for line in block.split("\n"):
line = line.strip()
# Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...")
@@ -169,6 +176,15 @@ def _parse_vcards(text: str) -> List[Dict]:
phone = _vunesc(name_part.split(":", 1)[1])
if phone and phone not in contact["phones"]:
contact["phones"].append(phone)
elif name_part.startswith("ADR"):
# vCard ADR is 7 semicolon-separated components:
# post-office-box;extended-address;street;locality;region;postal-code;country.
# Recover a human-readable string by joining non-empty
# components with ", ".
if ":" in name_part:
raw = name_part.split(":", 1)[1]
parts = [_vunesc(p).strip() for p in raw.split(";")]
contact["address"] = ", ".join(p for p in parts if p)
elif name_part.startswith("UID:"):
contact["uid"] = _vunesc(name_part[4:])
if contact["name"] or contact["emails"]:
@@ -193,7 +209,8 @@ def _vesc(value: str) -> str:
def _build_vcard(name: str, email: str, uid: Optional[str] = None,
emails: Optional[List[str]] = None,
phones: Optional[List[str]] = None) -> str:
phones: Optional[List[str]] = None,
address: Optional[str] = None) -> str:
"""Build a vCard. Accepts either a single `email` (legacy callers) or
full `emails`/`phones` lists (edit path). The first email is marked
PREF=1. All values are RFC-6350-escaped."""
@@ -226,6 +243,12 @@ def _build_vcard(name: str, email: str, uid: Optional[str] = None,
lines.append(f"EMAIL;PREF=1:{_vesc(em)}" if i == 0 else f"EMAIL:{_vesc(em)}")
for ph in phone_list:
lines.append(f"TEL:{_vesc(ph)}")
# Address: stuff the whole human-readable string into the street
# component of ADR. vCard ADR has 7 semicolon-separated components:
# post-office-box;extended-address;street;locality;region;postal-code;country.
addr = (address or "").strip()
if addr:
lines.append(f"ADR:;;{_vesc(addr)};;;;")
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
@@ -362,7 +385,7 @@ def _resolve_resource_url(uid: str) -> str:
return _lookup() or _vcard_url(uid)
def _create_contact(name: str, email: str) -> bool:
def _create_contact(name: str, email: str, address: str = "") -> bool:
"""Add a new contact via CardDAV or local contacts."""
cfg = _get_carddav_config()
if not _carddav_configured(cfg):
@@ -371,12 +394,12 @@ def _create_contact(name: str, email: str) -> bool:
for c in contacts:
if email_l and email_l in [e.lower() for e in c.get("emails", [])]:
return True
contacts.append(_normalize_contact({"name": name, "emails": [email]}))
contacts.append(_normalize_contact({"name": name, "emails": [email], "address": address}))
_save_local_contacts(contacts)
return True
contact_uid = str(uuid.uuid4())
vcard = _build_vcard(name, email, contact_uid)
vcard = _build_vcard(name, email, contact_uid, address=address)
try:
url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf"
auth = None
@@ -609,7 +632,7 @@ def _contacts_to_csv(contacts: List[Dict]) -> str:
return out.getvalue()
def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -> bool:
def _update_contact(uid: str, name: str, emails: List[str], phones: List[str], address: str = "") -> bool:
"""Rewrite an existing contact via CardDAV or local contacts."""
cfg = _get_carddav_config()
if not _carddav_configured(cfg):
@@ -618,16 +641,19 @@ def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -
out = []
for c in contacts:
if c.get("uid") == uid:
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones}))
# Preserve existing address when caller passes "" (only
# updating name/emails/phones, not touching address).
addr = address if address else c.get("address", "")
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": addr}))
found = True
else:
out.append(c)
if not found:
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones}))
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": address}))
_save_local_contacts(out)
return True
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones)
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones, address=address)
# Use the real resource href (handles externally-created contacts whose
# filename != UID); falls back to the <uid>.vcf guess.
try:
@@ -714,16 +740,39 @@ def setup_contacts_routes():
"""Add a new contact."""
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip()
phone = (data.get("phone") or "").strip()
address = (data.get("address") or "").strip()
if not email:
return {"success": False, "error": "Email required"}
# Check if already exists
contacts = _fetch_contacts()
for c in contacts:
if email.lower() in [e.lower() for e in c["emails"]]:
return {"success": True, "message": "Already exists", "contact": c}
# Check if already exists by email
if email:
contacts = _fetch_contacts()
for c in contacts:
if email.lower() in [e.lower() for e in c["emails"]]:
return {"success": True, "message": "Already exists", "contact": c}
if not name:
name = email.split("@")[0]
ok = _create_contact(name, email)
create_params = inspect.signature(_create_contact).parameters
if len(create_params) >= 3:
ok = _create_contact(name, email, address)
else:
ok = _create_contact(name, email)
# If a phone was provided, do an immediate update to thread it
# through (the simple _create_contact signature only takes name +
# email + address; phones happen via update).
if ok and phone:
try:
fresh = _fetch_contacts(force=True)
created = next((c for c in fresh if name == c.get("name") and (not email or email in c.get("emails", []))), None)
if created:
_update_contact(
created["uid"], name,
created.get("emails", []),
[phone],
address,
)
except Exception:
pass
return {"success": ok}
@router.post("/import")
@@ -785,7 +834,11 @@ def setup_contacts_routes():
except ValueError as e:
raise HTTPException(400, str(e))
else:
settings[key] = data[key]
value = data[key]
if key == "carddav_password" and value:
from src.secret_storage import encrypt
value = encrypt(value)
settings[key] = value
_save_settings(settings)
# Force re-fetch
_contact_cache["fetched_at"] = None
@@ -802,7 +855,7 @@ def setup_contacts_routes():
# match PUT /{uid} with uid="config".
@router.put("/{uid}")
async def edit_contact(uid: str, data: dict, _admin: str = Depends(require_admin)):
"""Edit an existing contact — name / emails / phones."""
"""Edit an existing contact — name / emails / phones / address."""
name = (data.get("name") or "").strip()
emails = data.get("emails")
phones = data.get("phones")
@@ -810,11 +863,12 @@ def setup_contacts_routes():
emails = [data["email"]]
emails = [e.strip() for e in (emails or []) if e and e.strip()]
phones = [p.strip() for p in (phones or []) if p and p.strip()]
if not name and not emails:
return {"success": False, "error": "Name or email required"}
address = (data.get("address") or "").strip()
if not name and not emails and not address:
return {"success": False, "error": "Name, email, or address required"}
if not name and emails:
name = emails[0].split("@")[0]
ok = _update_contact(uid, name, emails, phones)
ok = _update_contact(uid, name, emails, phones, address)
return {"success": ok}
@router.delete("/{uid}")
+6 -1
View File
@@ -362,7 +362,12 @@ def _user_shell_path_bootstrap() -> list[str]:
' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"',
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
'fi',
'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }',
# Windows can expose python3 as a Microsoft Store App Execution Alias
# under WindowsApps. Git Bash sees that stub as present, but it exits
# before running Python. A Windows venv usually has python.exe, not
# python3.exe, so treat a missing or WindowsApps python3 as absent.
'_odys_py3="$(command -v python3 2>/dev/null || true)"',
'case "$_odys_py3" in ""|*[Ww]indows[Aa]pps*) python3() { python "$@"; } ;; esac',
'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }',
]
+193 -6
View File
@@ -676,7 +676,7 @@ def setup_cookbook_routes() -> APIRouter:
_spf = f"-p {_port} " if _port and _port != "22" else ""
setup_cmd = (
f"scp -O {_pf}-q '{runner_path}' {remote}:{remote_runner} && "
f"ssh {_spf}{remote} 'chmod +x {remote_runner} && tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
f"ssh {_spf}{remote} 'chmod +x {remote_runner} && tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
)
else:
# Local: run hf download in the background (tmux on POSIX, a detached
@@ -708,7 +708,7 @@ def setup_cookbook_routes() -> APIRouter:
lines.append('exec "${SHELL:-/bin/bash}"')
wrapper_script.write_text("\n".join(lines) + "\n", encoding="utf-8")
wrapper_script.chmod(0o755)
setup_cmd = None if IS_WINDOWS else f"tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}"
setup_cmd = None if IS_WINDOWS else f"tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}"
logger.info(f"Model download: {req.repo_id} (backend={'ollama' if is_ollama_download else 'hf'}, include={req.include}, session={session_id}, remote={remote})")
logger.info(f"Download setup_cmd: {setup_cmd}")
@@ -984,9 +984,9 @@ def setup_cookbook_routes() -> APIRouter:
ssh_args = ["ssh"]
if ssh_port and ssh_port != "22":
ssh_args.extend(["-p", str(ssh_port)])
capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"]
capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-2000"]
else:
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"]
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-2000"]
_exit_re = re.compile(r"=== Process exited with code (-?\d+) ===")
for wait_s in _waits:
@@ -1577,10 +1577,10 @@ def setup_cookbook_routes() -> APIRouter:
setup_cmd = (
f"{scp_extras}"
f"scp -O {_Pf}-q '{runner_path}' {remote}:{remote_runner} && "
f"ssh {_pf}{remote} 'chmod +x {remote_runner} && tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
f"ssh {_pf}{remote} 'chmod +x {remote_runner} && tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
)
else:
setup_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(runner_path))}"
setup_cmd = f"tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} {shlex.quote(str(runner_path))}"
if setup_cmd is None:
# LOCAL Windows: launch the bash runner detached; no tmux setup_cmd.
@@ -2625,6 +2625,193 @@ def setup_cookbook_routes() -> APIRouter:
"error": _ollama_library_cache["error"],
}
# ── vLLM recipe scraper ─────────────────────────────────────────────
# Fetches the official YAML recipe for a model from vllm-project/recipes
# and normalizes it into a small JSON the frontend can consume. Cached
# per-repo so the GitHub raw endpoint isn't hammered.
_vllm_recipe_cache: dict[str, tuple[float, dict | None]] = {}
# Manifest of all <org>/<model> ids that have a recipe in the upstream
# repo. Cheap to fetch (one Git Tree API call), so we cache the whole
# set for ~12h. Per-row "does this model have a recipe?" lookups hit
# this set instead of doing 912 individual recipe fetches.
_vllm_recipe_manifest: dict = {"fetched_at": 0.0, "models": set(), "error": ""}
@router.get("/api/cookbook/vllm-recipe-manifest")
async def vllm_recipe_manifest(refresh: int = 0):
"""Return the set of <org>/<model> ids known to have a vLLM recipe.
One GitHub Tree API call, 12h cache. The frontend uses this to badge
rows in the model list before the user expands them."""
import time as _time
import httpx as _httpx
TTL = 12 * 3600.0
now = _time.time()
if (
refresh
or (now - _vllm_recipe_manifest["fetched_at"]) > TTL
or not _vllm_recipe_manifest["models"]
):
url = (
"https://api.github.com/repos/vllm-project/recipes/"
"git/trees/main?recursive=1"
)
def _fetch_sync() -> tuple[int, dict | None, str]:
try:
headers = {"Accept": "application/vnd.github+json"}
with _httpx.Client(timeout=10.0, follow_redirects=True) as client:
r = client.get(url, headers=headers)
if r.status_code != 200:
return r.status_code, None, r.text[:200]
return 200, r.json(), ""
except Exception as e:
return 0, None, f"fetch error: {e}"
status, data, err = await asyncio.to_thread(_fetch_sync)
if status == 200 and isinstance(data, dict):
models: set[str] = set()
for entry in data.get("tree") or []:
path = (entry or {}).get("path") or ""
if not path.startswith("models/") or not path.endswith(".yaml"):
continue
# path = "models/<org>/<model>.yaml" → "<org>/<model>"
body = path[len("models/"):-len(".yaml")]
if "/" in body:
models.add(body)
_vllm_recipe_manifest["models"] = models
_vllm_recipe_manifest["fetched_at"] = now
_vllm_recipe_manifest["error"] = ""
else:
_vllm_recipe_manifest["error"] = (
f"HTTP {status}: {err}" if status else err
)
# Don't clobber a stale-but-usable list on transient failures.
if not _vllm_recipe_manifest["models"]:
return {
"models": [],
"count": 0,
"error": _vllm_recipe_manifest["error"],
}
return {
"models": sorted(_vllm_recipe_manifest["models"]),
"count": len(_vllm_recipe_manifest["models"]),
"fetched_at": _vllm_recipe_manifest["fetched_at"],
"error": _vllm_recipe_manifest["error"],
}
@router.get("/api/cookbook/vllm-recipe")
async def vllm_recipe(repo: str, refresh: int = 0):
"""Return the vLLM official recipe for a HuggingFace repo, if one
exists at vllm-project/recipes. `repo` is the full HF id like
'MiniMaxAI/MiniMax-M2'. Cached 6h."""
import time as _time
import httpx as _httpx
import yaml as _yaml
TTL = 6 * 3600.0
now = _time.time()
repo = (repo or "").strip().strip("/")
if "/" not in repo:
return {"exists": False, "error": "repo must be <org>/<model>"}
cached = _vllm_recipe_cache.get(repo)
if cached and not refresh and (now - cached[0]) < TTL:
return cached[1] or {"exists": False, "cached": True}
url = (
f"https://raw.githubusercontent.com/vllm-project/recipes/"
f"main/models/{repo}.yaml"
)
def _fetch_sync() -> tuple[int, str]:
try:
with _httpx.Client(timeout=8.0, follow_redirects=True) as client:
r = client.get(url)
return r.status_code, r.text
except Exception as e:
return 0, f"fetch error: {e}"
status, text = await asyncio.to_thread(_fetch_sync)
if status == 404:
_vllm_recipe_cache[repo] = (now, {"exists": False})
return {"exists": False}
if status != 200:
return {"exists": False, "error": f"HTTP {status}", "transient": True}
try:
doc = _yaml.safe_load(text) or {}
except Exception as e:
return {"exists": False, "error": f"yaml parse: {e}"}
meta = doc.get("meta") or {}
model = doc.get("model") or {}
features = doc.get("features") or {}
deps = doc.get("dependencies") or []
variants = doc.get("variants") or {}
hw_overrides = doc.get("hardware_overrides") or {}
strat_overrides = doc.get("strategy_overrides") or {}
# Tool-call + reasoning parsers, as flat arg arrays, so the frontend
# can drop them straight into the launch command.
tool_calling = features.get("tool_calling") or {}
reasoning = features.get("reasoning") or {}
normalized = {
"exists": True,
"source_url": url,
"title": meta.get("title") or "",
"provider": meta.get("provider") or "",
"description": meta.get("description") or "",
"date_updated": str(meta.get("date_updated") or ""),
"hardware_support": meta.get("hardware") or {},
"model_id": model.get("model_id") or repo,
"min_vllm_version": model.get("min_vllm_version") or "",
"architecture": model.get("architecture") or "",
"parameter_count": model.get("parameter_count") or "",
"active_parameters": model.get("active_parameters") or "",
"context_length": model.get("context_length") or 0,
"base_args": list(model.get("base_args") or []),
"base_env": dict(model.get("base_env") or {}),
"tool_calling": {
"description": tool_calling.get("description") or "",
"args": list(tool_calling.get("args") or []),
} if tool_calling else None,
"reasoning": {
"description": reasoning.get("description") or "",
"args": list(reasoning.get("args") or []),
} if reasoning else None,
"dependencies": [
{
"note": (d.get("note") or "").strip(),
"command": (d.get("command") or "").strip(),
"optional": bool(d.get("optional", False)),
}
for d in deps if isinstance(d, dict)
],
"variants": {
k: {
"model_id": v.get("model_id") or model.get("model_id") or repo,
"precision": v.get("precision") or "",
"vram_minimum_gb": v.get("vram_minimum_gb") or 0,
"description": v.get("description") or "",
"extra_args": list(v.get("extra_args") or []),
"extra_env": dict(v.get("extra_env") or {}),
}
for k, v in variants.items() if isinstance(v, dict)
},
"hardware_overrides": {
hw: {
"extra_args": list((ov or {}).get("extra_args") or []),
"extra_env": dict((ov or {}).get("extra_env") or {}),
}
for hw, ov in hw_overrides.items() if isinstance(ov, dict)
},
"strategy_overrides": {
strat: dict(ov or {})
for strat, ov in strat_overrides.items() if isinstance(ov, dict)
},
"compatible_strategies": list(doc.get("compatible_strategies") or []),
}
_vllm_recipe_cache[repo] = (now, normalized)
return normalized
@router.get("/api/cookbook/tasks/status")
async def cookbook_tasks_status(request: Request):
"""Check status of all active cookbook tmux sessions.
+26 -1
View File
@@ -1,12 +1,13 @@
"""Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research."""
import logging
import os
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Form, Request
from services.youtube.youtube_handler import extract_youtube_id, extract_transcript_async
from core.constants import DEFAULT_HOST
from core.constants import DEFAULT_HOST, DATA_DIR
from core.middleware import require_admin
logger = logging.getLogger(__name__)
@@ -28,6 +29,30 @@ def setup_diagnostics_routes(
from src.service_health import collect_service_health
return await collect_service_health(rag_manager, memory_vector)
@router.get("/api/diagnostics/logs")
async def get_diagnostics_logs(request: Request, limit: int = 200) -> Dict[str, Any]:
require_admin(request)
limit = max(1, min(limit, 1000))
try:
log_file = os.path.join(DATA_DIR, "logs", "app.log")
if not os.path.exists(log_file):
return {"status": "success", "logs": []}
# Safe tail read of the log file (max 5MB via rotation)
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
tail_lines = lines[-limit:] if len(lines) > limit else lines
tail_lines = [line.rstrip('\r\n') for line in tail_lines]
return {
"status": "success",
"logs": tail_lines
}
except Exception as e:
logger.error(f"Diagnostics logs retrieval error: {e}")
raise HTTPException(500, f"Failed to retrieve logs: {str(e)}")
@router.get("/api/db/stats")
async def get_database_stats(request: Request) -> Dict[str, Any]:
require_admin(request)
+135 -5
View File
@@ -1097,7 +1097,12 @@ def setup_email_routes():
account_id: str | None = Query(None),
owner: str = Depends(require_owner),
):
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text."""
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text.
When the caller asks for INBOX and the account has an "All Mail"
folder (Gmail does), we transparently swap to All Mail so the
search surfaces archived / labelled emails too. Plain IMAP
accounts fall back to whatever folder the caller specified."""
if not q or len(q) < 2:
return {"emails": [], "total": 0, "query": q}
# CRLF in q would terminate the IMAP command early — reject defensively.
@@ -1105,7 +1110,27 @@ def setup_email_routes():
raise HTTPException(400, "Invalid query")
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder), readonly=True)
# If the user asked for INBOX, try to upgrade to All Mail —
# one folder == every email on Gmail-class servers.
effective_folder = folder
if (folder or "").upper() == "INBOX":
try:
status, folder_lines = conn.list()
if status == "OK" and folder_lines:
for raw in folder_lines:
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
m = re.match(r"\((?P<flags>[^)]*)\)\s+\"[^\"]*\"\s+(?P<name>.+)", raw)
if not m:
continue
flags = (m.group("flags") or "").lower()
name = m.group("name").strip().strip('"')
if "\\all" in flags or "all mail" in name.lower():
effective_folder = name
break
except Exception:
pass
conn.select(_q(effective_folder), readonly=True)
# Escape backslash and quote for the IMAP-SEARCH quoted-string.
q_escaped = q.replace('\\', '\\\\').replace('"', '\\"')
@@ -1113,7 +1138,7 @@ def setup_email_routes():
status, data = _imap_uid_search(conn, search_cmd)
if status != "OK" or not data[0]:
return {"emails": [], "total": 0, "query": q}
return {"emails": [], "total": 0, "query": q, "folder": effective_folder}
uid_list = data[0].split()
total = len(uid_list)
@@ -1178,6 +1203,13 @@ def setup_email_routes():
"is_flagged": "\\Flagged" in flags,
"flags": flags,
"has_attachments": has_attachments,
# Stamp the folder so the frontend opens each
# email from the folder it actually lives in
# (the search may have run against All Mail
# even though the caller asked for INBOX),
# otherwise clicks open whatever happens to
# have the same UID in INBOX → wrong email.
"folder": effective_folder,
})
except Exception as e:
logger.warning(f"Error parsing search result {uid}: {e}")
@@ -1724,6 +1756,22 @@ def setup_email_routes():
logger.error(f"Failed to mark unread {uid}: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.post("/flag/{uid}")
async def flag_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None),
on: bool = Query(True), owner: str = Depends(require_owner)):
"""Toggle the \\Flagged flag (a.k.a. favorite / star) on an email.
Pass `on=true` to favorite, `on=false` to unfavorite."""
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
return {"success": False, "error": "Email not found"}
_invalidate_list_cache(account_id, folder)
return {"success": True, "flagged": bool(on)}
except Exception as e:
logger.error(f"Failed to flag {uid}: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.post("/mark-read/{uid}")
async def mark_read(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
"""Mark an email as read (set \\Seen flag)."""
@@ -2104,6 +2152,79 @@ def setup_email_routes():
logger.error(f"cancel_scheduled {sid!r} failed: {e}")
return {"success": False, "error": "Mail operation failed"}
# ── Agent send-confirm: list/approve/cancel ──────────────────────────
# When `agent_email_confirm` is on, the MCP send_email tool drops the
# composed email into scheduled_emails with status='agent_draft' (a
# far-future send_at so the poller never picks it up). These endpoints
# let the chat UI surface them for the user and either approve (flip
# to status='pending' with send_at=now so the poller delivers it) or
# cancel (status='cancelled').
@router.get("/pending")
async def list_pending_agent_drafts(owner: str = Depends(require_owner)):
import sqlite3
try:
conn = sqlite3.connect(SCHEDULED_DB)
conn.row_factory = sqlite3.Row
# The MCP server can't easily set owner, so it stores '' — fall
# back to those rows in addition to the caller's owner.
rows = conn.execute(
"""SELECT id, to_addr, subject, body, created_at, account_id
FROM scheduled_emails
WHERE status = 'agent_draft' AND (owner = ? OR owner = '')
ORDER BY created_at DESC""",
(owner or "",),
).fetchall()
conn.close()
return {"pending": [dict(r) for r in rows]}
except Exception as e:
logger.error(f"list_pending_agent_drafts failed: {e}")
return {"pending": [], "error": "Mail operation failed"}
@router.post("/pending/{sid}/approve")
async def approve_agent_draft(sid: str, owner: str = Depends(require_owner)):
"""Approve a draft staged by the agent: flip status → pending and
backdate send_at so the scheduled-send poller picks it up
immediately."""
import sqlite3
try:
conn = sqlite3.connect(SCHEDULED_DB)
cur = conn.execute(
"""UPDATE scheduled_emails
SET status = 'pending', send_at = ?
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
(datetime.utcnow().isoformat(), sid, owner or ""),
)
conn.commit()
affected = cur.rowcount
conn.close()
if not affected:
return {"success": False, "error": "Draft not found or already handled"}
return {"success": True}
except Exception as e:
logger.error(f"approve_agent_draft {sid!r} failed: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.delete("/pending/{sid}")
async def cancel_agent_draft(sid: str, owner: str = Depends(require_owner)):
"""Discard a draft the agent staged for approval."""
import sqlite3
try:
conn = sqlite3.connect(SCHEDULED_DB)
cur = conn.execute(
"""UPDATE scheduled_emails SET status = 'cancelled'
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
(sid, owner or ""),
)
conn.commit()
affected = cur.rowcount
conn.close()
if not affected:
return {"success": False, "error": "Draft not found or already handled"}
return {"success": True}
except Exception as e:
logger.error(f"cancel_agent_draft {sid!r} failed: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.get("/resolve-contact")
async def resolve_contact(name: str = Query(..., description="Name to search for"), owner: str = Depends(require_owner)):
"""Search Sent folder for a contact by name. Returns matching email addresses."""
@@ -2617,11 +2738,15 @@ def setup_email_routes():
source_uid = (data.get("uid") or "").strip()
source_folder = (data.get("folder") or "INBOX").strip()
fast_reply = bool(data.get("fast", False))
user_hint = (data.get("user_hint") or "").strip()
if not original_body:
return {"success": False, "error": "No email body provided"}
if message_id:
# Skip cache lookup when the caller supplied a user_hint — the
# cached generic reply doesn't reflect the instructions and
# would silently override them.
if message_id and not user_hint:
try:
_c = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_cache_owner_clause(owner)
@@ -2761,8 +2886,13 @@ def setup_email_routes():
user_msg = (
f"Recipient: {to}\nSubject: {subject}\n\n"
f"Original email and any current draft:\n{original_body[:6000]}\n\n"
f"Draft a reply. Return only the reply body text."
)
if user_hint:
user_msg += (
f"User's instructions for THIS reply (follow these — they override "
f"defaults like length/tone):\n{user_hint[:2000]}\n\n"
)
user_msg += "Draft a reply. Return only the reply body text."
# Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here)
+24 -11
View File
@@ -67,6 +67,14 @@ def _gallery_image_path(filename: str) -> Path:
raise HTTPException(400, "Unsafe gallery filename")
if safe_name != original:
raise HTTPException(400, "Unsafe gallery filename")
if not path.exists():
cwd_root = (Path.cwd() / "data" / "generated_images").resolve()
cwd_path = (cwd_root / safe_name).resolve()
try:
if os.path.commonpath([str(cwd_root), str(cwd_path)]) == str(cwd_root) and cwd_path.exists():
return cwd_path
except Exception:
pass
return path
@@ -224,8 +232,6 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/{image_id}/replace")
async def gallery_replace(request: Request, image_id: str):
"""Replace an existing gallery image file with a new one."""
from pathlib import Path
user = get_current_user(request)
db = SessionLocal()
try:
@@ -241,9 +247,8 @@ def setup_gallery_routes() -> APIRouter:
raise HTTPException(400, "No image provided")
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
img_path = img_dir / _sanitize_gallery_filename(img.filename)
GALLERY_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
img_path = _gallery_image_path(img.filename)
img_path.write_bytes(content)
# Refresh dimensions in case the editor resized the canvas.
@@ -931,15 +936,23 @@ def setup_gallery_routes() -> APIRouter:
raise HTTPException(404, "Image not found")
img_filename = img.filename
# Remove the file from disk
img_path = _gallery_image_path(img_filename)
if img_path.exists():
img_path.unlink()
# Soft-delete the record
# Soft-delete the record first; the DB is the source of truth.
img.is_active = False
db.commit()
# Only after the soft-delete commit succeeds do we remove the file.
# If the file were deleted first and the commit then failed/rolled
# back, the still-active record would point at a missing file.
# Best-effort so a missing or locked file can't 500 a delete that
# already succeeded logically. Uses the path-confined resolver so a
# malformed stored filename can't escape generated_images.
try:
img_path = _gallery_image_path(img_filename)
if img_path.exists():
img_path.unlink()
except Exception as e:
logger.warning(f"Could not remove gallery image file for {img_filename}: {e}")
# Strip stale chat-history references so the image bubble
# (and its prompt caption) doesn't come back after a server
# reboot replays the session. We remove the matching tool
+1 -1
View File
@@ -119,7 +119,7 @@ def setup_hwfit_routes():
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models")
def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
"""Rank LLM models against detected hardware and return scored results.
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
active group). gpu_group: index into system.gpu_groups (the homogeneous
+2 -1
View File
@@ -377,7 +377,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
)
except KeyError:
raise HTTPException(404, "Session not found — needed for LLM config")
logger.warning("Session %s not found, falling back to utility endpoint", session)
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
else:
endpoint_url, model, headers = resolve_task_endpoint(owner=_owner(request))
+23 -9
View File
@@ -26,7 +26,7 @@ from src.endpoint_resolver import (
build_models_url,
build_headers,
)
from src.auth_helpers import _auth_disabled, owner_filter
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
logger = logging.getLogger(__name__)
@@ -248,6 +248,9 @@ _PROVIDER_CURATED = {
"zai-coding": [
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
],
"kimi-code": [
"kimi-for-coding",
],
"deepseek": [
"deepseek-chat", "deepseek-reasoner",
],
@@ -315,6 +318,8 @@ def _match_provider_curated(base_url: str, provider: str) -> str:
parsed = urlparse(base_url)
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
return "zai-coding"
if _host_match(base_url, "kimi.com") and "/coding" in (parsed.path or ""):
return "kimi-code"
for domain, key in _HOST_TO_CURATED:
if _host_match(base_url, domain):
return key
@@ -703,6 +708,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
"""Probe a base URL's /models endpoint and return list of model IDs.
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
from src.endpoint_resolver import resolve_url
from src.llm_core import httpx_get_kimi_aware
base = resolve_url(_normalize_base(base_url))
provider = _safe_detect_provider(base)
if provider == "chatgpt-subscription":
@@ -738,7 +744,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
url = _safe_build_models_url(base)
headers = _safe_build_headers(api_key, base)
try:
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
r = httpx_get_kimi_aware(url, headers, timeout=timeout, verify=llm_verify())
r.raise_for_status()
data = r.json()
# OpenAI format: {"data": [{"id": "model-name"}]}
@@ -754,6 +760,11 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
for _e in _PROVIDER_CURATED.get(_ck, []):
if _e not in set(models) and not any(m.startswith(_e) for m in models):
models.append(_e)
if _host_match(base, "kimi.com") and "/coding" in (urlparse(base).path or ""):
_ck = _match_provider_curated(base, None)
for _e in _PROVIDER_CURATED.get(_ck, []):
if _e not in set(models) and not any(m.startswith(_e) for m in models):
models.append(_e)
return [m for m in models if _is_chat_model(m)]
except httpx.HTTPStatusError as e:
if api_key:
@@ -1244,13 +1255,16 @@ def setup_model_routes(model_discovery):
# Require auth; "" is the unconfigured single-user mode, treated as
# "see everything" by _fetch_models.
try:
from src.auth_helpers import get_current_user as _gcu
owner = _gcu(request) or ""
except Exception:
owner = ""
# Reject anonymous in configured deployments — no leaking the model
# list to unauthenticated callers.
try:
if getattr(request.state, "api_token", False):
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
if not getattr(request.state, "api_token_owner", None):
raise HTTPException(403, "API token has no owner")
owner = effective_user(request) or ""
# Reject anonymous in configured deployments — no leaking the model
# list to unauthenticated callers.
auth_mgr = getattr(request.app.state, "auth_manager", None)
if not owner and not _auth_disabled() and auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
raise HTTPException(401, "Not authenticated")
+10 -1
View File
@@ -208,14 +208,17 @@ async def dispatch_reminder(
try:
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
from src.reminder_personas import synthesis_system_prompt
url, model, headers = resolve_endpoint("utility", owner=owner or None)
if not url:
url, model, headers = resolve_endpoint("default", owner=owner or None)
if url and model:
persona_id = (settings.get("reminder_llm_persona") or "").strip()
sys_prompt = synthesis_system_prompt(persona_id)
raw = await llm_call_async(
url=url, model=model,
messages=[
{"role": "system", "content": "You are a reminder assistant. Write a single short, warm, motivating sentence (max 25 words) reminding the user about the note below. Do not add greetings, preamble, or hashtags. Output only the sentence."},
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
],
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
@@ -826,6 +829,12 @@ def setup_note_routes(task_scheduler=None):
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
if body.get("webhook_payload_template"):
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
# Mirror the in-UI AI Synthesis toggle + persona so the test
# actually exercises the synthesis path before/without a Save.
if "llm_synthesis" in body:
_override["reminder_llm_synthesis"] = bool(body["llm_synthesis"])
if "llm_persona" in body:
_override["reminder_llm_persona"] = str(body["llm_persona"] or "")
else:
db = SessionLocal()
try:
+2 -2
View File
@@ -278,8 +278,8 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
# Delete file from disk if it's in uploads dir
deleted_from_disk = False
try:
abs_target = os.path.abspath(filepath)
base_abs = os.path.abspath(UPLOADS_DIR)
abs_target = os.path.realpath(filepath)
base_abs = os.path.realpath(UPLOADS_DIR)
in_uploads = (
abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
+5 -1
View File
@@ -691,8 +691,12 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
{"role": "user", "content": task},
]
try:
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
# OpenAI-compat) generate an empty completion, which manifested as
# the skill test returning nothing while chat (which carries its
# preset's max_tokens) worked. 4096 matches the chat default.
async for chunk in stream_agent_loop(url, model, messages, headers=headers,
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner):
temperature=0.3, max_tokens=4096, max_rounds=8, owner=owner):
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
continue
try:
+7
View File
@@ -151,6 +151,7 @@ class TaskCreate(BaseModel):
endpoint_url: Optional[str] = None
then_task_id: Optional[str] = None # chain: run this task after success
notifications_enabled: Optional[bool] = None # None lets action-specific defaults apply
character_id: Optional[str] = None # built-in persona id (PERSONAS) — biases output voice
class TaskUpdate(BaseModel):
@@ -171,6 +172,7 @@ class TaskUpdate(BaseModel):
endpoint_url: Optional[str] = None
then_task_id: Optional[str] = None
notifications_enabled: Optional[bool] = None
character_id: Optional[str] = None
def _display_task_name(t: ScheduledTask) -> str:
@@ -203,6 +205,7 @@ def _task_to_dict(t: ScheduledTask, include_last_run_result: bool = False) -> di
"output_target": t.output_target,
"session_id": t.session_id,
"crew_member_id": getattr(t, "crew_member_id", None),
"character_id": getattr(t, "character_id", None),
"model": t.model,
"endpoint_url": t.endpoint_url,
"run_count": t.run_count or 0,
@@ -552,6 +555,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
then_task_id=then_task_id,
webhook_token=webhook_token,
notifications_enabled=notifications_enabled,
character_id=(req.character_id or None),
)
db.add(task)
db.commit()
@@ -705,6 +709,9 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.then_task_id = _validate_then_task_id(db, req.then_task_id, user, current_task_id=task.id)
if req.notifications_enabled is not None:
task.notifications_enabled = bool(req.notifications_enabled)
if req.character_id is not None:
# Empty string clears the persona; non-empty stores the id.
task.character_id = req.character_id or None
if req.cron_expression is not None:
if req.cron_expression:
try:
+4
View File
@@ -198,6 +198,8 @@ def setup_webhook_routes(
"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
@@ -210,6 +212,8 @@ def setup_webhook_routes(
"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]:
+635
View File
@@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""Build a neutral agent migration manifest.
This helper is intentionally read-only. It does not import the Odysseus
application package, write to data/, call an LLM, or apply anything. It turns
common agent export shapes into a portable JSON manifest that Odysseus can
preview or import later.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import mimetypes
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
SCHEMA_VERSION = "agent-migration.v1"
TEXT_EXTENSIONS = {
".cfg",
".conf",
".csv",
".json",
".log",
".md",
".markdown",
".py",
".rst",
".toml",
".txt",
".yaml",
".yml",
}
@dataclass(frozen=True)
class InputWarning:
path: str
message: str
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha256_path(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def stable_id(kind: str, source_name: str, *parts: Any) -> str:
raw = "\x1f".join([kind, source_name, *[str(part) for part in parts]])
return f"{kind}:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
def read_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def normalize_category(value: Any) -> str:
category = str(value or "fact").strip().lower()
return category or "fact"
def normalize_memory_text(item: Any) -> str:
if isinstance(item, str):
return item.strip()
if isinstance(item, dict):
for key in ("text", "content", "memory", "value"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def memory_metadata(item: Any, source_path: Path, index: int) -> dict[str, Any]:
metadata: dict[str, Any] = {
"source_path": str(source_path),
"source_index": index,
}
if isinstance(item, dict):
for key in ("id", "timestamp", "created_at", "updated_at", "source", "tags", "pinned"):
if key in item:
metadata[f"source_{key}"] = item.get(key)
return metadata
def payload_items(payload: Any, keys: tuple[str, ...]) -> Any:
if isinstance(payload, dict):
for key in keys:
if isinstance(payload.get(key), list):
return payload[key]
return payload
def collect_memory_json(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
warnings: list[InputWarning] = []
try:
payload = read_json(path)
except Exception as exc:
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
payload = payload_items(payload, ("memories", "memory", "items", "data"))
if not isinstance(payload, list):
return [], [InputWarning(str(path), "expected a JSON list or an object containing a memory list")]
items: list[dict[str, Any]] = []
seen: set[str] = set()
for index, item in enumerate(payload):
text = normalize_memory_text(item)
if not text:
warnings.append(InputWarning(str(path), f"skipped memory at index {index}: missing text"))
continue
digest = sha256_text(text.strip().lower())
if digest in seen:
warnings.append(InputWarning(str(path), f"skipped duplicate memory at index {index}"))
continue
seen.add(digest)
category = normalize_category(item.get("category") if isinstance(item, dict) else "fact")
source = str(item.get("source") or source_name) if isinstance(item, dict) else source_name
items.append(
{
"id": stable_id("memory", source_name, path, index, digest),
"kind": "memory",
"text": text,
"category": category,
"source": source,
"metadata": memory_metadata(item, path, index),
}
)
return items, warnings
def normalize_timestamp(value: Any) -> str | None:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
try:
return (
datetime.fromtimestamp(float(value), timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
except (OverflowError, OSError, ValueError):
return str(value)
return str(value)
def normalize_role(value: Any) -> str:
role = str(value or "unknown").strip().lower()
if role in {"human", "user"}:
return "user"
if role in {"assistant", "ai", "bot", "model"}:
return "assistant"
if role in {"system", "tool"}:
return role
return role or "unknown"
def content_part_text(part: Any) -> str:
if isinstance(part, str):
return part
if isinstance(part, dict):
for key in ("text", "content", "value"):
value = part.get(key)
if isinstance(value, str):
return value
if part.get("type") == "text" and isinstance(part.get("text"), str):
return part["text"]
return ""
def normalize_message_text(message: dict[str, Any]) -> str:
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(text for text in (content_part_text(part).strip() for part in content) if text)
if isinstance(content, dict):
parts = content.get("parts")
if isinstance(parts, list):
return "\n".join(text for text in (content_part_text(part).strip() for part in parts) if text)
for key in ("text", "content", "value"):
value = content.get(key)
if isinstance(value, str):
return value
for key in ("text", "body", "message"):
value = message.get(key)
if isinstance(value, str):
return value
return ""
def normalize_message(message: dict[str, Any]) -> dict[str, Any] | None:
author = message.get("author") if isinstance(message.get("author"), dict) else {}
role = (
message.get("role")
or message.get("sender")
or message.get("speaker")
or author.get("role")
or author.get("name")
)
text = normalize_message_text(message).strip()
if not text:
return None
normalized: dict[str, Any] = {
"role": normalize_role(role),
"text": text,
}
timestamp = normalize_timestamp(message.get("created_at") or message.get("create_time") or message.get("timestamp"))
if timestamp:
normalized["created_at"] = timestamp
message_id = message.get("id")
if message_id is not None:
normalized["source_id"] = str(message_id)
return normalized
def chatgpt_mapping_messages(conversation: dict[str, Any]) -> list[dict[str, Any]]:
mapping = conversation.get("mapping")
if not isinstance(mapping, dict):
return []
rows: list[tuple[float, int, dict[str, Any]]] = []
for index, node in enumerate(mapping.values()):
if not isinstance(node, dict) or not isinstance(node.get("message"), dict):
continue
message = node["message"]
sort_value = message.get("create_time")
try:
sort_key = float(sort_value)
except (TypeError, ValueError):
sort_key = float(index)
normalized = normalize_message(message)
if normalized:
rows.append((sort_key, index, normalized))
return [row[2] for row in sorted(rows, key=lambda row: (row[0], row[1]))]
def conversation_messages(conversation: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
mapped = chatgpt_mapping_messages(conversation)
if mapped:
return mapped, "chatgpt_mapping"
for key in ("messages", "chat_messages", "turns"):
raw_messages = conversation.get(key)
if isinstance(raw_messages, list):
messages = [
normalized
for raw in raw_messages
if isinstance(raw, dict)
for normalized in [normalize_message(raw)]
if normalized
]
return messages, key
return [], "unknown"
def conversation_title(conversation: dict[str, Any], index: int) -> str:
for key in ("title", "name", "summary"):
value = conversation.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return f"Conversation {index + 1}"
def collect_conversation_json(
path: Path,
source_name: str,
*,
include_content: bool = False,
max_messages: int = 2000,
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
warnings: list[InputWarning] = []
try:
payload = read_json(path)
except Exception as exc:
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
payload = payload_items(payload, ("conversations", "conversation", "items", "data"))
if isinstance(payload, dict):
payload = [payload]
if not isinstance(payload, list):
return [], [InputWarning(str(path), "expected a JSON list or an object containing a conversation list")]
items: list[dict[str, Any]] = []
for index, conversation in enumerate(payload):
if not isinstance(conversation, dict):
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: expected object"))
continue
messages, format_hint = conversation_messages(conversation)
if not messages:
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: no text messages found"))
continue
title = conversation_title(conversation, index)
source_id = conversation.get("id") or conversation.get("uuid") or conversation.get("conversation_id")
text_digest = sha256_text("\n".join(f"{msg['role']}:{msg['text']}" for msg in messages))
metadata: dict[str, Any] = {
"source_path": str(path),
"source_index": index,
"source_format": format_hint,
"message_count": len(messages),
"text_sha256": text_digest,
"content_included": False,
}
if source_id is not None:
metadata["source_id"] = str(source_id)
for key in ("create_time", "created_at", "update_time", "updated_at"):
timestamp = normalize_timestamp(conversation.get(key))
if timestamp:
metadata[f"source_{key}"] = timestamp
item: dict[str, Any] = {
"id": stable_id("conversation", source_name, path, source_id or index, text_digest),
"kind": "conversation_thread",
"title": title,
"source": source_name,
"metadata": metadata,
}
if include_content:
if len(messages) > max_messages:
warnings.append(
InputWarning(
str(path),
f"skipped conversation content at index {index}: over {max_messages} messages",
)
)
else:
item["messages"] = messages
item["metadata"]["content_included"] = True
items.append(item)
return items, warnings
def parse_skill_frontmatter(text: str) -> dict[str, Any]:
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end < 0:
return {}
frontmatter: dict[str, Any] = {}
for line in text[3:end].strip().splitlines():
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
frontmatter[key] = value
return frontmatter
def collect_skill_dir(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
warnings: list[InputWarning] = []
if path.is_symlink():
return [], [InputWarning(str(path), "skills path is a symlink; skipped")]
if not path.exists():
return [], [InputWarning(str(path), "skills directory does not exist")]
if not path.is_dir():
return [], [InputWarning(str(path), "skills path is not a directory")]
items: list[dict[str, Any]] = []
for skill_path in sorted(path.rglob("SKILL.md")):
if skill_path.is_symlink():
warnings.append(InputWarning(str(skill_path), "skipped symlinked skill file"))
continue
try:
text = skill_path.read_text(encoding="utf-8")
except Exception as exc:
warnings.append(InputWarning(str(skill_path), f"could not read skill: {exc}"))
continue
frontmatter = parse_skill_frontmatter(text)
name = str(frontmatter.get("name") or skill_path.parent.name).strip() or skill_path.parent.name
items.append(
{
"id": stable_id("skill", source_name, skill_path, sha256_text(text)),
"kind": "skill",
"name": name,
"category": str(frontmatter.get("category") or "general"),
"source": source_name,
"format": "SKILL.md",
"content": text,
"metadata": {
"source_path": str(skill_path),
"sha256": sha256_text(text),
"frontmatter": frontmatter,
},
}
)
return items, warnings
def looks_textual(path: Path) -> bool:
if path.suffix.lower() in TEXT_EXTENSIONS:
return True
guessed, _ = mimetypes.guess_type(str(path))
return bool(guessed and (guessed.startswith("text/") or guessed in {"application/json"}))
def iter_archive_dir(path: Path) -> Iterable[Path | InputWarning]:
try:
children = sorted(path.iterdir())
except Exception as exc:
yield InputWarning(str(path), f"could not scan archive directory: {exc}")
return
for child in children:
if child.is_symlink():
yield InputWarning(str(child), "skipped symlinked archive path")
continue
if child.is_file():
yield child
elif child.is_dir():
yield from iter_archive_dir(child)
def iter_archive_files(paths: Iterable[Path]) -> Iterable[Path | InputWarning]:
for path in paths:
if path.is_symlink():
yield InputWarning(str(path), "skipped symlinked archive path")
continue
if path.is_file():
yield path
elif path.is_dir():
yield from iter_archive_dir(path)
def collect_archive_paths(
paths: list[Path],
source_name: str,
*,
include_content: bool = False,
max_bytes: int = 256_000,
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
warnings: list[InputWarning] = []
items: list[dict[str, Any]] = []
existing_paths: list[Path] = []
for path in paths:
if path.is_symlink():
warnings.append(InputWarning(str(path), "archive path is a symlink; skipped"))
continue
if not path.exists():
warnings.append(InputWarning(str(path), "archive path does not exist"))
continue
if not path.is_file() and not path.is_dir():
warnings.append(InputWarning(str(path), "archive path is not a file or directory"))
continue
existing_paths.append(path)
for entry in iter_archive_files(existing_paths):
if isinstance(entry, InputWarning):
warnings.append(entry)
continue
path = entry
if not looks_textual(path):
warnings.append(InputWarning(str(path), "skipped non-text archive file"))
continue
try:
st = path.stat()
except Exception as exc:
warnings.append(InputWarning(str(path), f"could not stat archive file: {exc}"))
continue
size = st.st_size
try:
file_hash = sha256_path(path)
except Exception as exc:
warnings.append(InputWarning(str(path), f"could not hash archive file: {exc}"))
continue
if include_content and size > max_bytes:
warnings.append(InputWarning(str(path), f"skipped archive content over {max_bytes} bytes"))
archive_item: dict[str, Any] = {
"id": stable_id("archive", source_name, path, file_hash),
"kind": "archive_document",
"title": path.name,
"source": source_name,
"metadata": {
"source_path": str(path),
"size_bytes": size,
"sha256": file_hash,
},
}
if include_content and size <= max_bytes:
try:
archive_item["content"] = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
archive_item["content"] = path.read_text(encoding="utf-8", errors="replace")
archive_item["metadata"]["decoded_with_replacement"] = True
items.append(archive_item)
return items, warnings
def build_manifest(args) -> dict[str, Any]:
warnings: list[InputWarning] = []
items: list[dict[str, Any]] = []
for path in args.memory_json:
collected, got_warnings = collect_memory_json(path, args.source_name)
items.extend(collected)
warnings.extend(got_warnings)
for path in args.skills_dir:
collected, got_warnings = collect_skill_dir(path, args.source_name)
items.extend(collected)
warnings.extend(got_warnings)
for path in args.conversation_json:
collected, got_warnings = collect_conversation_json(
path,
args.source_name,
include_content=args.include_conversation_content,
max_messages=args.max_conversation_messages,
)
items.extend(collected)
warnings.extend(got_warnings)
if args.archive:
collected, got_warnings = collect_archive_paths(
args.archive,
args.source_name,
include_content=args.include_archive_content,
max_bytes=args.max_archive_bytes,
)
items.extend(collected)
warnings.extend(got_warnings)
counts: dict[str, int] = {}
for item in items:
counts[item["kind"]] = counts.get(item["kind"], 0) + 1
return {
"schema_version": SCHEMA_VERSION,
"generated_at": utc_now_iso(),
"source": {
"name": args.source_name,
"kind": args.source_kind,
},
"summary": {
"item_count": len(items),
"counts_by_kind": counts,
"warning_count": len(warnings),
},
"items": items,
"warnings": [{"path": warning.path, "message": warning.message} for warning in warnings],
}
def parse_args(argv: list[str] | None = None):
parser = argparse.ArgumentParser(description="Build a neutral Odysseus agent migration manifest.")
parser.add_argument("--source-name", default="agent-export", help="Human-readable source name.")
parser.add_argument("--source-kind", default="generic", help="Source adapter kind, e.g. generic, openclaw, hermes.")
parser.add_argument(
"--memory-json",
action="append",
type=Path,
default=[],
help="JSON memory export. May be a list, or an object containing memories/items/data.",
)
parser.add_argument(
"--skills-dir",
action="append",
type=Path,
default=[],
help="Directory containing SKILL.md files. Scanned recursively.",
)
parser.add_argument(
"--archive",
action="append",
type=Path,
default=[],
help="Text/Markdown/JSON file or directory to preserve as archive documents.",
)
parser.add_argument(
"--conversation-json",
action="append",
type=Path,
default=[],
help="Conversation export JSON. Supports generic message lists and ChatGPT-style conversations.json.",
)
parser.add_argument(
"--include-archive-content",
action="store_true",
help="Embed archive document content in the manifest. By default only metadata is included.",
)
parser.add_argument(
"--max-archive-bytes",
type=int,
default=256_000,
help="Maximum bytes to embed per archive file when --include-archive-content is used.",
)
parser.add_argument(
"--include-conversation-content",
action="store_true",
help="Embed normalized conversation messages. By default only thread metadata is included.",
)
parser.add_argument(
"--max-conversation-messages",
type=int,
default=2000,
help="Maximum messages to embed per conversation when --include-conversation-content is used.",
)
parser.add_argument("--output", type=Path, help="Write manifest JSON to this path instead of stdout.")
parser.add_argument("--compact", action="store_true", help="Write compact JSON without indentation.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
manifest = build_manifest(args)
text = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) if args.compact else (
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Backfill release_date on entries in services/hwfit/data/hf_models.json.
Why: the `newest` sort in the cookbook ranks rows by release_date. Anything
missing a date sorts to the bottom. This script pulls `created_at` from the
HuggingFace API for each catalog entry without one (or all entries when
--refresh is passed) and writes the catalog back.
Usage:
python scripts/backfill_model_release_dates.py # missing only
python scripts/backfill_model_release_dates.py --refresh # all entries
python scripts/backfill_model_release_dates.py --limit 50 # cap requests
python scripts/backfill_model_release_dates.py --dry-run # show, don't write
Auth: set HF_TOKEN env var (or huggingface-cli login) to access gated repos.
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime
from pathlib import Path
try:
from huggingface_hub import HfApi
from huggingface_hub.utils import HfHubHTTPError
except ImportError:
print("Install huggingface_hub: pip install huggingface_hub", file=sys.stderr)
sys.exit(1)
CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json"
def fetch_release_date(api: HfApi, repo_id: str) -> str | None:
"""Return YYYY-MM-DD release date, or None on miss / error."""
try:
info = api.model_info(repo_id, files_metadata=False)
except HfHubHTTPError as e:
# 401 = gated/private, 404 = renamed/deleted. Either way, no date.
status = getattr(getattr(e, "response", None), "status_code", None)
print(f" {repo_id}: HTTP {status or '?'}", file=sys.stderr)
return None
except Exception as e:
print(f" {repo_id}: {type(e).__name__}: {e}", file=sys.stderr)
return None
created = getattr(info, "created_at", None)
if not created:
return None
return created.strftime("%Y-%m-%d")
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--refresh", action="store_true", help="Overwrite existing release_date too (default: only fill missing).")
p.add_argument("--limit", type=int, default=0, help="Stop after N API calls (0 = no limit).")
p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.")
p.add_argument("--sleep", type=float, default=0.05, help="Seconds to sleep between requests (default 0.05).")
args = p.parse_args()
if not CATALOG_PATH.exists():
print(f"Catalog not found: {CATALOG_PATH}", file=sys.stderr)
sys.exit(2)
with CATALOG_PATH.open(encoding="utf-8") as f:
catalog = json.load(f)
candidates = []
for i, m in enumerate(catalog):
name = m.get("name")
if not name:
continue
existing = (m.get("release_date") or "").strip()
if existing and not args.refresh:
continue
candidates.append(i)
if args.limit:
candidates = candidates[: args.limit]
print(f"Catalog: {CATALOG_PATH}")
print(f"Total entries: {len(catalog)}")
print(f"Targets ({'refresh all' if args.refresh else 'missing only'}{'' if not args.limit else f', capped at {args.limit}'}): {len(candidates)}")
if not candidates:
print("Nothing to do.")
return
api = HfApi(token=os.environ.get("HF_TOKEN") or None)
updated = 0
skipped = 0
started = time.time()
for n, idx in enumerate(candidates, start=1):
entry = catalog[idx]
name = entry["name"]
old = (entry.get("release_date") or "").strip()
new = fetch_release_date(api, name)
if new is None:
skipped += 1
tag = "skip"
elif new == old:
tag = "unchanged"
else:
entry["release_date"] = new
updated += 1
tag = f"set {new}" + (f" (was {old})" if old else "")
print(f"[{n}/{len(candidates)}] {name} — {tag}")
if args.sleep:
time.sleep(args.sleep)
elapsed = time.time() - started
print()
print(f"Done in {elapsed:.1f}s — {updated} updated, {skipped} skipped (HF unavailable / gated / missing date).")
if args.dry_run:
print("Dry run — no write.")
return
if updated:
# Atomic write: tmp file in the same dir, then rename. Keeps the
# catalog usable even if the process dies mid-write.
tmp = CATALOG_PATH.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(catalog, f, indent=1, ensure_ascii=False)
f.write("\n")
tmp.replace(CATALOG_PATH)
print(f"Wrote {CATALOG_PATH}")
else:
print("No changes to write.")
if __name__ == "__main__":
main()
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Import models from the upstream vllm-project/recipes catalog into our
local hf_models.json. Two modes:
--update-existing Stamp min_vllm_version + vllm_recipe=True on rows we
already carry. Cheap, no HF API calls.
--add-missing Create new catalog rows for every recipe model we
don't carry. Hits the HF API for created_at + downloads
(~1 req per missing model, paced).
Both modes write atomically (tmp + rename) so a crashed run leaves the
catalog intact. Default with no mode flags runs both, prefer to pass them
explicitly.
Usage:
python scripts/import_from_vllm_recipes.py --update-existing
python scripts/import_from_vllm_recipes.py --add-missing
python scripts/import_from_vllm_recipes.py --dry-run
python scripts/import_from_vllm_recipes.py --limit 10
Auth: set HF_TOKEN to access gated repos when --add-missing.
"""
import argparse
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
try:
import httpx
import yaml
except ImportError:
print("pip install httpx PyYAML", file=sys.stderr)
sys.exit(1)
try:
from huggingface_hub import HfApi
from huggingface_hub.utils import HfHubHTTPError
except ImportError:
HfApi = None
HfHubHTTPError = Exception
CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json"
RECIPES_TREE_URL = (
"https://api.github.com/repos/vllm-project/recipes/git/trees/main?recursive=1"
)
RECIPE_RAW_URL = (
"https://raw.githubusercontent.com/vllm-project/recipes/main/models/{repo}.yaml"
)
# Map recipe `precision` to the closest catalog `quantization` label that
# fit.py / models.py already understand.
_PRECISION_TO_QUANT = {
"fp8": "FP8",
"nvfp4": "NVFP4",
"mxfp4": "MXFP4",
"bf16": "BF16",
"fp16": "F16",
"f16": "F16",
"fp4": "FP4",
"int8": "INT8",
"int4": "INT4",
"awq-4bit": "AWQ-4bit",
"awq-8bit": "AWQ-8bit",
}
# Architecture name → use_case fallback. fit.py weights use_case for filtering;
# missing field defaults to a generic bucket.
_ARCH_USE_CASE = {
"moe": "General-purpose reasoning, long-context",
"llama": "General-purpose chat",
"qwen2": "General-purpose chat",
"qwen3": "General-purpose reasoning",
"deepseek_v3_moe": "General-purpose reasoning, long-context",
"deepseek_v4_moe": "General-purpose reasoning, long-context",
}
def _parse_param_count(s) -> int:
"""'230B' / '8.6B' / '4.2T' → integer parameter count."""
if s is None:
return 0
s = str(s).strip().replace(",", "")
m = re.match(r"^([\d.]+)\s*([KMBT]?)$", s, re.I)
if not m:
return 0
num = float(m.group(1))
unit = (m.group(2) or "").upper()
mult = {"K": 1e3, "M": 1e6, "B": 1e9, "T": 1e12, "": 1.0}[unit]
return int(num * mult)
def _capabilities_for(arch: str, hardware: dict, ctx_len: int, has_reasoning: bool) -> list[str]:
caps = []
if "moe" in (arch or "").lower():
caps.append("moe")
if has_reasoning:
caps.append("reasoning")
if ctx_len and ctx_len >= 100_000:
caps.append("long_context")
if any(hw in (hardware or {}) for hw in ("mi300x", "mi325x", "mi350x", "mi355x")):
caps.append("amd_supported")
return caps
def _fetch_manifest(client: httpx.Client) -> set[str]:
r = client.get(RECIPES_TREE_URL, headers={"Accept": "application/vnd.github+json"}, timeout=15)
r.raise_for_status()
tree = (r.json() or {}).get("tree") or []
out: set[str] = set()
for e in tree:
path = (e or {}).get("path") or ""
if path.startswith("models/") and path.endswith(".yaml"):
body = path[len("models/"):-len(".yaml")]
if "/" in body:
out.add(body)
return out
def _fetch_recipe(client: httpx.Client, repo: str) -> dict | None:
url = RECIPE_RAW_URL.format(repo=repo)
try:
r = client.get(url, timeout=10)
if r.status_code != 200:
return None
return yaml.safe_load(r.text) or {}
except Exception:
return None
def _stamp_from_recipe(entry: dict, recipe: dict) -> bool:
"""Mutate entry with recipe-derived fields. Returns True if anything changed."""
model = recipe.get("model") or {}
meta = recipe.get("meta") or {}
features = recipe.get("features") or {}
changed = False
new_min = (model.get("min_vllm_version") or "").strip()
if new_min and entry.get("min_vllm_version") != new_min:
entry["min_vllm_version"] = new_min
changed = True
if not entry.get("vllm_recipe"):
entry["vllm_recipe"] = True
changed = True
# Hardware support map — useful for filtering "which models run on my AMD box".
hw = meta.get("hardware") or {}
if hw and entry.get("recipe_hardware") != hw:
entry["recipe_hardware"] = {k: str(v) for k, v in hw.items()}
changed = True
# Tool/reasoning parser hints — purely informational at catalog level;
# the live launch command builder still reads them from the recipe API.
if features.get("reasoning") and not entry.get("has_reasoning_parser"):
entry["has_reasoning_parser"] = True
changed = True
if features.get("tool_calling") and not entry.get("has_tool_call_parser"):
entry["has_tool_call_parser"] = True
changed = True
return changed
def _build_new_entry(repo: str, recipe: dict, hf_info=None) -> dict | None:
"""Build a fresh catalog entry from a recipe + (optional) HF model info."""
model = recipe.get("model") or {}
meta = recipe.get("meta") or {}
features = recipe.get("features") or {}
variants = recipe.get("variants") or {}
org, name = repo.split("/", 1)
raw_params = _parse_param_count(model.get("parameter_count"))
active_raw = _parse_param_count(model.get("active_parameters"))
ctx = model.get("context_length") or 0
# Pick the smallest-VRAM variant as the catalog quant — that's what most
# users land on first. NVFP4/MXFP4 typically win this on Blackwell;
# FP8 elsewhere; BF16 baseline only.
pick_quant = None
pick_vram = None
for vk, vv in variants.items():
if not isinstance(vv, dict):
continue
prec = (vv.get("precision") or "").lower()
vram = vv.get("vram_minimum_gb") or 0
quant = _PRECISION_TO_QUANT.get(prec)
if quant and (pick_vram is None or (vram and vram < pick_vram)):
pick_quant = quant
pick_vram = vram or pick_vram
if not pick_quant:
pick_quant = "BF16"
arch = (model.get("architecture") or "").lower()
use_case = _ARCH_USE_CASE.get(arch, "General-purpose chat")
caps = _capabilities_for(arch, meta.get("hardware") or {}, ctx, bool(features.get("reasoning")))
rel_date = ""
downloads = 0
likes = 0
if hf_info is not None:
created = getattr(hf_info, "created_at", None)
if created:
rel_date = created.strftime("%Y-%m-%d")
downloads = int(getattr(hf_info, "downloads", 0) or 0)
likes = int(getattr(hf_info, "likes", 0) or 0)
if not rel_date:
rel_date = str(meta.get("date_updated") or datetime.utcnow().strftime("%Y-%m-%d"))
entry: dict = {
"name": repo,
"provider": org,
"parameter_count": str(model.get("parameter_count") or "?"),
"parameters_raw": raw_params,
"is_moe": "moe" in arch,
"quantization": pick_quant,
"context_length": int(ctx or 0),
"use_case": use_case,
"capabilities": caps,
"pipeline_tag": "text-generation",
"architecture": arch or "unknown",
"hf_downloads": downloads,
"hf_likes": likes,
"release_date": rel_date,
# Recipe-derived bits.
"vllm_recipe": True,
"min_vllm_version": (model.get("min_vllm_version") or "").strip() or None,
"recipe_hardware": {k: str(v) for k, v in (meta.get("hardware") or {}).items()},
"has_reasoning_parser": bool(features.get("reasoning")),
"has_tool_call_parser": bool(features.get("tool_calling")),
}
if active_raw:
entry["active_parameters"] = active_raw
if pick_vram:
# min_vram_gb is what hwfit uses for "does this fit". Recipe states a
# minimum for the chosen variant; round up slightly for KV-cache room.
entry["min_vram_gb"] = float(pick_vram)
entry["min_ram_gb"] = float(round(pick_vram * 0.6, 1))
entry["recommended_ram_gb"] = float(round(pick_vram * 1.2, 1))
# Drop empty / None fields to keep the JSON tidy.
return {k: v for k, v in entry.items() if v not in (None, "", [], {})}
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--update-existing", action="store_true", help="Stamp min_vllm_version + vllm_recipe on existing rows.")
p.add_argument("--add-missing", action="store_true", help="Add new rows for recipe models not in the catalog.")
p.add_argument("--limit", type=int, default=0, help="Stop after N recipe fetches.")
p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.")
p.add_argument("--sleep", type=float, default=0.05, help="Seconds between HTTP requests.")
args = p.parse_args()
if not args.update_existing and not args.add_missing:
args.update_existing = args.add_missing = True
with CATALOG_PATH.open(encoding="utf-8") as f:
catalog = json.load(f)
by_name = {m.get("name"): m for m in catalog if m.get("name")}
client = httpx.Client(follow_redirects=True)
print(f"Catalog: {CATALOG_PATH} ({len(catalog)} entries)")
print("Fetching upstream manifest…")
try:
manifest = _fetch_manifest(client)
except Exception as e:
print(f"FATAL: manifest fetch failed: {e}", file=sys.stderr)
sys.exit(2)
print(f"Manifest: {len(manifest)} recipes")
existing = sorted(by_name.keys() & manifest)
missing = sorted(manifest - by_name.keys())
print(f"Match catalog ↔ manifest: existing={len(existing)} missing={len(missing)}")
targets: list[tuple[str, str]] = [] # (repo, action)
if args.update_existing:
targets.extend((r, "update") for r in existing)
if args.add_missing:
targets.extend((r, "add") for r in missing)
if args.limit:
targets = targets[: args.limit]
print(f"Targets: {len(targets)}")
hf_api = HfApi(token=os.environ.get("HF_TOKEN") or None) if HfApi else None
updated = added = skipped = 0
started = time.time()
for n, (repo, action) in enumerate(targets, 1):
recipe = _fetch_recipe(client, repo)
if not recipe:
print(f"[{n}/{len(targets)}] {repo:55} skip (no recipe fetched)")
skipped += 1
time.sleep(args.sleep)
continue
if action == "update":
entry = by_name[repo]
if _stamp_from_recipe(entry, recipe):
updated += 1
print(f"[{n}/{len(targets)}] {repo:55} updated")
else:
print(f"[{n}/{len(targets)}] {repo:55} unchanged")
else: # add
hf_info = None
if hf_api:
try:
hf_info = hf_api.model_info(repo, files_metadata=False)
except HfHubHTTPError as e:
code = getattr(getattr(e, "response", None), "status_code", "?")
print(f" HF {code} for {repo} — building from recipe only", file=sys.stderr)
except Exception as e:
print(f" HF error for {repo}: {e}", file=sys.stderr)
new_entry = _build_new_entry(repo, recipe, hf_info)
if new_entry:
catalog.append(new_entry)
by_name[repo] = new_entry
added += 1
print(f"[{n}/{len(targets)}] {repo:55} added ({new_entry.get('parameter_count','?')}, {new_entry.get('quantization','?')})")
else:
skipped += 1
print(f"[{n}/{len(targets)}] {repo:55} skip (couldn't build entry)")
time.sleep(args.sleep)
elapsed = time.time() - started
print()
print(f"Done in {elapsed:.1f}s — added={added}, updated={updated}, skipped={skipped}")
if args.dry_run:
print("Dry run — no write.")
return
if added or updated:
tmp = CATALOG_PATH.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(catalog, f, indent=1, ensure_ascii=False)
f.write("\n")
tmp.replace(CATALOG_PATH)
print(f"Wrote {CATALOG_PATH} ({len(catalog)} entries)")
else:
print("No changes — catalog untouched.")
if __name__ == "__main__":
main()
+69 -13
View File
@@ -19,22 +19,32 @@ GPU_BANDWIDTH = {
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
# Apple Silicon unified-memory bandwidth (GB/s). Keyed off the chip name
# reported by sysctl machdep.cpu.brand_string (e.g. "Apple M4 Max"). Listed
# before the bare "m_" keys matters less than length-sorting (done below),
# which guarantees "m4 max" is tried before "m4".
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
"m3 ultra": 800, "m3 max": 300, "m3 pro": 150, "m3": 100,
"m4 max": 546, "m4 pro": 273, "m4": 120,
"m5 max": 546, "m5 pro": 273, "m5": 150,
}
# Pre-sort keys by length descending for correct substring matching
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True)
# metal: backstop for Apple Silicon chips not in GPU_BANDWIDTH (e.g. a future
# M5) — the named chips above take the accurate bandwidth path instead.
# Apple Silicon unified-memory bandwidth (GB/s). For chip families with both
# binned and full variants under the same "Apple Mx Max" brand string, prefer
# GPU core count when hardware detection provides it; otherwise fall back to the
# conservative tier so speed estimates do not over-promise.
APPLE_BANDWIDTH_FIXED = {
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
"m3 ultra": 800, "m3 pro": 150, "m3": 100,
"m4 pro": 273, "m4": 120,
"m5 pro": 307, "m5": 153,
}
APPLE_BANDWIDTH_BY_CORES = {
"m3 max": {30: 300, 40: 400},
"m4 max": {32: 410, 40: 546},
"m5 max": {32: 460, 40: 614},
}
_APPLE_FIXED_KEYS_SORTED = sorted(APPLE_BANDWIDTH_FIXED.keys(), key=len, reverse=True)
_APPLE_VARIANT_KEYS_SORTED = sorted(APPLE_BANDWIDTH_BY_CORES.keys(), key=len, reverse=True)
# metal: backstop for Apple Silicon chips not in the explicit tables above
# (e.g. a future M6) — use a conservative generic estimate when unknown.
FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
USE_CASE_WEIGHTS = {
@@ -60,10 +70,56 @@ CONTEXT_TARGET = {
}
def _lookup_bandwidth(gpu_name):
def _lookup_apple_bandwidth(system):
gpu_name = system.get("gpu_name")
if not isinstance(gpu_name, str) or not gpu_name:
return None
gn = gpu_name.lower()
# Guard against false matches on non-Apple GPUs whose names contain
# "m3"/"m4"/"m5" (e.g. NVIDIA Quadro M4 000).
if "apple" not in gn:
return None
raw_cores = system.get("gpu_cores")
try:
gpu_cores = int(raw_cores) if raw_cores is not None else None
except (TypeError, ValueError):
gpu_cores = None
for key in _APPLE_VARIANT_KEYS_SORTED:
if key not in gn:
continue
if gpu_cores in APPLE_BANDWIDTH_BY_CORES[key]:
return APPLE_BANDWIDTH_BY_CORES[key][gpu_cores]
return min(APPLE_BANDWIDTH_BY_CORES[key].values())
for key in _APPLE_FIXED_KEYS_SORTED:
if key in gn:
return APPLE_BANDWIDTH_FIXED[key]
return None
def _lookup_bandwidth(system):
if isinstance(system, dict):
gpu_name = system.get("gpu_name")
else:
gpu_name = system
if not isinstance(gpu_name, str) or not gpu_name:
return None
# Apple tiers live only in the Apple-specific table now (#2564), so route
# BOTH dict and bare-string callers through it. A bare string carries no
# gpu_cores, so the helper falls back to the conservative (lowest) tier for
# that model -- before #2564 the generic table answered string lookups, and
# dropping that made _lookup_bandwidth("Apple M3 Max") return None.
apple_input = system if isinstance(system, dict) else {"gpu_name": gpu_name}
bw = _lookup_apple_bandwidth(apple_input)
if bw is not None:
return bw
gn = gpu_name.lower()
for key in _BW_KEYS_SORTED:
if key in gn:
return GPU_BANDWIDTH[key]
@@ -84,7 +140,7 @@ def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
"""
pb = _active_params_b(model)
is_moe = model.get("is_moe", False)
bw = _lookup_bandwidth(system.get("gpu_name"))
bw = _lookup_bandwidth(system)
backend = system.get("backend", "cpu_x86")
if bw and run_mode in ("gpu", "cpu_offload"):
+127 -1
View File
@@ -1,3 +1,4 @@
import json
import os
import platform
import re
@@ -335,6 +336,37 @@ def _detect_apple_silicon():
if total_gb <= 0:
return None
def _parse_apple_gpu_cores(text):
if not text:
return None
try:
data = json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
data = None
if isinstance(data, dict):
for gpu in data.get("SPDisplaysDataType") or []:
if not isinstance(gpu, dict):
continue
model = str(gpu.get("sppci_model") or gpu.get("_name") or "")
if "apple" not in model.lower():
continue
cores = gpu.get("sppci_cores")
try:
return int(str(cores).strip())
except (TypeError, ValueError):
continue
m = re.search(r"Total Number of Cores:\s*(\d+)", text)
if m:
try:
return int(m.group(1))
except ValueError:
return None
return None
gpu_cores = _parse_apple_gpu_cores(_run(["system_profiler", "SPDisplaysDataType", "-json"]))
if gpu_cores is None:
gpu_cores = _parse_apple_gpu_cores(_run(["system_profiler", "SPDisplaysDataType"]))
# Usable GPU budget. macOS lets Metal use most of unified memory, but the
# default working-set limit scales with RAM: small machines have to keep
# more back for the OS + app. These fractions track Apple's
@@ -357,7 +389,7 @@ def _detect_apple_silicon():
pass
gpu = {"index": 0, "name": brand, "vram_gb": vram_gb}
return {
info = {
"gpu_name": brand,
"gpu_vram_gb": vram_gb,
"gpu_count": 1,
@@ -369,6 +401,9 @@ def _detect_apple_silicon():
# separate pool — downstream fit logic uses this to avoid double-budgeting.
"unified_memory": True,
}
if gpu_cores is not None:
info["gpu_cores"] = gpu_cores
return info
def _read_file(path):
@@ -611,6 +646,93 @@ def _cache_key(host: str, ssh_port: str, platform_name: str):
)
def _is_containerized():
"""Best-effort check for whether the local Odysseus process is running in a container."""
if _remote_host:
return False
if os.path.exists("/.dockerenv"):
return True
try:
with open("/proc/1/cgroup", encoding="utf-8", errors="replace") as f:
text = f.read().lower()
return any(marker in text for marker in ("docker", "containerd", "kubepods"))
except Exception:
return False
def _hardware_visibility_warning(result):
"""Return a non-blocking UX warning when detected hardware may only be container-visible."""
if not isinstance(result, dict):
return None
if result.get("manual_hardware"):
return None
if not result.get("containerized"):
return None
if result.get("gpu_error"):
return None
if not result.get("has_gpu"):
return {
"code": "container_no_gpu_visible",
"severity": "warning",
"title": "No GPU visible inside Docker",
"message": (
"Cookbook is scanning hardware from inside the Odysseus container. "
"If your host has a GPU, Docker may not be exposing it to the container, "
"so model recommendations may be CPU-only or too conservative."
),
"actions": [
"manual_hardware",
"rescan",
"copy_diagnostics",
],
}
total_ram = result.get("total_ram_gb") or 0
if total_ram and total_ram <= 8:
return {
"code": "container_low_ram_visible",
"severity": "info",
"title": "Container-visible RAM may be lower than host RAM",
"message": (
"Cookbook is seeing the RAM available inside the container. "
"If your host has more memory, validate host RAM separately or use Manual Hardware."
),
"actions": [
"manual_hardware",
"rescan",
"copy_diagnostics",
],
}
return None
def _attach_probe_context(result, host=""):
"""Attach probe-scope metadata and optional hardware visibility warning."""
if not isinstance(result, dict) or result.get("error"):
return result
is_remote = bool(host)
containerized = False if is_remote else _is_containerized()
result["probe_scope"] = "remote" if is_remote else ("container" if containerized else "native")
result["containerized"] = containerized
warning = _hardware_visibility_warning(result)
if warning:
result["hardware_visibility_warning"] = warning
else:
result.pop("hardware_visibility_warning", None)
return result
def detect_system(host="", ssh_port="", platform="", fresh=False):
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely
changes, and probing a remote host over SSH is slow). Pass fresh=True to
@@ -635,6 +757,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
if _remote_platform == "windows" and _remote_host:
result = _detect_windows()
if result:
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
@@ -653,6 +776,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
if not _remote_host and os.name == "nt":
result = _detect_windows()
if result:
result = _attach_probe_context(result, host=host)
_cache_by_host[cache_key] = (now, result)
return result
# PowerShell probe failed entirely — fall through to the generic path
@@ -683,6 +807,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"gpu_name": gpu_info["gpu_name"],
"gpu_vram_gb": gpu_info["gpu_vram_gb"],
"gpu_count": gpu_info["gpu_count"],
"gpu_cores": gpu_info.get("gpu_cores"),
"gpus": gpu_info.get("gpus", []),
"gpu_groups": gpu_info.get("gpu_groups", []),
"homogeneous": gpu_info.get("homogeneous", True),
@@ -714,6 +839,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"gpu_error": _last_gpu_error,
}
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
+42 -26
View File
@@ -66,41 +66,57 @@ def _has_duplicate_title(skills, title: str) -> bool:
def _extract_json_object(text: str) -> Optional[dict]:
"""Best-effort extraction of a JSON object from an LLM response.
The response may be wrapped in code fences or surrounded by prose, and some
models emit a stray brace in the prose before the real object
(e.g. "uses {placeholder} then {...}"). Slicing first-'{' .. last-'}' then
grabs an unparseable span and the skill is silently lost. Try the whole
string first, then each '{' start position in turn, returning the first
candidate that parses to a JSON object (dict). Returns None if none do.
The response may be wrapped in code fences or surrounded by prose. Uses
json.JSONDecoder().raw_decode() to locate the boundaries of complete JSON
objects starting at each '{' position. Nested objects are filtered out to
keep only top-level candidates. If multiple non-overlapping valid JSON
objects are found, it is treated as ambiguous and returns None. Otherwise,
returns the single valid candidate dictionary.
"""
if not text:
return None
s = text.strip()
if s.startswith("```"):
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
end = s.rfind("}")
if end == -1:
decoder = json.JSONDecoder()
candidates = []
start = s.find("{")
while start != -1:
try:
obj, idx = decoder.raw_decode(s[start:])
end_pos = start + idx
if isinstance(obj, dict):
candidates.append((start, end_pos, obj))
except (json.JSONDecodeError, ValueError):
pass
start = s.find("{", start + 1)
# Filter out nested candidates to identify top-level dictionaries
top_level = []
for c in candidates:
is_nested = False
for other in candidates:
if other == c:
continue
if other[0] <= c[0] and c[1] <= other[1]:
is_nested = True
break
if not is_nested:
top_level.append(c)
if not top_level:
return None
def _as_dict(candidate):
try:
obj = json.loads(candidate)
except (json.JSONDecodeError, ValueError):
return None
return obj if isinstance(obj, dict) else None
if len(top_level) > 1:
logger.debug(
"[skill-extract] Found multiple non-overlapping JSON objects: %s",
[item[2].get("title") for item in top_level]
)
return None
# The clean, common case: the whole (de-fenced) string is the object.
obj = _as_dict(s)
if obj is not None:
return obj
# Otherwise scan each '{' candidate up to the last '}'.
start = s.find("{")
while 0 <= start < end:
obj = _as_dict(s[start : end + 1])
if obj is not None:
return obj
start = s.find("{", start + 1)
return None
return top_level[0][2]
async def maybe_extract_skill(
+6 -4
View File
@@ -603,7 +603,6 @@ class SkillsManager:
escalation) — those are work-in-progress and pollute the
prompt with half-finished procedures.
"""
active_toolsets = active_toolsets or []
out = []
for s in self.load(owner=owner):
status = s.get("status")
@@ -617,13 +616,16 @@ class SkillsManager:
# Platform gating
if platform and s.get("platforms") and platform not in s["platforms"]:
continue
# requires_toolsets: hide unless every required toolset is active
# requires_toolsets: hide unless every required toolset is active.
# active_toolsets=None means the caller doesn't know the active
# set (API listings, chat preface) — don't gate in that case;
# only an explicit list filters.
req = s.get("requires_toolsets") or []
if req and not all(t in active_toolsets for t in req):
if req and active_toolsets is not None and not all(t in active_toolsets for t in req):
continue
# fallback_for_toolsets: hide when any of those toolsets is active
fb = s.get("fallback_for_toolsets") or []
if fb and any(t in active_toolsets for t in fb):
if fb and active_toolsets and any(t in active_toolsets for t in fb):
continue
out.append({
"name": s["name"],
+184 -10
View File
@@ -408,7 +408,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
"ask_teacher": "- ```ask_teacher``` — Escalate a hard question to a more capable model. Line 1 = model name or 'auto', rest = the question. Use when stuck or need expert knowledge.",
"list_models": "- ```list_models``` — Show all available AI models across all endpoints. Use when user asks what models are available.",
"manage_session": "- ```manage_session``` — Rename, archive, delete, fork, switch, or `list` chats (the UI calls them 'chats'; 'session' is internal). Line 1 = action (list/switch/rename/archive/unarchive/delete/important/unimportant/truncate/fork), Line 2 = exact chat id from `list_sessions` (or `current` where supported). For delete/archive/truncate, always list first and reuse the exact id; never invent placeholder ids. `switch`/`open` returns a clickable anchor link the user can tap to open the chat — use for \"open my X chat\".",
"manage_memory": "- ```manage_memory``` — Manage the user's persistent memory (facts, identity, preferences, context that persists across chats). Line 1 = action (list/add/edit/delete/search), rest = content. Use when user says 'remember this', states identity facts like 'my name is <name>' / 'call me <name>' / 'I live in <place>', or asks about stored memories.",
"manage_memory": "- ```manage_memory``` — Manage the user's persistent memory (facts about the USER themselves, their preferences, context that persists across chats). Line 1 = action (list/add/edit/delete/search), rest = content. Use when user says 'remember this' about themselves, states identity facts like 'my name is <name>' / 'call me <name>' / 'I live in <place>', or asks about stored memories. DO NOT use for info about another person (their address, phone, email, birthday) — that goes in `manage_contact`. If the user pastes an address/phone with a name and says 'save this for <person>', use `manage_contact add` with the address arg, NOT manage_memory.",
"manage_skills": "- ```manage_skills``` — Skill registry (SKILL.md format). Args (JSON): {\"action\": \"list|view|view_ref|search|add|edit|patch|publish|delete\", ...}. `list` returns the index of available skills (published + teacher-escalation drafts); `view name=foo` fetches the full SKILL.md; `view_ref name=foo path=...` loads a reference file under the skill directory. For `add`, provide an explicit kebab-case `name` and only report the exact returned name, because storage may normalize or dedupe it. Use this BEFORE doing domain work — there may already be a procedure (published or draft) that prescribes the correct steps. Drafts written by the teacher loop are authoritative guidance even though they're not yet published.",
"manage_tasks": "- ```manage_tasks``` — Create and manage scheduled background tasks (recurring AI jobs). Args (JSON): {\"action\": \"list|create|edit|delete|pause|resume|run\", ...}",
"manage_endpoints": "- ```manage_endpoints``` — Add, remove, or configure AI model API endpoints. Args (JSON): {\"action\": \"list|add|delete|enable|disable\", ...}. Use when user wants to add a new AI provider.",
@@ -428,7 +428,9 @@ Notes, checklists, AND user reminders. Use this for "create/add/write a note", t
```send_email
{"to": "recipient@example.com", "subject": "Re: Your question", "body": "Hi, ...", "account": "gmail"}
```
Send a new email via SMTP. Use `resolve_contact` first if you only have a name. If multiple email accounts exist, call `list_email_accounts` first and pass the chosen `account`.""",
Send a new email via SMTP. Use `resolve_contact` first if you only have a name. If multiple email accounts exist, call `list_email_accounts` first and pass the chosen `account`.
CRITICAL — signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar — never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""",
"list_emails": """\
```list_emails
{"folder": "INBOX", "max_results": 20, "unread_only": false, "account": "gmail"}
@@ -439,7 +441,9 @@ List recent emails from a folder, newest first, including read messages by defau
```reply_to_email
{"uid": "1234", "body": "Sounds good — talk Friday.", "account": "gmail"}
```
SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" — those should use `ui_control` with `open_email_reply <uid> <folder> reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).""",
SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" — those should use `ui_control` with `open_email_reply <uid> <folder> reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).
CRITICAL — signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar — never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""",
"bulk_email": """\
```bulk_email
{"action": "delete", "uids": ["10997", "10998"], "folder": "INBOX", "account": "Gmail"}
@@ -449,7 +453,7 @@ Bulk delete/archive/mark emails. Use this for "delete all those" after listing e
"archive_email": "- ```archive_email``` — Archive one email by UID. Args (JSON): {\"uid\":\"...\", \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.",
"mark_email_read": "- ```mark_email_read``` — Mark one email read/unread. Args (JSON): {\"uid\":\"...\", \"read\":true, \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.",
"resolve_contact": "- ```resolve_contact``` — Look up a contact's email by name. Searches CardDAV address book + sent email history. Args (JSON): {\"name\": \"...\"}. Use BEFORE send_email when the user gives only a name.",
"manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"uid\": \"...\"}. Use only for explicit address-book/contact requests with contact details. Do NOT use for user identity facts like 'my name is <name>'; save those with manage_memory. For update/delete, call action=list first to get the uid.",
"manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"phones\": [...], \"address\": \"...\", \"uid\": \"...\"}. Use for info about another person: email, phone, postal address. For 'save this for <person>' / address paste / phone next to a name, use this — NOT manage_memory. Do NOT use for user identity facts ('my name is X'); those are manage_memory. For update/delete, call action=list first for the uid.",
"manage_calendar": """\
```manage_calendar
{"action": "create_event", "summary": "<event title>", "dtstart": "<natural language or ISO datetime>"}
@@ -606,7 +610,7 @@ _API_HOSTS = frozenset([
"api.deepseek.com", "deepseek.com",
"api.together.xyz", "api.fireworks.ai",
"api.perplexity.ai", "api.x.ai",
"ollama.com", "api.venice.ai",
"ollama.com", "api.venice.ai", "api.kimi.com",
"api.githubcopilot.com",
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
# Without these, `_is_api_model` falls back to keyword sniffing on the
@@ -859,6 +863,7 @@ def _build_system_prompt(
compact: bool = False,
owner: Optional[str] = None,
suppress_local_context: bool = False,
active_email: Optional[Dict[str, str]] = None,
) -> List[Dict]:
"""Build agent system prompt, inject MCP/document context, merge consecutive system msgs."""
global _cached_base_prompt, _cached_base_prompt_key
@@ -1051,6 +1056,66 @@ def _build_system_prompt(
else:
set_active_document(None)
# Active email reader — frontend told us the user has an email open.
# Inject a context block so "reply", "summarize this", "what does it say"
# resolve to the real UID instead of the agent inventing a fresh .md
# draft with fake headers. This is the email equivalent of _doc_message.
_email_message = None
if active_email and active_email.get("uid"):
_em_uid = active_email.get("uid", "")
_em_folder = active_email.get("folder", "INBOX")
_em_account = active_email.get("account", "")
_em_subject = active_email.get("subject", "") or "(no subject)"
_em_from = active_email.get("from", "") or "(unknown sender)"
_em_preview = (active_email.get("body_preview", "") or "").strip()
_preview_block = f"\nBody preview:\n```\n{_em_preview[:1800]}\n```" if _em_preview else ""
_acct_arg = f" {_em_account}" if _em_account else ""
email_ctx = (
f"ACTIVE EMAIL OPEN (the user has this email open in a reader window right now)\n"
f"UID: {_em_uid}\n"
f"Folder: {_em_folder}\n"
f"Account: {_em_account or '(default)'}\n"
f"From: {_em_from}\n"
f"Subject: {_em_subject}{_preview_block}\n\n"
f"CRITICAL DEFAULT — every request about email this turn refers to "
f"THIS email unless the user names a DIFFERENT specific recipient "
f"(a name, an email address, or another thread). Examples that "
f"ALL mean reply-to-the-open-email:\n"
f" • 'reply' / 'reply to this' / 'respond'\n"
f" • 'write email saying X' / 'send email saying X' / 'draft something'\n"
f" • 'tell them X' / 'say hi' / 'thanks' / 'ack' / 'lmk'\n"
f" • 'summarize it' / 'what does it say' / 'tldr'\n"
f" • 'forward this' / 'forward to <addr>'\n"
f"DO NOT ASK THE USER 'who do you want to send this to?' — the "
f"answer is ALWAYS the sender of the open email (above) unless they "
f"named someone else. Asking that is the wrong move every time.\n\n"
f"RULES for the open email:\n"
f"1. DRAFT a reply (default for any 'write/send/reply/tell them' "
f"request without a different recipient): call `ui_control` with "
f"`action=\"open_email_reply\"` and `extra=\"{_em_uid} {_em_folder} "
f"reply\"`. This opens the proper reply doc with To/Subject/"
f"In-Reply-To pre-filled by the backend. The user will see and edit "
f"it before sending. DO NOT `create_document` a markdown file with "
f"hand-written `To:` / `Subject:` / `In-Reply-To:` headers — that "
f"is wrong every time.\n"
f"2. SEND a reply immediately (skip the draft): call "
f"`reply_to_email` with the UID above. Only do this when the user "
f"explicitly says 'send' / 'send the reply' / 'reply and send'.\n"
f"3. READ the full body (the preview above may be truncated): "
f"call `read_email` with the UID/folder/account above.\n"
f"4. SUMMARIZE / answer questions about it: read it first, then "
f"answer in chat. Don't create a document for a summary unless "
f"the user explicitly asks for one.\n"
f"5. Never ask the user to paste the email or 'share it with you' "
f"— you already have its identity above and can read the full body.\n"
f"6. The ONLY time you ask 'who to send to?' is when the user "
f"explicitly says 'send a NEW email to someone else' or names a "
f"recipient you can't identify. A bare 'send email saying X' = the "
f"open email's sender.\n"
)
_email_message = untrusted_context_message("active email reader", email_ctx)
_email_message["_protected"] = True
# Inject writing style for any email writing path. This is deliberately
# broader than read/list: models may compose via send_email, reply_to_email,
# or ui_control open_email_reply after the first tool round.
@@ -1258,6 +1323,9 @@ def _build_system_prompt(
if _doc_message:
merged.insert(last_user_idx, _doc_message)
last_user_idx += 1 # the document message is now at last_user_idx
if _email_message:
merged.insert(last_user_idx, _email_message)
last_user_idx += 1
if _skills_message:
merged.insert(last_user_idx, _skills_message)
last_user_idx += 1
@@ -1292,12 +1360,18 @@ def _build_base_prompt(
from src.tool_index import ALWAYS_AVAILABLE
disabled = set(disabled_tools or [])
if not get_setting("image_gen_enabled", True):
if not get_setting("image_gen_enabled", False):
disabled.add("generate_image")
if relevant_tools is not None:
# RAG mode: include always-available + retrieved + admin (if needed)
tool_names = set(ALWAYS_AVAILABLE) | set(relevant_tools)
# RAG mode: trust the relevant_tools set as already-composed.
# get_tools_for_query starts from ALWAYS_AVAILABLE and may
# *discard* tools that conflict with the query's intent (e.g.
# drop manage_memory for clear contact-save patterns). Unioning
# ALWAYS_AVAILABLE back in here used to silently undo those
# drops. Only force-include the irreducible loop primitives
# (ask_user, update_plan) as belt-and-suspenders.
tool_names = set(relevant_tools) | {"ask_user", "update_plan"}
if needs_admin:
tool_names |= _ADMIN_TOOLS
agent_prompt = _assemble_prompt(tool_names, disabled, compact=compact)
@@ -1738,6 +1812,7 @@ async def stream_agent_loop(
max_tool_calls: int = 0,
context_length: int = 0,
active_document=None,
active_email: Optional[Dict[str, str]] = None,
session_id: Optional[str] = None,
disabled_tools: Optional[Set[str]] = None,
owner: Optional[str] = None,
@@ -1904,6 +1979,44 @@ async def stream_agent_loop(
if _relevant_tools is not None and active_document is not None:
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
# The skill index injected by _build_system_prompt tells the model to
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
# into the prompt as procedures to follow — but neither path goes through
# tool selection, so the model can be handed a procedure naming tools
# (grep, read_file, ...) that aren't in its schema list. Keep the schemas
# in lockstep: manage_skills is callable whenever any skill is indexed,
# and a matched skill's declared requires_toolsets ride along with it.
if not guide_only and _relevant_tools is not None:
try:
from services.memory.skills import SkillsManager
from src.constants import DATA_DIR
_skills_on = True
try:
from routes.prefs_routes import _load_for_user as _load_prefs
_skills_on = (_load_prefs(owner) or {}).get("skills_enabled", True)
except Exception:
pass
_sm = SkillsManager(DATA_DIR)
_owner_skills = _sm.load(owner=owner) if _skills_on else []
if _owner_skills:
_relevant_tools.add("manage_skills")
if _retrieval_query:
# Validate against every known executable tool, not just
# TOOL_SECTIONS — code-nav tools (grep/glob/ls) ship as
# schemas without a prompt-prose section.
from src.tool_policy import known_tool_names
_known = known_tool_names()
for _sk in _sm.get_relevant_skills(
_retrieval_query, skills=_owner_skills,
threshold=0.25, max_items=3,
):
_relevant_tools.update(
t for t in (_sk.get("requires_toolsets") or [])
if t in _known
)
except Exception as _e:
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}")
if _relevant_tools is not None:
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
@@ -1987,6 +2100,7 @@ async def stream_agent_loop(
compact=_is_api_model,
owner=owner,
suppress_local_context=guide_only,
active_email=active_email,
)
if plan_mode and not guide_only:
# Steer the model to investigate-then-propose. Hard tool gating handles
@@ -2167,9 +2281,17 @@ async def stream_agent_loop(
elif _is_api_model:
# Filter schemas by RAG-selected tools (if available)
if _relevant_tools:
# _build_base_prompt unions _ADMIN_TOOLS into the prompt
# sections when admin intent fires — the schema list must
# offer the same names, or the model reads prose describing
# tools it cannot call and substitutes the nearest schema
# it does have (e.g. manage_memory for manage_skills).
_schema_names = set(_relevant_tools)
if _needs_admin:
_schema_names |= _ADMIN_TOOLS
base_schemas = [
s for s in FUNCTION_TOOL_SCHEMAS
if s.get("function", {}).get("name") in _relevant_tools
if s.get("function", {}).get("name") in _schema_names
]
_mcp_filtered = [
s for s in mcp_schemas
@@ -2705,6 +2827,46 @@ async def stream_agent_loop(
)
desc, result = await _tool_task
# A skill the model just loaded can prescribe tools that weren't
# RAG-selected this turn (declared via requires_toolsets in its
# frontmatter). Union them into the selection so the NEXT round's
# schema list includes them — otherwise the model reads "use
# grep" from the skill it fetched but has no grep schema to call.
if (
block.tool_type == "manage_skills"
and _relevant_tools is not None
and not result.get("error")
):
_ms_args = {}
_ms_raw = (block.content or "").strip()
if _ms_raw.startswith("{"):
try:
_ms_args = json.loads(_ms_raw)
except json.JSONDecodeError:
_ms_args = {}
_ms_name = str(_ms_args.get("name", "") or "").strip()
if _ms_name and _ms_args.get("action") in ("view", "view_ref"):
try:
from services.memory.skills import SkillsManager as _SkM
from src.constants import DATA_DIR as _DD
from src.tool_policy import known_tool_names as _ktn
_known = _ktn()
for _sk in _SkM(_DD).load(owner=owner):
if _sk.get("name") == _ms_name:
_new = {
t for t in (_sk.get("requires_toolsets") or [])
if t in _known and t not in _relevant_tools
}
if _new:
_relevant_tools.update(_new)
logger.info(
"[tool-rag] skill '%s' unlocked tools for next round: %s",
_ms_name, sorted(_new),
)
break
except Exception as _e:
logger.debug(f"skill requires_toolsets unlock skipped: {_e}")
# Extract structured web sources from web_search tool output.
# web_search returns {"output": ..., "exit_code": 0}; check "output"
# first so the <!-- SOURCES:…--> marker is found and stripped even
@@ -2824,7 +2986,19 @@ async def stream_agent_loop(
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
if "ui_event" in result:
tool_output_data["ui_event"] = result["ui_event"]
for k in ("toggle_name", "state", "mode", "model", "endpoint_url", "theme_name", "colors"):
for k in (
"toggle_name", "state", "mode", "model", "endpoint_url",
"theme_name", "colors",
# ui_control open_email_reply payload — without these the
# frontend openReplyDraft bails on undefined uid and the
# reply window silently never opens.
"uid", "folder", "account_id",
# Optional pre-filled body for open_email_reply so the
# agent can compose-and-open in one tool call.
"body",
# ui_control open_panel payload
"panel",
):
if k in result:
tool_output_data[k] = result[k]
# Forward image data from generate_image tool
+55 -14
View File
@@ -972,16 +972,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
memories = [m for m in memories if m.get("category", "").lower() == category_filter]
if not memories:
return {"results": "No memories found" + (f" in category '{category_filter}'" if category_filter else "") + "."}
result_lines = [f"Found {len(memories)} memory entries:\n"]
for m in memories[:100]:
for m in memories:
cat = m.get("category", "fact")
mid = m.get("id", "?")[:8]
text = m.get("text", "")
if len(text) > 150:
text = text[:150] + "..."
result_lines.append(f"- [{cat}] `{mid}` — {text}")
if len(memories) > 100:
result_lines.append(f"... and {len(memories) - 100} more")
return {"results": "\n".join(result_lines)}
elif action == "add":
@@ -1293,7 +1292,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
open_panel <name> — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text] — Open a reply draft document for an email; does not send. ALWAYS append the body text when the user told you what to say (one-shot draft); only omit body when the user just asked to "open a reply" without content.
get_toggles — Return current toggle states (server-side knowledge)
"""
lines = content.strip().split("\n")
@@ -1537,21 +1536,54 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
elif action == "open_email_reply":
reply_parts = lines[0].strip().split()
uid = reply_parts[1].strip() if len(reply_parts) > 1 else ""
folder = reply_parts[2].strip() if len(reply_parts) > 2 else "INBOX"
mode = reply_parts[3].strip().lower() if len(reply_parts) > 3 else "reply"
# Two forms supported:
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
# <body text on subsequent lines or after the mode token>
# The body text (if any) gets pre-filled into the reply draft so the
# agent can compose-and-open in one tool call instead of opening an
# empty draft and leaving the user to wonder what happened.
first_line = lines[0].strip()
parts = first_line.split(maxsplit=4)
uid = parts[1].strip() if len(parts) > 1 else ""
folder = parts[2].strip() if len(parts) > 2 else "INBOX"
mode = parts[3].strip().lower() if len(parts) > 3 else "reply"
# Body: everything on the first line after the mode token, plus any
# subsequent lines. Allows multi-line bodies.
inline_body = parts[4] if len(parts) > 4 else ""
rest_lines = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
body = (inline_body + ("\n" + rest_lines if rest_lines else "")).strip()
if not uid:
return {"error": "open_email_reply needs: open_email_reply <uid> [folder] [reply|reply-all|ai-reply]"}
return {"error": "open_email_reply needs: open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text]"}
if mode not in ("reply", "reply-all", "ai-reply"):
mode = "reply"
return {
# Body is REQUIRED for the agent path. Opening an empty draft is what
# users do by clicking the Reply button — they don't ask the agent
# for that. Every agent invocation of open_email_reply MUST include
# the body. Reject empty so the agent retries with the content the
# user asked for. Exception: ai-reply mode triggers the existing
# AI-Reply path on the frontend which generates its own body.
if not body and mode != "ai-reply":
return {
"error": (
"open_email_reply called without body. The agent path REQUIRES a body — "
"opening an empty draft is the wrong response when the user asked you to write. "
"Re-call with the reply text included: "
f"`open_email_reply {uid} {folder or 'INBOX'} {mode} <your reply text here>`. "
"Compose the reply now based on the open email's content and the user's request, "
"then call this tool again with the body. Do NOT call create_document instead."
),
}
result = {
"ui_event": "open_email_reply",
"uid": uid,
"folder": folder or "INBOX",
"mode": mode,
"results": f"Opening reply draft for email UID {uid}",
"results": f"Opening reply draft for email UID {uid}" + (" with pre-filled body" if body else ""),
}
if body:
result["body"] = body
return result
elif action == "get_toggles":
return {
@@ -1581,7 +1613,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
"""
import base64
import httpx
import os
from pathlib import Path
from src.url_safety import check_outbound_url
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
@@ -1747,8 +1781,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
elif img.get("url"):
# Download external URL and save locally (DALL-E returns temp URLs)
result_url = img["url"]
ok, reason = check_outbound_url(
result_url,
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
return {"error": f"Image API returned unsafe image URL: {reason}"}
try:
dl_resp = httpx.get(img["url"], timeout=60)
dl_resp = httpx.get(result_url, timeout=60)
if dl_resp.status_code == 200:
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
@@ -1758,10 +1799,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
else:
image_url = img["url"] # fallback to external URL
image_url = result_url # fallback to external URL
except Exception as _dl_e:
logger.warning(f"Failed to download DALL-E image: {_dl_e}")
image_url = img["url"] # fallback to external URL
image_url = result_url # fallback to external URL
else:
return {"error": "Image API returned unexpected format (no b64_json or url)"}
+178 -1
View File
@@ -128,6 +128,17 @@ def validate_caldav_url(raw_url: str) -> str:
return urlunparse(parsed._replace(fragment="")).rstrip("/")
def _event_etag(obj) -> str:
"""Best-effort ETag extraction from python-caldav resources."""
try:
etag = getattr(obj, "etag", None)
if callable(etag):
etag = etag()
return str(etag or "")
except Exception:
return ""
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
"""Deterministic local id for a remote CalDAV calendar, scoped to owner
and account so two users — or one user with two accounts — pointing at
@@ -316,11 +327,12 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
color="#5b8abf",
source="caldav",
account_id=account_id or None,
caldav_base_url=remote_url,
)
db.add(local_cal)
db.commit()
else:
# Refresh display name and stamp account_id if missing.
# Refresh display name and stamp CalDAV metadata if missing.
changed = False
if local_cal.name != display_name:
local_cal.name = display_name
@@ -328,6 +340,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
if account_id and not local_cal.account_id:
local_cal.account_id = account_id
changed = True
if local_cal.caldav_base_url != remote_url:
local_cal.caldav_base_url = remote_url
changed = True
if changed:
db.commit()
result["calendars"] += 1
@@ -395,6 +410,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
if existing:
if existing.caldav_sync_pending in {"create", "update"}:
result["events"] += 1
continue
existing.calendar_id = local_cal.id
existing.summary = summary
existing.description = description
@@ -405,6 +423,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
existing.is_utc = row_is_utc
existing.rrule = rrule
existing.origin = "caldav"
existing.remote_href = str(getattr(obj, "url", "") or "") or None
existing.remote_etag = _event_etag(obj) or None
existing.caldav_sync_pending = None
else:
new_ev = CalendarEvent(
uid=uid_val,
@@ -418,6 +439,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
is_utc=row_is_utc,
rrule=rrule,
origin="caldav",
remote_href=str(getattr(obj, "url", "") or "") or None,
remote_etag=_event_etag(obj) or None,
)
db.add(new_ev)
pending[uid_val] = new_ev
@@ -442,6 +465,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
CalendarEvent.origin == "caldav",
CalendarEvent.dtstart >= start,
CalendarEvent.dtstart <= end,
CalendarEvent.remote_href.isnot(None),
CalendarEvent.caldav_sync_pending.is_(None),
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
).all()
for ev in stale:
@@ -458,6 +483,92 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
return result
def _event_payload(ev) -> dict:
return {
"uid": ev.uid,
"summary": ev.summary,
"description": ev.description,
"location": ev.location,
"dtstart": ev.dtstart,
"dtend": ev.dtend,
"all_day": ev.all_day,
"is_utc": ev.is_utc,
"rrule": ev.rrule or "",
}
def _load_event_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
from core.database import CalendarCal, CalendarEvent, SessionLocal
db = SessionLocal()
try:
ev = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if not ev or not ev.calendar or ev.calendar.source != "caldav":
return None
return ev.calendar.source, ev.calendar.id, _event_payload(ev)
finally:
db.close()
def _load_delete_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
db = SessionLocal()
try:
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == uid,
CalendarDeletedEvent.owner == owner,
).first()
if tombstone:
return "caldav", tombstone.calendar_id, {"uid": uid}
ev = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if not ev or not ev.calendar or ev.calendar.source != "caldav":
return None
return ev.calendar.source, ev.calendar.id, {"uid": uid}
finally:
db.close()
def _pending_writeback_uids(owner: str) -> tuple[list[str], list[str]]:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
db = SessionLocal()
try:
rows = (
db.query(CalendarEvent.uid)
.join(CalendarCal)
.filter(
CalendarCal.owner == owner,
CalendarCal.source == "caldav",
CalendarEvent.status != "cancelled",
(
(CalendarEvent.caldav_sync_pending.isnot(None))
| (CalendarEvent.remote_href.is_(None))
),
)
.all()
)
delete_rows = (
db.query(CalendarDeletedEvent.uid)
.filter(CalendarDeletedEvent.owner == owner)
.all()
)
return [row[0] for row in rows], [row[0] for row in delete_rows]
finally:
db.close()
def _load_caldav_accounts(owner: str) -> list:
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
@@ -533,3 +644,69 @@ async def sync_caldav(owner: str) -> dict:
for err in result.get("errors", []):
totals["errors"].append(f"{label}: {err}")
return totals
async def push_event_create(owner: str, uid: str) -> dict:
loaded = _load_event_for_writeback(owner, uid)
if not loaded:
return {"ok": True, "skipped": True}
source, calendar_id, payload = loaded
from src.caldav_writeback import writeback_event
return await writeback_event(owner, source, calendar_id, payload)
async def push_event_update(owner: str, uid: str) -> dict:
return await push_event_create(owner, uid)
async def push_event_delete(owner: str, uid: str) -> dict:
loaded = _load_delete_for_writeback(owner, uid)
if not loaded:
return {"ok": True, "skipped": True}
source, calendar_id, payload = loaded
from src.caldav_writeback import writeback_event
return await writeback_event(owner, source, calendar_id, payload, delete=True)
async def push_pending_events(owner: str) -> dict:
result = {"events": 0, "errors": []}
uids, delete_uids = _pending_writeback_uids(owner)
for event_uid in uids:
try:
out = await push_event_update(owner, event_uid)
if out.get("ok"):
result["events"] += 1
elif not out.get("skipped"):
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
except Exception as e:
logger.warning("CalDAV pending push failed for uid=%s: %s", event_uid, e)
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
for event_uid in delete_uids:
try:
out = await push_event_delete(owner, event_uid)
if out.get("ok"):
result["events"] += 1
elif not out.get("skipped"):
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
except Exception as e:
logger.warning("CalDAV pending delete failed for uid=%s: %s", event_uid, e)
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
return result
async def sync_caldav_direction(owner: str, direction: str = "pull") -> dict:
direction = (direction or "pull").strip().lower()
if direction == "pull":
return await sync_caldav(owner)
if direction == "push":
return await push_pending_events(owner)
if direction == "both":
pushed = await push_pending_events(owner)
pulled = await sync_caldav(owner)
return {"push": pushed, "pull": pulled}
return {
"calendars": 0,
"events": 0,
"deleted": 0,
"errors": [f"Unsupported CalDAV sync direction: {direction}"],
}
+92 -6
View File
@@ -89,6 +89,23 @@ def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_
return None
def _resource_href(obj) -> str:
try:
return str(getattr(obj, "url", "") or "")
except Exception:
return ""
def _resource_etag(obj) -> str:
try:
etag = getattr(obj, "etag", None)
if callable(etag):
etag = etag()
return str(etag or "")
except Exception:
return ""
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
owner: str = "", account_id: str = "") -> dict:
"""Create/update (or delete) ``ev`` on the matching remote calendar.
@@ -105,6 +122,7 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
if remote is None:
return {"ok": False, "error": "remote calendar not found"}
remote_url = str(getattr(remote, "url", "") or "")
try:
existing = remote.event_by_uid(uid)
@@ -113,17 +131,34 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
if delete:
if existing is None:
return {"ok": True, "note": "already absent on remote"}
return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
existing.delete()
return {"ok": True}
return {
"ok": True,
"calendar_url": remote_url,
"remote_href": _resource_href(existing),
"remote_etag": _resource_etag(existing),
}
ical = build_event_ical(ev)
if existing is not None:
existing.data = ical
existing.save()
return {"ok": True, "updated": True}
remote.save_event(ical)
return {"ok": True, "created": True}
return {
"ok": True,
"updated": True,
"calendar_url": remote_url,
"remote_href": _resource_href(existing),
"remote_etag": _resource_etag(existing),
}
created = remote.save_event(ical)
return {
"ok": True,
"created": True,
"calendar_url": remote_url,
"remote_href": _resource_href(created),
"remote_etag": _resource_etag(created),
}
def _discover_calendars(client):
@@ -154,6 +189,54 @@ def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
owner=owner, account_id=account_id)
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
if not uid or not isinstance(result, dict):
return
db = SessionLocal()
try:
calendar = db.query(CalendarCal).filter(
CalendarCal.id == calendar_id,
CalendarCal.owner == owner,
).first()
if calendar and result.get("calendar_url"):
calendar.caldav_base_url = result.get("calendar_url")
if delete:
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == uid,
CalendarDeletedEvent.owner == owner,
).first()
if result.get("ok"):
if tombstone:
db.delete(tombstone)
elif tombstone:
tombstone.last_error = str(result.get("error") or result)[:500]
db.commit()
return
event = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if event and result.get("ok"):
if result.get("remote_href"):
event.remote_href = result.get("remote_href")
if result.get("remote_etag"):
event.remote_etag = result.get("remote_etag")
event.caldav_sync_pending = None
db.commit()
except Exception:
db.rollback()
logger.exception("CalDAV write-back metadata persistence failed")
finally:
db.close()
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
ev: dict, *, delete: bool = False) -> dict:
"""Best-effort push of a local change to the remote CalDAV server.
@@ -204,9 +287,12 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
result = await asyncio.to_thread(
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
)
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
if not result.get("ok"):
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
return result
except Exception as e:
logger.exception("CalDAV write-back raised")
return {"ok": False, "error": str(e)[:200]}
result = {"ok": False, "error": str(e)[:200]}
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
return result
+11 -3
View File
@@ -244,9 +244,17 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
protected_tokens = estimate_tokens(protected_msgs)
budget -= protected_tokens
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo)
essential_system = system_msgs[:1] if system_msgs else []
extra_system = system_msgs[1:]
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo).
# Exception: a research-spinoff primer (the seeded report that grounds a
# "Discuss" chat) must never be dropped — it is the conversation's whole
# knowledge base. Treat any system message carrying research_spinoff_from
# metadata as essential alongside the leading system prompt.
def _is_research_primer(m):
return bool((m.get("metadata") or {}).get("research_spinoff_from"))
_primers = [m for m in system_msgs if _is_research_primer(m)]
_non_primer = [m for m in system_msgs if not _is_research_primer(m)]
essential_system = (_non_primer[:1] if _non_primer else []) + _primers
extra_system = _non_primer[1:]
# Try dropping extra system messages one by one (from the end)
trimmed = essential_system + convo_msgs
+58 -3
View File
@@ -199,11 +199,20 @@ def _fit_inline_attachment_text(
return text[:remaining] + marker, 0
def _process_office_document(path: str, display_name: str) -> str:
def _process_office_document(
path: str,
display_name: str,
session_id: str | None = None,
auto_opened_docs: list[Dict[str, Any]] | None = None,
owner: str | None = None,
) -> str:
"""Extract an Office/EPUB document to Markdown via the optional markitdown dep.
Falls back to a friendly banner when markitdown is unavailable or finds no
text, so a missing optional dependency never breaks the chat path.
text, so a missing optional dependency never breaks the chat path. When a
session_id is provided AND the extraction succeeded, the FULL text is also
saved as a Document so the agent can page through it via
`manage_documents action=read offset=…` after the inline copy is capped.
"""
from src.markitdown_runtime import (
is_markitdown_format,
@@ -218,6 +227,46 @@ def _process_office_document(path: str, display_name: str) -> str:
if markdown and markdown.strip():
title = os.path.splitext(os.path.basename(path))[0]
body, marker = _truncate_inline(markdown)
# Persist the full extracted text as a Document. The agent's existing
# manage_documents tool can then read past the inline cap with offset.
doc_id = None
if session_id:
try:
from src.office_doc import create_office_document
doc_id = create_office_document(
session_id=session_id,
upload_id=os.path.basename(path),
title=title,
body_text=markdown,
)
if doc_id and auto_opened_docs is not None:
from src.database import SessionLocal, Document
_db = SessionLocal()
try:
_d = _db.query(Document).filter(Document.id == doc_id).first()
if _d:
auto_opened_docs.append({
"doc_id": _d.id,
"title": _d.title,
"language": _d.language,
"content": _d.current_content,
"version": _d.version_count,
})
finally:
_db.close()
except Exception as e:
logger.warning("Office auto-doc creation failed for %s: %s", path, e)
# Upgrade the truncation marker with a hint pointing at the full doc so
# the agent knows it can read the rest.
if doc_id and marker:
marker = (
f"\n[…truncated for inline context — full {len(markdown):,} chars "
f"saved as document `{doc_id}`. Use `manage_documents` with "
f"action=read, document_id={doc_id}, offset=<N> to page through.]"
)
return f"\n\n[Document content — {title}]:\n{body}{marker}"
# No content: tell the user whether to install the optional dep or whether
@@ -521,7 +570,13 @@ def build_user_content(
elif mime.startswith("text/") or _is_text_file(path):
extracted_text = _process_text_file(path)
else:
extracted_text = _process_office_document(path, display_name)
extracted_text = _process_office_document(
path,
display_name,
session_id=session_id,
auto_opened_docs=auto_opened_docs,
owner=owner,
)
extracted_text, inline_attachment_remaining = _fit_inline_attachment_text(
extracted_text,
+12 -6
View File
@@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict
from urllib.parse import urlparse, urlunparse
from core.database import SessionLocal, ModelEndpoint
from src.llm_core import _detect_provider, _host_match, _ollama_api_root
from src.llm_core import _detect_provider, _host_match, _is_kimi_code_url, KIMI_CODE_USER_AGENT, _ollama_api_root
logger = logging.getLogger(__name__)
@@ -201,11 +201,15 @@ def build_models_url(base: str) -> Optional[str]:
return _ollama_api_root(base) + "/tags"
if provider == "chatgpt-subscription":
return None
# Generic OpenAI-compatible fallback: ensure the path lands on /v1/models
# when the user omitted a path entirely. If a non-empty path is already
# present (e.g. /openai, /api/openai/v1, /v1), trust the caller — the
# /models suffix is appended as-is and the caller's prefix is preserved.
if not urlparse(base).path:
# Generic OpenAI-compatible fallback: local model servers with no explicit
# path conventionally expose `/v1/models` (LM Studio, llama.cpp, vLLM).
# For non-local unknown hosts, do not invent `/v1`; append `/models` to the
# caller's base so look-alike provider hosts stay generic.
parsed = urlparse(base)
host = (parsed.hostname or "").lower()
is_local = host in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
uses_v1_models_by_default = is_local or host in {"api.deepseek.com"}
if not parsed.path and uses_v1_models_by_default:
base = base + "/v1"
return base + "/models"
@@ -230,6 +234,8 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
if provider == "openrouter":
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
headers.setdefault("X-OpenRouter-Title", "Odysseus")
if _is_kimi_code_url(base):
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
return headers
+178 -7
View File
@@ -442,6 +442,146 @@ def _host_match(url: str, *domains: str) -> bool:
return any(host == d or host.endswith("." + d) for d in domains)
# Kimi Code subscription keys (api.kimi.com/coding/v1) require a whitelisted
# coding-agent User-Agent; otherwise the API returns 403 access_terminated_error.
# Tried in order; first success is cached per base URL for later requests.
KIMI_CODE_USER_AGENTS: tuple[str, ...] = (
"claude-code/0.1.0",
"claude-code/1.0.0",
"KimiCLI/1.0",
"Kilo-Code/1.0",
"Roo-Code/1.0",
"Cursor/1.0",
)
KIMI_CODE_USER_AGENT = KIMI_CODE_USER_AGENTS[0]
_kimi_code_ua_cache: dict[str, str] = {}
def _is_kimi_code_url(url: str) -> bool:
if not url or not _host_match(url, "kimi.com"):
return False
try:
return "/coding" in (urlparse(url).path or "")
except Exception:
return False
def _kimi_code_base_key(url: str) -> str:
"""Normalize a Kimi Code chat/models URL to its OpenAI base (.../coding/v1)."""
parsed = urlparse(url)
path = (parsed.path or "").rstrip("/")
for suffix in ("/chat/completions", "/models", "/completions"):
if path.endswith(suffix):
path = path[: -len(suffix)]
path = path.rstrip("/") or "/coding/v1"
return f"{parsed.scheme}://{parsed.netloc}{path}"
def _is_kimi_code_access_denied(status: int, body: bytes | str) -> bool:
if status != 403:
return False
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else (body or "")
lower = text.lower()
return (
"access_terminated_error" in lower
or "coding agents" in lower
or "only available for coding" in lower
)
def _kimi_code_ua_candidates(url: str) -> list[str]:
if not _is_kimi_code_url(url):
return []
base_key = _kimi_code_base_key(url)
cached = _kimi_code_ua_cache.get(base_key)
if cached:
return [cached] + [ua for ua in KIMI_CODE_USER_AGENTS if ua != cached]
return list(KIMI_CODE_USER_AGENTS)
def _remember_kimi_code_user_agent(url: str, user_agent: str) -> None:
_kimi_code_ua_cache[_kimi_code_base_key(url)] = user_agent
def apply_kimi_code_headers(headers: Optional[Dict], url: str) -> Dict[str, str]:
"""Pick a Kimi Code User-Agent (cached probe when possible)."""
h = dict(headers or {})
if not _is_kimi_code_url(url):
return h
base_key = _kimi_code_base_key(url)
cached = _kimi_code_ua_cache.get(base_key)
if cached:
h["User-Agent"] = cached
return h
models_url = base_key.rstrip("/") + "/models"
from src.tls_overrides import llm_verify
for ua in KIMI_CODE_USER_AGENTS:
trial = dict(h)
trial["User-Agent"] = ua
try:
r = httpx.get(models_url, headers=trial, timeout=8, verify=llm_verify())
except Exception:
continue
if _is_kimi_code_access_denied(r.status_code, r.content):
logger.debug("Kimi Code rejected User-Agent %s (403), trying next", ua)
continue
if r.status_code < 400:
_remember_kimi_code_user_agent(url, ua)
h["User-Agent"] = ua
return h
break
h.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
return h
def httpx_get_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
h = apply_kimi_code_headers(headers, url)
if not _is_kimi_code_url(url):
return httpx.get(url, headers=h, **kwargs)
last = None
for ua in _kimi_code_ua_candidates(url):
trial = dict(h)
trial["User-Agent"] = ua
last = httpx.get(url, headers=trial, **kwargs)
if not _is_kimi_code_access_denied(last.status_code, last.content):
if last.status_code < 400:
_remember_kimi_code_user_agent(url, ua)
return last
return last
def httpx_post_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
h = apply_kimi_code_headers(headers, url)
if not _is_kimi_code_url(url):
return httpx.post(url, headers=h, **kwargs)
last = None
for ua in _kimi_code_ua_candidates(url):
trial = dict(h)
trial["User-Agent"] = ua
last = httpx.post(url, headers=trial, **kwargs)
if not _is_kimi_code_access_denied(last.status_code, last.content):
if last.status_code < 400:
_remember_kimi_code_user_agent(url, ua)
return last
return last
async def httpx_post_kimi_aware_async(client, url: str, headers: Optional[Dict], **kwargs):
h = apply_kimi_code_headers(headers, url)
if not _is_kimi_code_url(url):
return await client.post(url, headers=h, **kwargs)
last = None
for ua in _kimi_code_ua_candidates(url):
trial = dict(h)
trial["User-Agent"] = ua
last = await client.post(url, headers=trial, **kwargs)
if not _is_kimi_code_access_denied(last.status_code, last.content):
if last.status_code < 400:
_remember_kimi_code_user_agent(url, ua)
return last
return last
def _detect_provider(url: str) -> str:
"""Detect the API provider from a configured endpoint URL.
@@ -465,6 +605,8 @@ def _detect_provider(url: str) -> str:
return "groq"
if _host_match(url, "nvidia.com"):
return "nvidia"
if _host_match(url, "moonshot.ai") or _host_match(url, "moonshot.cn"):
return "moonshot"
from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(url):
return "chatgpt-subscription"
@@ -561,6 +703,12 @@ def _provider_label(url: str) -> str:
if _host_match(url, "googleapis.com"): return "Google"
if _host_match(url, "together.xyz", "together.ai"): return "Together"
if _host_match(url, "fireworks.ai"): return "Fireworks"
if _host_match(url, "kimi.com"):
try:
if "/coding" in (urlparse(url).path or ""):
return "Kimi Code"
except Exception:
pass
if _is_ollama_native_url(url): return "Ollama"
try:
host = (urlparse(url).hostname or "").lower()
@@ -701,7 +849,7 @@ def _uses_max_completion_tokens(model: str) -> bool:
# perfectly good model as failing. For these models we omit the field and let
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
# not a reasoning model and accepts temperature normally.)
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5")
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5", "kimi-for-coding")
def _restricts_temperature(model: str) -> bool:
"""Check if a model rejects any non-default temperature."""
@@ -710,6 +858,28 @@ def _restricts_temperature(model: str) -> bool:
m = model.lower()
return any(m.startswith(p) or f"/{p}" in m for p in _FIXED_TEMPERATURE_MODELS)
# The official Moonshot API fixes temperature at 1.0 in thinking mode and 0.6
# when thinking is explicitly disabled for Kimi K2.5/K2.6. Any other explicit
# value returns HTTP 400. Odysseus does not currently send the `thinking` mode
# control, so omit temperature and let Moonshot use its default thinking mode.
# Keep the gate provider-specific: self-hosted Kimi deployments may accept
# custom sampling values, and older Moonshot models have different defaults.
def _moonshot_rejects_custom_temperature(provider: str, model: str) -> bool:
"""Check if the official Moonshot API fixes temperature for this model."""
if provider != "moonshot" or not isinstance(model, str):
return False
model_id = model.lower().rsplit("/", 1)[-1]
return bool(re.match(r"^kimi-k2\.(?:5|6)(?:$|[-_:])", model_id))
def _omit_temperature(provider: str, model: str) -> bool:
"""Check if a request should use the provider's default temperature."""
return _restricts_temperature(model) or _moonshot_rejects_custom_temperature(
provider, model
)
# Anthropic removed the sampling parameters (temperature, top_p, top_k) starting
# with Claude Opus 4.7. On Opus 4.7 and later, sending `temperature` at all —
# even 0.0 — returns HTTP 400. Earlier Claude models (Opus 4.6 and below, every
@@ -1157,7 +1327,7 @@ def list_model_ids(
from src.endpoint_resolver import build_models_url
models_url = build_models_url(base_chat_url)
r = httpx.get(models_url, headers=h, timeout=timeout)
r = httpx_get_kimi_aware(models_url, h, timeout=timeout)
r.raise_for_status()
data = r.json()
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
@@ -1258,14 +1428,14 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
"messages": messages_copy,
"temperature": temperature,
}
if _restricts_temperature(model):
if _omit_temperature(provider, model):
payload.pop("temperature", None)
if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = max_tokens
try:
note_model_activity(target_url, model)
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout)
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
except Exception as e:
raise HTTPException(502, f"POST {target_url} failed: {e}")
if not r.is_success:
@@ -1452,7 +1622,7 @@ async def llm_call_async(
"messages": messages_copy,
"temperature": temperature,
}
if _restricts_temperature(model):
if _omit_temperature(provider, model):
payload.pop("temperature", None)
if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
@@ -1473,7 +1643,7 @@ async def llm_call_async(
try:
note_model_activity(target_url, model)
client = _get_http_client()
r = await client.post(target_url, headers=h, json=payload, timeout=call_timeout)
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
duration = time.time() - start
if not r.is_success:
friendly = _format_upstream_error(r.status_code, r.text, target_url)
@@ -1569,7 +1739,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
"temperature": temperature,
"stream": True,
}
if _restricts_temperature(model):
if _omit_temperature(provider, model):
payload.pop("temperature", None)
if provider not in {"openrouter", "groq"}:
payload["stream_options"] = {"include_usage": True}
@@ -1870,6 +2040,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
events.append(_stream_delta_event(part))
return events
h = apply_kimi_code_headers(h, target_url)
try:
client = _get_http_client()
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
+44
View File
@@ -40,15 +40,59 @@ def load_markitdown():
return MarkItDown
def _extract_docx_native(path: str) -> str | None:
"""Pure-Python .docx text extractor — no external deps.
A .docx file is just a zip of XML. The body prose lives in <w:t> runs
inside <w:p> paragraphs. Iterating with ElementTree (rather than
re.findall) keeps paragraph breaks intact and lets the XML parser handle
namespaces + entity unescaping. Loses tables, footnotes, images and
list bullets — keeps ~95% of "summarize this doc" content, which is the
case people hit when markitdown isn't installed.
"""
import zipfile
import xml.etree.ElementTree as ET
ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
try:
with zipfile.ZipFile(path) as z:
xml_bytes = z.read("word/document.xml")
except (zipfile.BadZipFile, KeyError, OSError):
return None
try:
root = ET.fromstring(xml_bytes)
except ET.ParseError:
return None
paragraphs: list[str] = []
for para in root.iter(f"{ns}p"):
runs = [t.text or "" for t in para.iter(f"{ns}t")]
line = "".join(runs).strip()
if line:
paragraphs.append(line)
return "\n\n".join(paragraphs) if paragraphs else None
def convert_to_markdown(path: str) -> str | None:
"""Convert a document to Markdown text via markitdown.
Returns the extracted Markdown, or ``None`` if markitdown is unavailable or
the conversion fails — callers degrade gracefully rather than erroring.
Fallback: when markitdown isn't installed and the file is a .docx, run
the bundled pure-Python extractor so the most common case (Word docs)
works out of the box. Other Office/EPUB formats still need markitdown.
"""
try:
markitdown_cls = load_markitdown()
except RuntimeError:
if isinstance(path, str) and path.lower().endswith(".docx"):
text = _extract_docx_native(path)
if text:
logger.info(
"markitdown not installed — used native .docx extractor for %s",
path,
)
return text
logger.warning("markitdown not installed; cannot extract %s", path)
return None
try:
+73
View File
@@ -0,0 +1,73 @@
"""Auto-create a Document row from an Office attachment.
When a .docx (and friends) lands in chat, the full extracted text is stored
as a Document so the agent can page through it with `manage_documents
action=read offset=…` even after the inline chat payload was capped. Mirrors
the PDF auto-doc pattern in `src.pdf_form_doc`.
"""
import logging
import uuid
from typing import Optional
logger = logging.getLogger(__name__)
def create_office_document(
session_id: str,
upload_id: str,
title: str,
body_text: Optional[str] = None,
) -> Optional[str]:
"""Create a markdown Document for an Office attachment and set it active.
Returns the new doc_id, or None on failure / empty body. The full
extracted body lives in `current_content`, so the agent can fetch
arbitrary windows via `manage_documents action=read` even when the
inline chat copy was truncated.
"""
from src.database import (
SessionLocal,
Document,
DocumentVersion,
Session as DbSession,
)
from src.agent_tools.document_tools import set_active_document
if not body_text or not body_text.strip():
return None
db = SessionLocal()
try:
doc_id = str(uuid.uuid4())
ver_id = str(uuid.uuid4())
sess = db.query(DbSession).filter(DbSession.id == session_id).first()
doc = Document(
id=doc_id,
session_id=session_id,
title=title,
language="markdown",
current_content=body_text,
version_count=1,
is_active=True,
owner=sess.owner if sess else None,
)
ver = DocumentVersion(
id=ver_id,
document_id=doc_id,
version_number=1,
content=body_text,
summary="Imported from Office attachment",
source="upload",
)
db.add(doc)
db.add(ver)
db.commit()
set_active_document(doc_id)
return doc_id
except Exception as e:
db.rollback()
logger.error("Failed to create office document: %s", e)
return None
finally:
db.close()
+78
View File
@@ -0,0 +1,78 @@
"""Server-side mirror of the built-in characters used for reminder synthesis.
The frontend ships these in static/js/presets.js (PROMPT_TEMPLATES with
isCharacter:true). The Reminders → AI Synthesis card writes only the
persona ID into settings; the synthesis route in note_routes.py needs
the full prompt text to bias the utility model's voice. Keeping a small
local mirror avoids having the client send the prompt over the wire on
every reminder fire.
If the user picks a custom character (id == "custom") we fall back to
the warm-neutral baseline — custom prompts live in browser localStorage
and aren't visible to the server.
"""
PERSONAS = {
"socrates": (
"Never answer directly. Respond only with questions — sharp, layered, "
"Socratic. Expose contradictions. Make the person argue with themselves "
"until the truth falls out. Use irony like a scalpel. Be genuinely "
"curious, never condescending."
),
"razor": (
"Strip everything to the bone. No filler, no hedging, no pleasantries. "
"Answer in the fewest words possible. If one sentence works, don't use "
"two. If a word adds nothing, cut it. Blunt, precise, surgical."
),
"nietzsche": (
"Think and respond through the lens of Nietzsche. Analyze every "
"question in terms of will to power, self-overcoming, eternal "
"recurrence, ressentiment, value-creation, and master-slave morality. "
"Write with aphoristic force — sharp, compressed, vivid, and "
"unapologetic — but do not sacrifice depth for style. Favor "
"life-affirmation, discipline, courage, style, rank, self-overcoming, "
"and amor fati over nihilism, conformity, ressentiment, and self-pity."
),
"spark": (
"You are Spark, a playful, quick-witted assistant with bright energy "
"and practical instincts. Keep responses concise, vivid, and helpful. "
"Be warm without being cloying, imaginative without losing the thread, "
"and always center the user's actual goal. Use a light, lively voice "
"with occasional clever turns of phrase."
),
"odysseus": (
"You are Odysseus, king of Ithaca — subtle in counsel, disciplined in "
"judgment, and unmatched in strategic cunning. Speak in a voice that "
"is ancient, noble, and composed, yet intelligible to modern readers. "
"Be eloquent but not flowery. Be wise but not vague. Speak as one who "
"has weathered storms and taken back his house by wit, timing, and "
"resolve."
),
}
_DEFAULT_SYNTHESIS_TONE = (
"You write short, warm, one-line reminders. The user has set a note for "
"themselves and the moment to remember has arrived. Keep it under 18 "
"words. Be human, gentle, and direct — never robotic."
)
def synthesis_system_prompt(persona_id: str) -> str:
"""Return the system prompt for reminder synthesis given a persona id.
Falls back to the warm-neutral baseline when the id is empty, unknown,
or refers to a custom (client-only) character we don't have on file.
"""
persona = (persona_id or "").strip().lower()
persona_prompt = PERSONAS.get(persona)
if persona_prompt:
# Persona drives the voice; the synthesis-instruction stays attached
# so the model knows it's writing a short reminder, not a chat reply.
return (
persona_prompt
+ "\n\n"
+ "You are now writing a single one-line reminder for the user. "
"Keep it under 18 words and in the voice above."
)
return _DEFAULT_SYNTHESIS_TONE
+10 -1
View File
@@ -29,7 +29,15 @@ def _invalidate_caches():
# ── Default values ──
DEFAULT_SETTINGS = {
"image_gen_enabled": True,
# Agent email safety: when True, the MCP send_email / reply_to_email
# tools don't SMTP directly. They stage the composed message into the
# scheduled_emails table with status='agent_draft' and return a
# pending_id + the rendered email so the user can review and approve
# (or cancel) before it actually goes out. Default ON because models
# have been observed inventing signatures and sending to real
# recipients without confirmation.
"agent_email_confirm": True,
"image_gen_enabled": False,
"image_model": "",
"image_quality": "medium",
"vision_model": "",
@@ -151,6 +159,7 @@ DEFAULT_SETTINGS = {
# Reminders
"reminder_channel": "browser", # "browser" | "email" | "ntfy" | "webhook"
"reminder_llm_synthesis": False,
"reminder_llm_persona": "",
"reminder_ntfy_topic": "Reminders",
"reminder_email_to": "",
# Generic outbound webhook channel: pick any saved Integration as the
+13
View File
@@ -1338,11 +1338,24 @@ class TaskScheduler:
return await self._execute_checkin(task, crew, db, session_id, endpoint_url, model)
# Build system prompt: crew member persona overrides the default.
# Built-in character_id (Socrates, Razor, etc.) further biases the
# voice — it prepends to whichever base prompt we landed on so the
# task still knows it's executing a scheduled task but in that
# character's tone.
system_prompt = (
(crew.personality or "").strip()
if crew and crew.personality
else "You are a helpful assistant executing a scheduled task. Use available tools to complete the task thoroughly."
)
char_id = (getattr(task, "character_id", None) or "").strip()
if char_id:
try:
from src.reminder_personas import PERSONAS as _PERSONAS
char_prompt = _PERSONAS.get(char_id.lower())
if char_prompt:
system_prompt = f"{char_prompt}\n\n{system_prompt}"
except Exception:
pass
# Inject current time so the model knows what's past vs upcoming
tz_name = _resolve_task_timezone(db, task)
try:
+1 -1
View File
@@ -42,7 +42,7 @@ _SOTA_HOSTS = frozenset({
"api.together.xyz", "api.fireworks.ai",
"api.perplexity.ai", "api.x.ai",
"generativelanguage.googleapis.com", "api.groq.com",
"openrouter.ai", "ollama.com", "api.venice.ai",
"openrouter.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
})
+56 -1
View File
@@ -18,6 +18,40 @@ from core.constants import internal_api_base
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Active email state
# ---------------------------------------------------------------------------
# When the user has an email reader window open, the frontend tells the
# backend about it on each chat submit. Email tools can resolve "this email"
# without guessing a UID. Cleared between requests by chat_routes.
_active_email_ref: Optional[Dict[str, str]] = None
def set_active_email(uid: Optional[str], folder: Optional[str] = None, account: Optional[str] = None,
subject: Optional[str] = None, sender: Optional[str] = None) -> None:
"""Stash the email currently open in the UI. None clears it."""
global _active_email_ref
if not uid:
_active_email_ref = None
return
_active_email_ref = {
"uid": str(uid),
"folder": str(folder or "INBOX"),
"account": str(account or ""),
"subject": str(subject or ""),
"from": str(sender or ""),
}
def get_active_email() -> Optional[Dict[str, str]]:
return _active_email_ref
def clear_active_email() -> None:
global _active_email_ref
_active_email_ref = None
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
@@ -1445,7 +1479,15 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
from datetime import datetime, timedelta
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
from routes.calendar_routes import _ensure_default_calendar, _parse_dt, _parse_dt_pair, parse_due_for_user, _resolve_base_uid
from routes.calendar_routes import (
_ensure_default_calendar,
_parse_dt,
_parse_dt_pair,
parse_due_for_user,
_resolve_base_uid,
_push_caldav_event_after_commit,
_record_caldav_delete_tombstone,
)
import uuid as _uuid
try:
@@ -1825,6 +1867,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
rrule=args.get("rrule", "") or "",
event_type=event_type,
importance=importance,
caldav_sync_pending="create" if cal.source == "caldav" else None,
)
db.add(ev)
reminder_note_id = None
@@ -1839,6 +1882,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
dtstart_is_utc and not all_day,
)
db.commit()
if cal.source == "caldav":
await _push_caldav_event_after_commit(owner, uid, "create")
tag_blurb = f" [{event_type}]" if event_type else ""
if minutes_before is None:
reminder_blurb = ""
@@ -1896,7 +1941,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
ev.event_type = _tag or None
if args.get("importance") is not None:
ev.importance = args["importance"]
is_caldav = ev.calendar and ev.calendar.source == "caldav"
if is_caldav:
ev.caldav_sync_pending = "update"
db.commit()
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "update")
return {"response": f"Updated event {uid}", "exit_code": 0}
elif action == "delete_event":
@@ -1910,8 +1960,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
if not ev:
return {"error": f"Event {uid} not found", "exit_code": 1}
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
if is_caldav:
_record_caldav_delete_tombstone(db, ev, owner)
db.delete(ev)
db.commit()
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "delete")
return {"response": f"Deleted event {uid}", "exit_code": 0}
else:
+64 -5
View File
@@ -88,14 +88,14 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.",
"list_models": "List all available AI models and their endpoints.",
"manage_session": "Chat management: rename, archive, delete, or fork chats (the UI calls these 'chats'; internally 'sessions'). Use for 'rename my chats', 'rename this chat', 'archive/delete a chat'.",
"manage_memory": "Memory management: list, add, edit, delete, or search persistent memories.",
"manage_memory": "Memory management: list, add, edit, delete, or search persistent memories. For facts about the USER (their name, preferences, where they live). NOT for info about ANOTHER person — addresses, phones, emails belonging to a contact go in manage_contact, not memory.",
"manage_skills": "Skill management: add, update, publish, or search reusable skills/presets.",
"manage_tasks": "Scheduled task management: list, create, edit, delete, pause, resume, or run cron tasks.",
"manage_endpoints": "Endpoint management: list, add, delete, enable, or disable model API endpoints.",
"manage_mcp": "MCP server management: list, add, delete, reconnect servers, or list available tools.",
"manage_webhooks": "Webhook management: list, add, delete, enable, or disable webhooks.",
"manage_tokens": "API token management: list, create, or delete API access tokens.",
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content. action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"create_session": "Create a new chat with a name and model.",
@@ -104,7 +104,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"search_chats": "Search past session transcripts across chats.",
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
@@ -115,7 +115,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.",
"bulk_email": "Perform one action on many emails at once. Use for delete all those, archive these, mark all read, move spam to junk. Takes explicit UIDs from list_emails or all_unread=true. Always pass account for Gmail/work/custom mailbox results.",
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
"manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is <name>'; those are memory.",
"manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for <person>' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.",
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
@@ -372,7 +372,19 @@ class ToolIndex:
{"resolve_contact", "manage_contact"},
frozenset({"save contact", "add contact", "new contact", "update contact",
"edit contact", "delete contact", "remove contact",
"save this person", "add to contacts", "save to contacts"}):
"save this person", "add to contacts", "save to contacts",
# "add <name> to (my) contacts" — words between 'add' and
# 'contacts' break the literal phrase match above, so anchor
# on the tail.
"to my contacts", "to contacts", "to address book",
# "save this for <person>" / "save it for <person>" — the user
# is storing info on a known person without using the literal
# word 'contact'. Catches the address/phone-paste pattern.
"save this for", "save it for", "save for",
"save this one for", "save that for",
# Postal-address-like signals
"postal code", "zip code", "street address",
"mailing address", "their address"}):
{"manage_contact"},
# "Ask another model" intent → chat_with_model relays to a
# different model and returns its answer. ask_teacher escalates
@@ -507,6 +519,53 @@ class ToolIndex:
# prompts do not drag web schemas into the agent context.
if self._WEB_RE.search(query):
base.update({"web_search", "web_fetch"})
# Hard steering: when the query is a clear "save info about a specific
# person" pattern (address paste + name, phone next to a name, etc.),
# the model has been observed defaulting to manage_memory even with
# manage_contact in the toolset. Pull memory out for these queries so
# the model literally cannot pick it. ALWAYS_AVAILABLE includes
# manage_memory by default; we override that here.
# The "for/to <word>" check needs to allow lowercase names (users
# don't always capitalize) but filter out timing/pronoun stopwords
# so "save this for later" / "save for tomorrow" don't trigger.
_CONTACT_STOPWORDS_AFTER_FOR = {
"later", "tomorrow", "yesterday", "now", "then", "today",
"tonight", "me", "us", "you", "him", "her", "them", "myself",
"yourself", "next", "this", "that", "the", "a", "an", "future",
"real", "use", "uses", "another", "future", "reference",
}
# Regex catches "save (this|it|the|her|...|<noun>) for <name>" / "to my
# contacts" patterns. More forgiving than literal-keyword matching —
# 'save this address for Alex' uses one extra word between 'save' and
# 'for' that breaks the contiguous 'save this for' phrase.
save_for_match = re.search(
r"\bsave\b(?:\s+\w+){0,3}\s+(?:for|to)\s+([A-Za-z]+)",
ql,
)
# "to my contacts", "into my contacts", "in my address book", etc.
to_contacts = re.search(r"\b(?:to|in|into)\s+(?:my\s+)?(?:contacts|address\s+book)\b", ql)
# Possessive: "save (his|her|their) (address|phone|email|number) ..."
# — strong contact signal even without "for <name>". Force-include
# manage_contact here too since the keyword fallback misses this
# construction.
possessive_contact = re.search(
r"\bsave\b(?:\s+\w+){0,2}\s+(?:his|her|their)\s+(?:address|phone|number|email|contact|details)",
ql,
)
word_after = (
save_for_match.group(1).lower() if save_for_match else None
)
contact_only_signal = (
(save_for_match is not None
and word_after is not None
and word_after not in _CONTACT_STOPWORDS_AFTER_FOR)
or to_contacts is not None
or possessive_contact is not None
)
if possessive_contact is not None:
base.add("manage_contact")
if contact_only_signal and "manage_contact" in base:
base.discard("manage_memory")
return base
+2 -1
View File
@@ -1022,7 +1022,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_contact",
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact ('save Jonathan's email jon@x.com'), update an existing one ('change Maria's number'), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
"parameters": {
"type": "object",
"properties": {
@@ -1033,6 +1033,7 @@ FUNCTION_TOOL_SCHEMAS = [
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."},
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."},
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."},
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
},
"required": ["action"]
}
+3 -2
View File
@@ -130,11 +130,12 @@ fi
# 3. Python environment + dependencies (kept inside the repo, in venv/).
# Named `venv` to match the manual steps and build-macos-app.sh, so the
# clickable .app reuses this same environment.
if [ ! -d venv ]; then
VENV_PY="./venv/bin/python3"
if [ ! -x "$VENV_PY" ] || ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then
[ -d venv ] && { echo "▶ Existing venv is incomplete (no working pip) — rebuilding…"; rm -rf venv; }
echo "▶ Creating Python environment…"
"$PY" -m venv venv
fi
VENV_PY="./venv/bin/python3"
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
REQ_HASH_FILE="venv/.requirements_hash"
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
+3 -14
View File
@@ -1218,7 +1218,7 @@ function initializeEventListeners() {
sortDropdown.querySelectorAll('.sort-option').forEach(o => {
const check = o.querySelector('.sort-check') || document.createElement('span');
check.className = 'sort-check';
check.style.cssText = 'float:right;font-size:20px;line-height:1;position:relative;top:3px;color:var(--accent, var(--red));opacity:' + (o.dataset.sort === current ? '1' : '0');
check.style.cssText = 'float:right;font-size:20px;line-height:1;position:relative;top:1px;color:var(--accent, var(--red));opacity:' + (o.dataset.sort === current ? '1' : '0');
check.textContent = '\u2022';
if (!o.querySelector('.sort-check')) o.appendChild(check);
});
@@ -1262,9 +1262,9 @@ function initializeEventListeners() {
let msg;
if (data.updated > 0) {
msg = `Sorted ${data.updated} into ${data.folders.length} folder${data.folders.length === 1 ? '' : 's'}`;
if (remaining > 0) msg += ` — ${remaining} unfiled left, hit Tidy again`;
if (remaining > 0) msg += ` — ${remaining} unfiled left, hit Group again`;
} else if (remaining > 0) {
msg = `${remaining} unfiled chats — hit Tidy again`;
msg = `${remaining} unfiled chats — hit Group again`;
} else {
msg = 'All sorted';
}
@@ -1285,17 +1285,6 @@ function initializeEventListeners() {
const autoSortBtn = el('auto-sort-sessions-btn');
if (autoSortBtn) autoSortBtn.addEventListener('click', () => _runTidy(false));
// Chevron next to the Tidy row toggles the no-AI sub-item.
const autoSortMoreBtn = el('auto-sort-sessions-more');
const autoSortNoaiBtn = el('auto-sort-sessions-noai-btn');
if (autoSortMoreBtn && autoSortNoaiBtn) {
autoSortMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
autoSortNoaiBtn.style.display = autoSortNoaiBtn.style.display === 'none' ? 'block' : 'none';
});
autoSortNoaiBtn.addEventListener('click', () => _runTidy(true));
}
}
// Model sort dropdown
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+347 -258
View File
@@ -12,7 +12,7 @@
in email bodies — was wrapping random digits in <a href="tel:..."> with
browser-default styling that didn't match the Odysseus theme. -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
(function(){
@@ -258,21 +258,29 @@
<div class="memory-tab-panel" data-memory-panel="browse">
<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;">Memories <span id="memory-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Memories <span id="memory-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
<span style="flex:1"></span>
<span class="admin-toggle-state"></span>
<label class="admin-switch" title="Include memories in chat context"><input type="checkbox" id="memory-enabled-header-toggle" checked /><span class="admin-slider"></span></label>
</div>
<p class="memory-desc doclib-desc" style="margin-top:6px;">Long-term facts the AI remembers across chats — recall, edit, or curate.</p>
<div class="memory-toolbar">
<div class="memory-toolbar-row">
<select id="memory-sort" class="memory-sort-select" aria-label="Sort memories">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="alpha">A-Z</option>
<option value="uses">Most used</option>
</select>
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories">Select</button>
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
<div class="memory-sort-picker" id="memory-sort-picker" style="position:relative;">
<select id="memory-sort" class="memory-sort-select" aria-label="Sort memories" style="display:none;">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="alpha">A-Z</option>
<option value="uses">Most used</option>
</select>
<button type="button" class="memory-sort-btn" id="memory-sort-btn" aria-haspopup="listbox" aria-expanded="false">
<span class="memory-sort-current"><span class="memory-sort-icon-cur"></span><span class="memory-sort-label">Newest</span></span>
<svg class="memory-sort-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="memory-sort-menu" id="memory-sort-menu" role="listbox" hidden></div>
</div>
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories" style="position:relative;left:-2px;"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
</div>
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" aria-label="Search memories" />
<div id="memory-category-filters" class="memory-category-filters">
@@ -293,7 +301,7 @@
<div class="memory-tab-panel hidden" data-memory-panel="add">
<div class="admin-card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Add Memory</h2>
<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;color:var(--accent, var(--red));"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Add Memory</h2>
<span style="flex:1"></span>
<button id="memory-import-btn" class="theme-io-btn" title="Import memories from a file" style="height:26px;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;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Import</button>
<button id="memory-export-btn" class="theme-io-btn" title="Export all memories as JSON" style="height:26px;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;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Export</button>
@@ -312,7 +320,7 @@
</div>
<div class="admin-card">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Add Skill</h2>
<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;color:var(--accent, var(--red));"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Add Skill</h2>
</div>
<p class="memory-desc doclib-desc" style="margin-top:6px;">Import a skill from GitHub or <a href="https://skills.sh" target="_blank" rel="noopener noreferrer">skills.sh</a> (folder with <code>SKILL.md</code> and optional templates).</p>
<div class="memory-add-row" style="margin-top:6px;margin-bottom:10px;">
@@ -348,8 +356,9 @@
<div class="memory-tab-panel hidden" data-memory-panel="skills">
<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;">Skills <span id="skills-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Skills <span id="skills-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
<span style="flex:1"></span>
<span class="admin-toggle-state"></span>
<label class="admin-switch" title="Inject relevant skills into chat context"><input type="checkbox" id="skills-enabled-header-toggle" checked /><span class="admin-slider"></span></label>
</div>
<p class="memory-desc doclib-desc" style="margin-top:6px;">Reusable procedures the AI can call via /skill — sort by confidence to surface the proven ones.</p>
@@ -374,8 +383,8 @@
<option value="filter:conf70">Confidence ≤ 70%</option>
</optgroup>
</select>
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills">Select</button>
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Audit all</button>
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Audit</button>
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
</div>
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" aria-label="Search skills" />
</div>
@@ -395,34 +404,23 @@
<!-- ── Settings tab ── -->
<div class="memory-tab-panel hidden" data-memory-panel="settings">
<div class="admin-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<h2 style="margin:0">Auto-extract memories</h2>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-extract memories</h2>
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-memory-toggle" checked /><span class="admin-slider"></span></label>
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px">Automatically extract memories from conversations.</span>
</div>
<div class="admin-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<h2 style="margin:0">Auto-extract skills</h2>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-extract skills</h2>
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-skills-toggle" /><span class="admin-slider"></span></label>
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Automatically draft reusable skills from your workflows. Audit all can publish passing skills using the threshold below.</span>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">The library can grow; cleanup retires weak/duplicate skills only after review.</span>
</div>
<div class="admin-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<h2 style="margin:0">Inject Skills</h2>
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
<span class="admin-toggle-sub" style="margin:0">Max skills per request</span>
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" aria-label="Max skills to inject" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.5">Set to 0 to disable skill injection.</span>
</div>
<div class="admin-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<h2 style="margin:0">Auto-approve skills</h2>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-approve skills</h2>
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-approve-skills-toggle" checked /><span class="admin-slider"></span></label>
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Audit all publishes passing, necessary skills at or above this confidence. Off = keep audit results as drafts unless manually approved.</span>
@@ -434,6 +432,17 @@
</span>
</div>
</div>
<div class="admin-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M12 5v14"/><polyline points="6 11 12 17 18 11"/><path d="M5 20h14"/></svg>Inject Skills</h2>
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
<span class="admin-toggle-sub" style="margin:0">Max skills per request</span>
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" aria-label="Max skills to inject" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
</div>
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.5">Set to 0 to disable skill injection.</span>
</div>
</div>
</div>
</div>
@@ -704,12 +713,9 @@
<div class="section-header-flex">
<span class="section-title" id="chats-section-title"><svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span id="chats-section-label" class="section-title-label">Chats</span><span id="chats-notif-dot" class="sidebar-notif-dot" style="display:none"></span></span>
<div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;">
<button type="button" class="section-header-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<path d="M9 7h6M9 11h4"/>
</svg>
<button type="button" class="section-header-btn list-item-plus-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
<span aria-hidden="true" style="display:inline-block;width:13px;height:13px;"></span>
<span class="list-item-plus-label">manage</span>
</button>
<button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions">
<svg class="sort-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -726,14 +732,11 @@
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="newest">Newest First</div>
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="group">By Folder</div>
<div class="dropdown-item sort-dropdown-item sort-dropdown-sep" id="auto-sort-sessions-row" style="display:flex;align-items:center;padding:0;">
<span id="auto-sort-sessions-btn" style="flex:1;padding:5px 10px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;">
<span class="auto-sort-icon"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</span>
<span id="auto-sort-sessions-btn" style="flex:1;padding:5px 10px 5px 4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
<span class="auto-sort-icon"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg></span>
<span>Group</span>
<span class="auto-sort-spinner" style="display:none;">Sorting...</span>
</span>
<button type="button" id="auto-sort-sessions-more" title="Tidy options" aria-label="Tidy options" style="background:none;border:none;border-left:1px solid var(--border);color:inherit;cursor:pointer;padding:5px 8px;font-size:9px;opacity:0.7;"><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<div class="dropdown-item sort-dropdown-item" id="auto-sort-sessions-noai-btn" style="display:none;padding-left:24px;">
Tidy <span class="auto-sort-noai-spinner" style="display:none;font-size:9px;opacity:0.6;margin-left:4px;">Cleaning...</span>
</div>
<div class="dropdown-item rearrange-toggle sort-dropdown-item sort-dropdown-sep" id="session-rearrange-toggle">
&#8593;&#8595; Rearrange <span class="rearrange-check" style="float:right; opacity:0;">&#x2022;</span>
@@ -1330,7 +1333,6 @@
</button>
<button class="close-btn" aria-label="Close settings">✖</button>
</div>
<div class="admin-toggle-sub" style="padding:0 12px 8px;opacity:0.6;font-size:11px;">Toggle on/off visibility of tools and modules across the interface.</div>
<div class="settings-layout">
<div class="settings-sidebar">
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
@@ -1338,6 +1340,10 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>
<span>Add Models</span>
</button>
<button class="settings-nav-item" data-settings-tab="added-models">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>Added Models</span>
</button>
<button class="settings-nav-item" data-settings-tab="ai">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
<span>AI Defaults</span>
@@ -1404,14 +1410,21 @@
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Endpoint</label>
<span class="adm-model-logo" id="set-defaultEpSelect-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-defaultEpSelect" class="settings-select"></select>
</div>
<div class="settings-row">
<label class="settings-label">Model</label>
<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 id="set-defaultFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
<div class="settings-row" style="align-items:flex-start;">
<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>
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
</div>
</div>
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -1421,14 +1434,21 @@
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Endpoint</label>
<span class="adm-model-logo" id="set-utilityEpSelect-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-utilityEpSelect" class="settings-select"><option value="">—</option></select>
</div>
<div class="settings-row">
<label class="settings-label">Model</label>
<span class="adm-model-logo" id="set-utilityModelSelect-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-utilityModelSelect" class="settings-select"><option value="">—</option></select>
</div>
<div id="set-utilityFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-utilityAddFallback" title="Add a model to try if the utility model fails">+ Add fallback</button>
<div class="settings-row" style="align-items:flex-start;">
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-utilityFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-utilityAddFallback" title="Add a model to try if the utility model fails">+ Add fallback</button>
</div>
</div>
<div id="set-utilityChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -1438,16 +1458,22 @@
<div style="display:flex;flex-direction:column;gap:0.5rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label">Model</label>
<span class="adm-model-logo" id="set-vlModelSelect-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-vlModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
</div>
<div id="set-visionFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-visionAddFallback" title="Add a vision model to try if the one above fails">+ Add fallback</button>
<div class="settings-row" style="align-items:flex-start;">
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-visionFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-visionAddFallback" title="Add a vision model to try if the one above fails">+ Add fallback</button>
</div>
</div>
<div id="set-visionSettingsMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Research Model</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Model used for Deep Research. Falls back to the default chat model if not set.</div>
<div class="admin-toggle-sub" style="margin-bottom:8px">Model used for Deep Research, more settings under <a href="#" data-go-settings-tab="search" style="color:var(--accent, var(--red));text-decoration:underline;font-weight:600;">Search →</a></div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Endpoint</label>
@@ -1457,48 +1483,17 @@
</div>
<div class="settings-row">
<label class="settings-label">Model</label>
<span class="adm-model-logo" id="set-researchModel-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-researchModel" class="settings-select">
<option value="">Same as chat</option>
</select>
</div>
<div class="settings-row">
<label class="settings-label">Search</label>
<select id="set-researchSearch" class="settings-select">
<option value="">Same as web search</option>
<option value="searxng">SearXNG</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="tavily">Tavily</option>
<option value="brave">Brave</option>
<option value="google">Google</option>
<option value="serper">Serper</option>
</select>
</div>
<div class="settings-row">
<label class="settings-label">Max Tokens</label>
<input id="set-researchMaxTokens" type="text" inputmode="numeric" placeholder="8192 (default)" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Extract Timeout</label>
<input id="set-researchExtractTimeout" type="text" inputmode="numeric" placeholder="90 sec" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Extract Parallel</label>
<input id="set-researchExtractConcurrency" type="text" inputmode="numeric" placeholder="3" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Max Time</label>
<input id="set-researchRunTimeout" type="text" inputmode="numeric" placeholder="1800 sec (0 = no limit)" class="settings-select" style="width:120px;">
</div>
<div id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
<div id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);margin-top:2px;"></div>
</div>
</div>
<!-- Agent card moved to the Agent Tools tab. -->
<!-- Image Generation removed — only inpaint remains in this build,
and inpaint is configured via the gallery editor not this card.
Keeping the DOM (hidden) so JS wiring against the inputs
doesn't throw. -->
<div class="admin-card" hidden style="display:none">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Image Generation<span style="flex:1"></span><label class="admin-switch"><input type="checkbox" id="set-imgEnabledToggle" checked><span class="admin-slider"></span></label></h2>
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Image Generation<span style="flex:1"></span><label class="admin-switch"><input type="checkbox" id="set-imgEnabledToggle"><span class="admin-slider"></span></label></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Configure which model to use for image generation.</div>
<div style="display:flex;flex-direction:column;gap:0.5rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
@@ -1570,20 +1565,15 @@
<div id="set-ttsSettingsMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
<!-- Teacher Model settings card hidden as part of the 2.0
"harden the core" pass. The escalation flow is dormant when
`teacher_model` is unset (its default), so the backend keeps
working for anyone who wired it via `manage_settings` /
settings backup. Re-add this card to surface the toggle
again once the core experience is faster. -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>Teacher Model <span style="font-size:0.72em;opacity:0.55;font-weight:normal;">(Experimental)</span><span style="flex:1"></span><label class="admin-switch"><input type="checkbox" id="set-teacherEnabledToggle"><span class="admin-slider"></span></label></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">When a self-hosted student fails an agent-mode task, escalate to a SOTA teacher that writes a SKILL.md procedure so the student can do it next time. Off by default.</div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Endpoint</label>
<select id="set-teacherEpSelect" class="settings-select"><option value="">—</option></select>
</div>
<div class="settings-row">
<label class="settings-label">Model</label>
<select id="set-teacherModelSelect" class="settings-select"><option value="">—</option></select>
</div>
<div id="set-teacherChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>Email Safety<span style="flex:1"></span><label class="admin-switch" title="When on, agent send_email and reply_to_email tools stage a draft for your approval instead of sending immediately."><input type="checkbox" id="set-agentEmailConfirm" checked><span class="admin-slider"></span></label></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, agent <code>send_email</code> / <code>reply_to_email</code> tools stage a draft for your approval (in the chat) instead of SMTPing immediately. Stops models from inventing a signature and sending it to a real recipient before you can review.</div>
</div>
</div>
@@ -1614,10 +1604,12 @@
<option value="serper" data-search-logo="serper">Serper.dev</option>
<option value="disabled" data-search-logo="disabled">Disabled</option>
</select>
<button type="button" class="admin-btn-sm" id="set-searchTestBtn" title="Run a test query against the configured provider" style="margin-left:6px;flex-shrink:0;position:relative;top:2px;">Test</button>
<button type="button" class="admin-btn-sm" id="set-searchTestBtn" title="Run a test query against the configured provider" style="margin-left:2px;flex-shrink:0;position:relative;top:2px;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
</button>
</div>
<div class="settings-row">
<label class="settings-label">Results</label>
<label class="settings-label" title="How many web search results to fetch per query">Results per query</label>
<div style="display:flex;gap:8px;flex:1;">
<select id="set-searchResultCount" class="settings-select" style="flex:1;">
<option value="3">3</option>
@@ -1631,30 +1623,78 @@
</div>
<div id="set-searchUrlRow" class="settings-row">
<label class="settings-label">URL</label>
<input id="set-searchUrl" type="text" placeholder="http://localhost:8080" class="settings-select">
<input id="set-searchUrl" type="text" placeholder="http://localhost:8080 (optional)" class="settings-select">
</div>
<div id="set-searchKeyRow" class="settings-row" style="display:none;">
<label class="settings-label">API Key</label>
<input id="set-searchApiKey" type="password" placeholder="API key" class="settings-select">
<div style="position:relative;flex:1;display:flex;align-items:center;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);opacity:0.55;pointer-events:none;"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>
<input id="set-searchApiKey" type="password" placeholder="API key" class="settings-select" style="flex:1;padding-left:28px;">
</div>
</div>
<div id="set-searchCxRow" class="settings-row" style="display:none;">
<label class="settings-label">CX ID</label>
<input id="set-searchCx" type="text" placeholder="Google PSE engine ID" class="settings-select">
</div>
<div class="settings-row">
<label class="settings-label" title="Providers tried in order when the primary fails or hits a rate limit">Fallbacks</label>
<div class="search-fallback-chain" id="set-searchFallbackChain"></div>
<div class="settings-row" style="align-items:flex-start;">
<label class="settings-label" style="margin-top:6px;" title="Providers tried in order when the primary fails or hits a rate limit">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div class="settings-fallbacks" id="set-searchFallbackChain"></div>
<button type="button" class="settings-fallback-add" id="set-searchAddFallback" title="Add a search provider to try if the primary fails">+ Add fallback</button>
</div>
</div>
<div id="set-searchHint" class="admin-toggle-sub"></div>
<div id="set-searchMsg" style="font-size:11px;"></div>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Deep Research</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Deep Research runtime settings. Default Model is picked in <a href="#" data-go-settings-tab="ai" style="color:var(--accent, var(--red));text-decoration:underline;font-weight:600;">AI Defaults →</a></div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Search</label>
<span style="margin-left:auto;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;color:var(--fg);" id="set-researchSearch-logo"></span>
<select id="set-researchSearch" class="settings-select" style="width:358.5px;flex:0 0 auto;max-width:calc(100% - 24px);">
<option value="" data-search-logo="">Same as web search</option>
<option value="searxng" data-search-logo="searxng">SearXNG</option>
<option value="duckduckgo" data-search-logo="duckduckgo">DuckDuckGo</option>
<option value="tavily" data-search-logo="tavily">Tavily</option>
<option value="brave" data-search-logo="brave">Brave</option>
<option value="google" data-search-logo="google_pse">Google</option>
<option value="serper" data-search-logo="serper">Serper</option>
</select>
</div>
<div class="settings-row">
<label class="settings-label">Max Tokens</label>
<input id="set-researchMaxTokens" type="text" inputmode="numeric" placeholder="8192 (default)" class="settings-select" style="width:382.5px;flex:0 0 auto;margin-left:auto;">
</div>
<div class="settings-row">
<label class="settings-label">Extract Timeout</label>
<div style="position:relative;width:382.5px;flex:0 0 auto;margin-left:auto;">
<input id="set-researchExtractTimeout" type="text" inputmode="numeric" placeholder="90 sec" class="settings-select" style="width:100%;padding-right:30px;">
<span title="How long the researcher waits for a single URL to fetch and extract before giving up on it. Slow sites get skipped. Default 90 seconds." style="position:absolute;right:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:50%;border:1px solid var(--border);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;opacity:0.55;cursor:help;user-select:none;">?</span>
</div>
</div>
<div class="settings-row">
<label class="settings-label">Extract Parallel</label>
<div style="position:relative;width:382.5px;flex:0 0 auto;margin-left:auto;">
<input id="set-researchExtractConcurrency" type="text" inputmode="numeric" placeholder="3" class="settings-select" style="width:100%;padding-right:30px;">
<span title="How many URLs the researcher fetches and extracts in parallel. Higher is faster but uses more memory/CPU. Default 3." style="position:absolute;right:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:50%;border:1px solid var(--border);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;opacity:0.55;cursor:help;user-select:none;">?</span>
</div>
</div>
<div class="settings-row">
<label class="settings-label">Timeout</label>
<input id="set-researchRunTimeout" type="text" inputmode="numeric" placeholder="1800 sec (0 = no limit)" class="settings-select" style="width:382.5px;flex:0 0 auto;margin-left:auto;">
</div>
</div>
</div>
</div>
<!-- ═══ APPEARANCE TAB ═══ -->
<div data-settings-panel="appearance" class="settings-appearance-panel hidden">
<div class="admin-card" style="padding-bottom:6px;">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>Sidebar</h2>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>Sidebar<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Sidebar to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
<div class="vis-toggles">
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M8 12l2.5 2.5L16 9"/></svg></span>
@@ -1754,7 +1794,7 @@
</div>
</div>
<div class="admin-card" style="padding-bottom:6px;">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>Chat Area</h2>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>Chat Area<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Chat Area to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
<div class="vis-toggles">
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16"/><path d="M4 10h8"/></svg></span>
@@ -1789,7 +1829,7 @@
</div>
</div>
<div class="admin-card" style="padding-bottom:6px;">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>Chat Bar</h2>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>Chat Bar<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Chat Bar to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
<div class="vis-toggles">
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
@@ -1833,9 +1873,6 @@
</label>
</div>
</div>
<div style="text-align:right;padding:0 4px;">
<button type="button" class="admin-btn-sm" id="set-uiVisResetBtn" style="opacity:0.5;">Reset All</button>
</div>
</div>
<!-- ═══ THEME TAB ═══ -->
@@ -1848,7 +1885,7 @@
<h2 style="margin:0;font-size:13px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg>Keyboard Shortcuts</h2>
<p style="font-size:10px;opacity:0.4;margin:2px 0 0;">Click a shortcut to rebind. Press Escape to cancel.</p>
</div>
<button type="button" class="shortcut-action-btn is-reset" id="shortcuts-reset-btn" title="Reset Shortcuts" style="width:28px;height:28px;font-size:15px;">&#x21A9;</button>
<button type="button" class="vis-reset-btn" id="shortcuts-reset-btn" title="Reset shortcuts to defaults" aria-label="Reset shortcuts to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</div>
<div class="admin-card">
<div id="shortcuts-list"></div>
@@ -1860,7 +1897,7 @@
<div data-settings-panel="account" class="hidden">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Account</h2>
<div style="display:flex;align-items:center;gap:10px;margin:4px 0 12px;">
<div style="display:flex;align-items:center;gap:10px;margin:12px 0 12px;">
<div class="user-bar-avatar" id="settings-account-avatar" style="width:32px;height:32px;font-size:14px;"></div>
<div style="flex:1;">
<div id="settings-account-username" style="font-size:13px;font-weight:600;"></div>
@@ -1898,7 +1935,7 @@
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Add, edit, delete, and test accounts in Integrations.</div>
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Manage in Integrations</button>
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Open Integrations</button>
</div>
</div>
@@ -1914,10 +1951,10 @@
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div>
<div class="settings-col">
<textarea id="set-email-style" rows="4" class="settings-select" style="font-family:inherit;resize:vertical" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
<textarea id="set-email-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
<div class="settings-row" style="margin-top:4px">
<span id="set-email-style-msg" style="font-size:11px;"></span>
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;">Extract from Sent (15 emails)</button>
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Extract from Sent (15 emails)</button>
<button class="admin-btn-add" id="set-email-style-save">Save</button>
</div>
</div>
@@ -1927,7 +1964,7 @@
<!-- ═══ REMINDERS TAB ═══ -->
<div data-settings-panel="reminders" class="hidden">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>How you're reminded</h2>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>How you're reminded<span style="flex:1"></span><span id="set-reminder-test-msg" style="font-size:11px;font-weight:normal;"></span><button class="admin-btn-sm" id="set-reminder-test-btn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test</button></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Controls how fired note reminders are delivered.</div>
<div class="settings-col">
<div class="settings-row">
@@ -1965,7 +2002,19 @@
</div>
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Synthesis<span style="flex:1"></span><label class="admin-switch" title="Use the utility model to write reminder messages"><input type="checkbox" id="set-reminder-llm-toggle"><span class="admin-slider"></span></label></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, AND webhook reminders instead of just the raw note content.</div>
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, and webhook reminders instead of just the raw note content.</div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label" title="Optional — write the reminder in the voice of a saved character">Persona</label>
<select id="set-reminder-llm-persona" class="settings-select" style="flex:1;">
<option value="">Default (warm, neutral)</option>
</select>
</div>
<div style="font-size:11px;opacity:0.7;margin-top:2px;">
<a href="#" data-open-prompt-modal style="color:var(--accent, var(--red));text-decoration:underline;font-weight:600;">Edit persona settings here →</a>
</div>
<div id="set-reminder-llm-persona-msg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 55%, transparent);"></div>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Public App URL</h2>
@@ -1978,14 +2027,6 @@
<div id="set-app-public-url-msg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 55%, transparent);"></div>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>Test</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Fire a test reminder using your current settings to verify everything works.</div>
<div class="settings-row">
<span id="set-reminder-test-msg" style="font-size:11px;"></span>
<button class="admin-btn-add" id="set-reminder-test-btn" style="margin-left:auto;">Send Test Reminder</button>
</div>
</div>
</div>
<!-- ═══ ADMIN: USERS TAB ═══ -->
@@ -2020,75 +2061,96 @@
<!-- ═══ SERVICES TAB ═══ -->
<div data-settings-panel="services">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Add Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
<div class="admin-toggle-sub" style="margin-bottom:10px">Connect local models first, or add a cloud API.</div>
<!-- Local subsection -->
<div class="adm-add-section collapsible collapsed" id="adm-add-local">
<div class="adm-ep-section-head adm-section-toggle" role="button" tabindex="0" aria-expanded="false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
<span>Local</span>
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<!-- ── Local card ─────────────────────────────────────────── -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
</button>
<div style="position:relative;display:inline-block;">
<button class="admin-btn-sm" id="adm-epLocalMoreBtn" title="More options" aria-haspopup="true" aria-expanded="false" style="font-size:11px;font-weight:normal;padding:4px 8px;line-height:1;">
<svg width="14" height="4" viewBox="0 0 14 4" fill="currentColor"><circle cx="2" cy="2" r="1.4"/><circle cx="7" cy="2" r="1.4"/><circle cx="12" cy="2" r="1.4"/></svg>
</button>
<div id="adm-epLocalMoreMenu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;z-index:50;min-width:170px;padding:4px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.22);flex-direction:column;gap:1px;">
<button class="admin-btn-sm adm-more-item" id="adm-epDiscoverBtn" title="Scan your network for running model servers" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan network
</button>
<button class="admin-btn-sm adm-more-item" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;"><span class="adm-ollama-logo" style="display:inline-flex;width:13px;height:13px;"></span>Add Ollama</button>
<button class="admin-btn-sm adm-more-item" id="adm-epLocalKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epLocalApiKey-row" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API key
</button>
</div>
</div>
</h2>
<div class="admin-toggle-sub" style="margin:0 0 10px 2px;">Add a local model server (Ollama, llama.cpp, vLLM).</div>
<div class="adm-add-section">
<div class="admin-model-form">
<div class="admin-model-form-row">
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1">
</div>
<!-- API key row stays in the DOM but is collapsed until the
user clicks the Key button on the action row. Local
endpoints rarely need a key; hiding it by default keeps
the form a single visual line. -->
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
</div>
<!-- Action row: LLM/Image type, Quickstart buttons (Scan,
Ollama), Key reveal toggle, Test, Add — all inline so
the Quickstart fold is gone and Type sits with the
primary actions. -->
<div class="admin-model-form-row">
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;opacity:0.6;flex-shrink:0;">Type:<select id="adm-epLocalType" style="padding:5px;width:72px;flex-shrink:0;">
<option value="llm" selected>LLM</option>
<option value="image">Image</option>
</select></label>
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers" style="display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan
</button>
<button class="admin-btn-sm" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint" style="display:inline-flex;align-items:center;gap:5px;"><span class="adm-ollama-logo" style="display:inline-flex;width:13px;height:13px;"></span>Ollama</button>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epLocalKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epLocalApiKey-row" style="opacity:0.75;display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API
</button>
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
</button>
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<div class="adm-fused-group" style="display:flex;flex:1 1 180px;min-width:0;">
<select id="adm-epLocalType" style="padding:5px;width:66px;flex-shrink:0;border-top-right-radius:0;border-bottom-right-radius:0;border-right:0;">
<option value="llm" selected>LLM</option>
<option value="image">Image</option>
</select>
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1;min-width:0;border-top-left-radius:0;border-bottom-left-radius:0;">
</div>
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
</button>
</div>
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
</div>
<div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div>
</div>
</div>
</div>
<!-- API subsection -->
<div class="adm-add-section collapsible collapsed" id="adm-add-api" style="margin-top:14px">
<div class="adm-ep-section-head adm-section-toggle" role="button" tabindex="0" aria-expanded="false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<span>API</span>
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="admin-model-form">
<!-- Custom picker (with logos). Hidden native <select> mirrors
its value so the existing JS that reads adm-epProvider
keeps working unchanged. -->
<div class="adm-provider-picker adm-provider-combo" id="adm-provider-picker">
<input id="adm-epUrl" type="text" placeholder="Base URL or pick provider" autocomplete="off">
<button type="button" class="adm-provider-btn" id="adm-provider-btn" title="Pick provider">
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Provider</span></span>
<svg class="adm-provider-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<!-- ── API card ───────────────────────────────────────────── -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
</button>
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="font-size:11px;font-weight:normal;">Cancel</button>
<div style="position:relative;display:inline-block;">
<button class="admin-btn-sm" id="adm-epApiMoreBtn" title="More options" aria-haspopup="true" aria-expanded="false" style="font-size:11px;font-weight:normal;padding:4px 8px;line-height:1;">
<svg width="14" height="4" viewBox="0 0 14 4" fill="currentColor"><circle cx="2" cy="2" r="1.4"/><circle cx="7" cy="2" r="1.4"/><circle cx="12" cy="2" r="1.4"/></svg>
</button>
<div id="adm-epApiMoreMenu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;z-index:50;min-width:200px;padding:4px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.22);flex-direction:column;gap:1px;">
<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.55;padding:6px 9px 2px;">Connection mode</div>
<button class="admin-btn-sm adm-more-item adm-kind-opt" data-kind="proxy" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
<svg class="adm-kind-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>Proxy</span>
<span style="margin-left:auto;opacity:0.5;font-size:10px;">routed via server</span>
</button>
<button class="admin-btn-sm adm-more-item adm-kind-opt" data-kind="api" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
<svg class="adm-kind-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="visibility:hidden;"><polyline points="20 6 9 17 4 12"/></svg>
<span>API (direct)</span>
<span style="margin-left:auto;opacity:0.5;font-size:10px;">browser→provider</span>
</button>
<div class="adm-provider-menu hidden" id="adm-provider-menu"></div>
</div>
</div>
</h2>
<div class="admin-toggle-sub" style="margin:0 0 10px 2px;">Connect a cloud provider (OpenAI, Anthropic, DeepSeek, OpenRouter, etc.).</div>
<div class="adm-add-section">
<div class="admin-model-form">
<div class="admin-model-form-row">
<div class="adm-provider-picker adm-provider-combo" id="adm-provider-picker" style="flex:1 1 220px;min-width:0;margin-bottom:0;">
<button type="button" class="adm-provider-btn" id="adm-provider-btn" title="Pick provider" style="border-top-right-radius:0;border-bottom-right-radius:0;border-top-left-radius:6px;border-bottom-left-radius:6px;border-left:1px solid var(--border);border-right:1px solid var(--border);">
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Provider</span></span>
<svg class="adm-provider-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<input id="adm-epUrl" type="text" placeholder="Base URL or pick provider" autocomplete="off" style="border-left:0;border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:6px;border-bottom-right-radius:6px;">
<div class="adm-provider-menu hidden" id="adm-provider-menu"></div>
</div>
</div>
<select id="adm-epKind" style="display:none">
<option value="proxy">proxy</option>
<option value="api" selected>api</option>
</select>
<select id="adm-epProvider" style="display:none">
<option value="">Custom URL</option>
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
@@ -2110,41 +2172,27 @@
<option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
<option value="https://integrate.api.nvidia.com/v1" data-logo="nvidia">NVIDIA</option>
</select>
<!-- API key row stays in DOM, hidden until Key button is
clicked. Mirrors the Local section pattern: most users
paste a key via the provider preset flow rather than
typing it free-form, so the row only appears on demand. -->
<div class="admin-model-form-row" id="adm-epApiKey-row" style="display:none;">
<input id="adm-epApiKey" type="password" placeholder="API key" autocomplete="off" style="flex:1">
</div>
<div class="admin-model-form-row" style="margin-top:-4px;">
<select id="adm-epKind" style="padding:5px;width:82px;">
<option value="proxy">Proxy</option>
<option value="api">API</option>
</select>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;opacity:0.6;flex-shrink:0;">Type:<select id="adm-epType" style="padding:5px;width:80px;flex-shrink:0;">
<option value="llm" selected>LLM</option>
<option value="image">Image</option>
</select></label>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epApiKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epApiKey-row" style="opacity:0.75;display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API
</button>
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
</button>
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="width:62px;text-align:center;">Cancel</button>
<button class="admin-btn-add" id="adm-epAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<div class="admin-model-form-row" id="adm-epApiKey-row">
<div style="position:relative;flex:1;display:flex;align-items:center;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);opacity:0.55;pointer-events:none;"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>
<input id="adm-epApiKey" type="password" placeholder="API key, e.g. sk-proj-AbCdEf…" autocomplete="off" style="flex:1;padding-left:28px;height:32px;box-sizing:border-box;">
</div>
<button class="admin-btn-add" id="adm-epAddBtn" style="height:32px;min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;box-sizing:border-box;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
</button>
</div>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg" style="min-height:0;margin-top:0;"></div>
</div>
</div>
</div>
</div>
<!-- ═══ ADDED MODELS TAB ═══ -->
<div data-settings-panel="added-models" class="hidden">
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><polyline points="20 6 9 17 4 12"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epProbeAllBtn" title="Re-test every endpoint and refresh online status" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>Probe
@@ -2153,20 +2201,18 @@
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Clear offline <span id="adm-epOfflineCount" style="opacity:0.6;margin-left:2px;"></span>
</button>
</h2>
<div class="admin-toggle-sub" style="margin-bottom:10px">Manage the endpoints you've added.</div>
<div class="admin-toggle-sub" style="margin-bottom:12px">Endpoints you've connected. Probe re-tests them all; Clear offline removes the dead ones.</div>
<div class="adm-ep-section">
<div class="adm-ep-section-head">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
<span>Local</span>
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Local
</div>
<div id="adm-epList-local"><div class="admin-empty">Loading...</div></div>
</div>
<div class="adm-ep-section" style="margin-top:14px">
<div class="adm-ep-section-head">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<span>API</span>
<div class="adm-ep-section" style="margin-top:18px;">
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>API
</div>
<div id="adm-epList-api"></div>
<div id="adm-epList-api"><div class="admin-empty">No API endpoints yet.</div></div>
</div>
</div>
</div>
@@ -2179,24 +2225,8 @@
<div class="admin-toggle-sub" style="margin-bottom:8px">All external service connections in one place.</div>
<div id="unified-integrations-list"></div>
<div id="unified-intg-form" style="display:none"></div>
<div style="text-align:center;padding:8px 0;">
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn" style="display:inline-flex;align-items:center;gap:6px;">+ Add Integration<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
</div>
</div>
<div class="admin-card admin-only" style="margin-top:12px;">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>API Tokens</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Bearer tokens for external integrations (scripts, Codex, headless agent runs). Token value shown ONCE on create — copy it then.</div>
<div id="adm-tokenList" style="margin-bottom:8px;"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-start;">
<input type="text" id="adm-tokenName" placeholder="Token name (e.g. agent-test)" class="settings-select" style="flex:1;min-width:160px;">
<input type="text" id="adm-tokenScopes" placeholder="scopes (comma-separated, blank = chat)" class="settings-select" style="flex:2;min-width:220px;" title="Allowed: chat, cookbook:read, cookbook:launch, documents:read|write, todos:read|write, email:read|draft|send, calendar:read|write, memory:read|write">
<button class="admin-btn-add" id="adm-tokenAddBtn">Create token</button>
</div>
<div id="adm-tokenMsg" style="font-size:11px;margin-top:6px;"></div>
<div id="adm-tokenReveal" style="display:none;margin-top:8px;padding:8px 10px;background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);border:1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);border-radius:6px;">
<div style="font-size:11px;font-weight:600;margin-bottom:4px;">Copy now — this is the only time you'll see it:</div>
<code id="adm-tokenValue" style="font-family:'Berkeley Mono','SF Mono','Fira Code',monospace;font-size:11px;word-break:break-all;display:block;background:var(--bg);padding:6px 8px;border-radius:4px;margin-bottom:6px;user-select:all;"></code>
<button class="admin-btn-sm" id="adm-tokenCopyBtn">Copy</button>
<div style="text-align:right;padding:8px 0;">
<button type="button" class="admin-btn-add" id="unified-intg-add-btn" style="text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:5px;flex-shrink:0;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Add Integration</button>
</div>
</div>
</div>
@@ -2218,10 +2248,6 @@
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
<div class="admin-card" style="margin-bottom:12px;">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Agent loop<span style="flex:1"></span><label class="admin-switch" title="On a failing effectful turn, climb verify → different-method → teacher → stop-and-summarize instead of silently quitting." style="flex-shrink:0"><input type="checkbox" id="set-agentSupervisorLadder"><span class="admin-slider"></span></label></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Supervisor ladder. When on, every effectful agent turn that claims done is verified; on FAIL the ladder escalates verify → different method → teacher → stop-with-blocker, each rung visible in chat. Teacher rung requires <code>teacher_model</code> to be set.</div>
</div>
<div class="admin-card" style="margin-bottom:12px;">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Built-in Tools</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Enable or disable tools available to the AI agent.</div>
@@ -2232,6 +2258,61 @@
<!-- ═══ SYSTEM TAB ═══ -->
<div data-settings-panel="system" class="hidden">
<div class="admin-card" id="settings-system-logs-card">
<h2>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="settings-system-logs-svg">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
Terminal Logs
</h2>
<div class="admin-toggle-sub settings-system-logs-toggle-sub">Live diagnostic logs and system output from the Odysseus process.</div>
<div class="settings-col settings-system-logs-col">
<!-- Controls row -->
<div class="settings-system-logs-controls">
<!-- Search input -->
<input type="text" id="log-search-input" placeholder="Search logs..." class="settings-system-logs-search">
<!-- Level select -->
<select id="log-level-select" class="settings-system-logs-select">
<option value="ALL">All Levels</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="DEBUG">DEBUG</option>
</select>
<!-- Limit select -->
<select id="log-limit-select" class="settings-system-logs-select">
<option value="100">100 lines</option>
<option value="200" selected>200 lines</option>
<option value="500">500 lines</option>
<option value="1000">1000 lines</option>
</select>
<!-- Refresh Button -->
<button type="button" class="admin-btn-sm" id="log-refresh-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="settings-system-logs-refresh-svg"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
Refresh
</button>
<!-- Auto-refresh switch -->
<div class="settings-system-logs-autopoll-container">
<label class="admin-switch" title="Auto-polling every 3 seconds">
<input type="checkbox" id="log-auto-refresh-toggle">
<span class="admin-slider"></span>
</label>
<span>Auto-poll</span>
</div>
</div>
<!-- Console container -->
<div id="log-console-container">
<div class="settings-system-logs-placeholder">Initializing logs terminal viewer...</div>
</div>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Data Backup</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.</div>
@@ -2248,68 +2329,76 @@
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="admin-toggle-label">Wipe all chats</div>
<div class="admin-toggle-label">Delete all chats</div>
<div class="admin-toggle-sub">Every session, message, and chat history. Documents/notes/etc. stay.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="chats" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="chats" title="Delete all chats" aria-label="Delete all chats" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all memory</div>
<div class="admin-toggle-label">Delete all memory</div>
<div class="admin-toggle-sub">Clears `memory.json`, the Memory table, and the vector store. Skills not affected.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="memory" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="memory" title="Delete all memory" aria-label="Delete all memory" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all skills</div>
<div class="admin-toggle-label">Delete all skills</div>
<div class="admin-toggle-sub">Drops `data/skills/` (all SKILL.md files). Memory not affected.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="skills" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="skills" title="Delete all skills" aria-label="Delete all skills" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all notes</div>
<div class="admin-toggle-label">Delete all notes</div>
<div class="admin-toggle-sub">Every note, todo, and checklist.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="notes" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="notes" title="Delete all notes" aria-label="Delete all notes" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all tasks</div>
<div class="admin-toggle-label">Delete all tasks</div>
<div class="admin-toggle-sub">Every scheduled task and its run history (Tasks tool).</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="tasks" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="tasks" title="Delete all tasks" aria-label="Delete all tasks" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all documents</div>
<div class="admin-toggle-label">Delete all documents</div>
<div class="admin-toggle-sub">Every document and version. Drafts, exports, library — all gone.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="documents" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="documents" title="Delete all documents" aria-label="Delete all documents" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all gallery</div>
<div class="admin-toggle-label">Delete all gallery</div>
<div class="admin-toggle-sub">Every image record and the upload directory on disk.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="gallery" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="gallery" title="Delete all gallery" aria-label="Delete all gallery" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div>
<div class="admin-toggle-label">Wipe all calendar</div>
<div class="admin-toggle-label">Delete all calendar</div>
<div class="admin-toggle-sub">Every event and every calendar (incl. CalDAV-synced ones; resync to restore).</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="calendar" style="white-space:nowrap;">Wipe</button>
<button class="admin-btn-delete" data-wipe-kind="calendar" title="Delete all calendar" aria-label="Delete all calendar" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
</div>
<hr style="border:0;border-top:1px solid color-mix(in srgb, #e55 25%, var(--border));margin:14px 0 10px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="admin-toggle-label" style="color:#e55;">Delete everything</div>
<div class="admin-toggle-sub">All eight categories above, in one go. Same effect as wiping each one in sequence.</div>
</div>
<button class="admin-btn-delete" data-wipe-kind="__all__" title="Delete every category" aria-label="Delete everything" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;font-weight:600;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete All</button>
</div>
<div id="adm-wipeMsg" style="margin-top:8px;"></div>
</div>
</div>
+558 -70
View File
@@ -3,7 +3,7 @@
import uiModule from './ui.js';
import settingsModule from './settings.js';
import { providerLogo } from './providers.js';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
@@ -55,6 +55,7 @@ async function loadUsers() {
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="admin-btn-sm" data-adm-toggle-admin="${esc(u.username)}" data-make-admin="${u.is_admin ? '0' : '1'}" style="font-size:11px;">${u.is_admin ? 'Revoke admin' : 'Make admin'}</button>
<button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button>
${u.is_admin ? '' : `<button class="admin-btn-delete" data-adm-del-user="${esc(u.username)}" style="font-size:11px;">Remove</button>`}
${u.is_admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>'}
@@ -113,7 +114,7 @@ async function loadUsers() {
// Toggle panel visibility + rotate chevron + load models
let _modelsLoaded = false;
header.addEventListener('click', (e) => {
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user]')) return;
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin]')) return;
privPanel.classList.toggle('hidden');
const chevron = header.querySelector('.admin-user-chevron');
if (chevron) {
@@ -199,6 +200,42 @@ async function loadUsers() {
});
}
// Promote / demote (admin toggle) — present on every row
const adminToggleBtn = row.querySelector('[data-adm-toggle-admin]');
if (adminToggleBtn) {
adminToggleBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const username = adminToggleBtn.dataset.admToggleAdmin;
const makeAdmin = adminToggleBtn.dataset.makeAdmin === '1';
const confirmMsg = makeAdmin
? `Grant admin rights to "${username}"? They'll get full access to all settings and users — including the power to demote or remove other admins (you included).`
: `Revoke admin rights from "${username}"? They'll lose access to the admin panel.`;
if (!await uiModule.styledConfirm(confirmMsg, { confirmText: makeAdmin ? 'Make admin' : 'Revoke admin', danger: !makeAdmin })) return;
adminToggleBtn.disabled = true;
try {
const res = await fetch(`/api/auth/users/${encodeURIComponent(username)}/admin`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: makeAdmin }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
uiModule.showError(data.detail || 'Failed to change admin status');
adminToggleBtn.disabled = false;
return;
}
// Demoting yourself drops your own admin access — reload into the
// normal-user view (mirrors the rename-self reload above).
if (data.self) { window.location.reload(); return; }
loadUsers();
} catch (err) {
uiModule.showError('Failed to change admin status');
adminToggleBtn.disabled = false;
}
});
}
list.appendChild(row);
});
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
@@ -449,13 +486,14 @@ async function loadEndpoints() {
return `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;align-items:center;">
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(ep.name)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
${hasModels ? `<span style="font-size:10px;opacity:0.4;${category === 'api' ? 'flex-basis:100%;' : ''}">Click to manage models</span>` : ''}
</div>
<div style="display:flex;gap:4px;align-items:center;">
<button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button>
@@ -828,6 +866,14 @@ function initEndpointForm() {
document.addEventListener('click', (e) => {
if (!picker.contains(e.target)) pickerMenu.classList.add('hidden');
});
// Capture-phase Esc: dismiss the picker menu without bubbling to the
// settings-modal handler that would otherwise close the whole modal.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (pickerMenu.classList.contains('hidden')) return;
e.stopPropagation();
pickerMenu.classList.add('hidden');
}, { capture: true });
}
provider.addEventListener('change', () => {
@@ -1022,14 +1068,15 @@ function initEndpointForm() {
if (d.id) _recentlyAddedEpId = String(d.id);
await loadEndpoints();
await _selectAddedModelInChat(d);
const goLink = ' <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>';
if (!d.online) {
msg.textContent = 'Added (endpoint offline — will retry on next load)';
msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink;
msg.className = 'admin-error';
} else if (d.status === 'empty') {
msg.textContent = 'Added — endpoint reachable, no models found';
msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink;
msg.className = 'admin-success';
} else {
msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`;
msg.innerHTML = `Added — found ${count} model${count !== 1 ? 's' : ''}` + goLink;
msg.className = 'admin-success';
}
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
@@ -1168,7 +1215,125 @@ function initEndpointForm() {
});
};
_wireKeyToggle('adm-epLocalKeyBtn', 'adm-epLocalApiKey-row');
_wireKeyToggle('adm-epApiKeyBtn', 'adm-epApiKey-row');
// Delegated link handler for jumping between settings tabs.
// [data-go-added-models] → quick shortcut for the Added Models tab
// [data-go-settings-tab="X"] → any tab whose nav button has data-settings-tab="X"
// [data-go-scroll-to="#elementId"] → after switching, scroll the element into view
document.addEventListener('click', (e) => {
const explicit = e.target.closest('[data-go-settings-tab]');
if (explicit) {
e.preventDefault();
const tab = explicit.getAttribute('data-go-settings-tab');
const scrollTo = explicit.getAttribute('data-go-scroll-to');
const btn = document.querySelector(`[data-settings-tab="${tab}"]`);
if (btn) btn.click();
if (scrollTo) {
// Defer to the next frame so the panel has actually become visible
// before we try to scroll into it.
requestAnimationFrame(() => {
const target = document.querySelector(scrollTo);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
return;
}
const link = e.target.closest('[data-go-added-models]');
if (!link) return;
e.preventDefault();
const btn = document.querySelector('[data-settings-tab="added-models"]');
if (btn) btn.click();
});
// Generic open/close helper for the kebab dropdowns in this card.
// Both the Local and API cards use the same shape: an h2-anchored button
// with id "<prefix>MoreBtn" toggles a sibling menu with id "<prefix>MoreMenu".
// Global Esc handler: close any currently-open kebab menu in the admin
// panel regardless of which _wireKebab instance owns it. Belt-and-braces
// backup for the per-instance handler below — registered once.
if (!document._admKebabEscWired) {
document._admKebabEscWired = true;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
// Any visible kebab dropdown in the admin panel — match by id pattern
// so adding a new kebab elsewhere automatically benefits.
const menus = document.querySelectorAll(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu'
);
let closed = false;
menus.forEach((m) => {
if (m && m.style.display !== 'none') {
m.style.display = 'none';
// Sync the associated button's aria-expanded when we can find it.
const btn = document.getElementById(m.id.replace('Menu', 'Btn'));
if (btn) btn.setAttribute('aria-expanded', 'false');
closed = true;
}
});
if (closed) e.stopPropagation();
}, { capture: true });
}
const _wireKebab = (btnId, menuId, onItem) => {
const btn = el(btnId);
const menu = el(menuId);
if (!btn || !menu) return;
const isOpen = () => menu.style.display !== 'none';
const close = () => { menu.style.display = 'none'; btn.setAttribute('aria-expanded', 'false'); };
const open = () => { menu.style.display = 'flex'; btn.setAttribute('aria-expanded', 'true'); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (isOpen()) close(); else open();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.adm-more-item');
if (!item) return;
if (onItem) onItem(item, e);
close();
});
document.addEventListener('click', (e) => {
if (!isOpen()) return;
if (e.target.closest('#' + menuId + ', #' + btnId)) return;
close();
});
// Use capture phase so this fires before the settings-modal Esc handler
// (which is in bubble phase). stopPropagation prevents the modal from
// closing when the user only meant to dismiss this menu.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) {
e.stopPropagation();
close();
}
}, { capture: true });
};
// API card "..." menu: contains the Proxy/API connection-mode toggle.
// Sync the visible checkmarks with the hidden #adm-epKind select so
// downstream code (which reads kindSel.value) keeps working.
(function wireApiKindMenu() {
const kind = el('adm-epKind');
if (!kind) return;
const opts = document.querySelectorAll('#adm-epApiMoreMenu .adm-kind-opt');
const sync = () => {
opts.forEach((o) => {
const check = o.querySelector('.adm-kind-check');
if (check) check.style.visibility = (o.dataset.kind === kind.value) ? 'visible' : 'hidden';
});
};
sync();
kind.addEventListener('change', sync);
_wireKebab('adm-epApiMoreBtn', 'adm-epApiMoreMenu', (item) => {
const k = item.dataset.kind;
if (!k) return;
kind.value = k;
kind.dispatchEvent(new Event('change'));
});
})();
// Local card "..." kebab: holds Scan network / Ollama / API key reveal.
// Item buttons keep their own click handlers; the helper just handles
// open/close + outside-click + Esc.
_wireKebab('adm-epLocalMoreBtn', 'adm-epLocalMoreMenu');
// ── Added Models toolbar: Probe + Clear offline ────────────────────
// Both buttons act over the currently-rendered endpoint list. The
@@ -1180,10 +1345,10 @@ function initEndpointForm() {
if (!lbl) return;
const n = document.querySelectorAll('[data-adm-ep-id] [data-adm-ep-online="0"]').length;
lbl.textContent = n > 0 ? `(${n})` : '';
// Keep the button enabled even when there are no offline rows — a
// click on the empty case fires a toast instead of feeling dead.
// Hide the button entirely when there's nothing offline — no point
// showing an action that has nothing to act on.
const btn = el('adm-epClearOfflineBtn');
if (btn) btn.style.opacity = n === 0 ? '0.55' : '0.85';
if (btn) btn.style.display = n === 0 ? 'none' : '';
};
// Wire after every loadEndpoints() run by patching the render hook —
// simplest path: MutationObserver on the two list containers.
@@ -1200,7 +1365,17 @@ function initEndpointForm() {
probeAllBtn.addEventListener('click', async () => {
probeAllBtn.disabled = true;
const origHTML = probeAllBtn.innerHTML;
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
let _wp = null;
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
_wp = sp.createWhirlpool(11);
_wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;';
probeAllBtn.innerHTML = '';
probeAllBtn.appendChild(_wp.element);
probeAllBtn.appendChild(document.createTextNode('Probing'));
} catch (_) {
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
}
try {
// Hit the bulk local probe (same one the model picker uses).
await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).catch(() => {});
@@ -1222,6 +1397,7 @@ function initEndpointForm() {
await loadEndpoints();
if (uiModule && uiModule.showToast) uiModule.showToast('Endpoint status refreshed', 1800);
} finally {
if (_wp) { try { _wp.destroy(); } catch (_) {} }
probeAllBtn.innerHTML = origHTML;
probeAllBtn.disabled = false;
}
@@ -1292,15 +1468,16 @@ function initEndpointForm() {
const localTestBtn = el('adm-epLocalTestBtn');
if (localTestBtn) {
localTestBtn.addEventListener('click', async () => {
const testOriginalHtml = localTestBtn.innerHTML || '>Test';
const msg = _endpointMsg('local');
msg.textContent = ''; msg.className = '';
msg.textContent = ''; msg.className = 'adm-ep-inline-msg';
const raw = (el('adm-epLocalUrl').value || '').trim();
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
const url = _normalizeBaseUrl(raw);
const keyEl = el('adm-epLocalApiKey');
const apiKey = keyEl ? keyEl.value.trim() : '';
localTestBtn.disabled = true;
localTestBtn.textContent = 'Testing...';
localTestBtn.innerHTML = testOriginalHtml.replace(/>Test\s*$/, '>Testing...');
try {
const fd = new FormData();
fd.append('base_url', url);
@@ -1313,19 +1490,21 @@ function initEndpointForm() {
msg.className = 'admin-error';
}
localTestBtn.disabled = false;
localTestBtn.textContent = 'Test';
localTestBtn.innerHTML = testOriginalHtml;
});
}
if (localAddBtn) {
localAddBtn.addEventListener('click', async () => {
const addOriginalHtml = localAddBtn.innerHTML || '>Add';
const msg = _endpointMsg('local');
msg.textContent = ''; msg.className = '';
msg.textContent = ''; msg.className = 'adm-ep-inline-msg';
const raw = (el('adm-epLocalUrl').value || '').trim();
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
const url = _normalizeBaseUrl(raw);
const keyEl = el('adm-epLocalApiKey');
const apiKey = keyEl ? keyEl.value.trim() : '';
localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...';
localAddBtn.disabled = true;
localAddBtn.innerHTML = addOriginalHtml.replace(/>Add\s*$/, '>Adding...');
try {
const fd = new FormData();
fd.append('base_url', url);
@@ -1345,15 +1524,17 @@ function initEndpointForm() {
await loadEndpoints();
await _selectAddedModelInChat(d);
const count = (d.models || []).length;
msg.textContent = d.status === 'empty'
const baseText = d.status === 'empty'
? 'Added — Ollama is running, no models pulled yet'
: d.online
? `Added — found ${count} model${count !== 1 ? 's' : ''}`
: 'Added (offline — will retry on next load)';
msg.innerHTML = `${baseText} <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>`;
msg.className = d.online ? 'admin-success' : 'admin-error';
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
localAddBtn.disabled = false; localAddBtn.textContent = 'Add';
localAddBtn.disabled = false;
localAddBtn.innerHTML = addOriginalHtml;
});
}
@@ -1379,10 +1560,7 @@ function initEndpointForm() {
discoverBtn.addEventListener('click', async () => {
const msg = _endpointMsg('local');
discoverBtn.disabled = true;
// Keep the button's icon as-is while scanning; the whirlpool +
// status text below is enough feedback. (Two spinning indicators
// at once looks busy.)
msg.className = '';
msg.className = 'adm-ep-inline-msg';
msg.innerHTML = '';
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
@@ -1393,7 +1571,7 @@ function initEndpointForm() {
wrap.appendChild(wp.element);
const txt = document.createElement('span');
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
txt.style.cssText = 'font-size:12px;opacity:0.7;';
txt.style.cssText = 'opacity:0.7;';
wrap.appendChild(txt);
msg.appendChild(wrap);
discoverBtn._wp = wp;
@@ -1444,30 +1622,6 @@ function initEndpointForm() {
});
}
// Collapsible Add-Models subsections (API / Local). Both start collapsed
// so the card is compact; the last-used state is remembered per section
// in localStorage so a frequent API-adder doesn't re-expand every time.
document.querySelectorAll('#adm-add-api, #adm-add-local').forEach((sec) => {
const head = sec.querySelector('.adm-section-toggle');
if (!head) return;
const key = 'odysseus.addModels.' + sec.id + '.open';
let open = false;
try { open = localStorage.getItem(key) === '1'; } catch {}
const apply = () => {
sec.classList.toggle('collapsed', !open);
head.setAttribute('aria-expanded', open ? 'true' : 'false');
};
apply();
const toggle = () => {
open = !open;
try { localStorage.setItem(key, open ? '1' : '0'); } catch {}
apply();
};
head.addEventListener('click', toggle);
head.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
});
document.querySelectorAll('.adm-quickstart-section').forEach((sec) => {
const head = sec.querySelector('.adm-quickstart-toggle');
if (!head) return;
@@ -2183,28 +2337,126 @@ function initRag() {
/* ═══════════════════════════════════════════
SYSTEM TAB — Tokens
═══════════════════════════════════════════ */
// Catalog mirrors the one in settings.js integration form. Keep keys in
// sync with the backend scope allowlist.
const _TOKEN_SCOPES = [
{ key: 'todos:read', label: 'Todos read', detail: 'Read notes and checklists' },
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
{ key: 'documents:read', label: 'Documents read', detail: 'Read documents when a document API is enabled' },
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
{ key: 'email:read', label: 'Email read', detail: 'Read email when an email API is enabled' },
{ key: 'email:draft', label: 'Email draft', detail: 'Create email reply drafts without sending' },
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
{ key: 'calendar:read', label: 'Calendar read', detail: 'Read calendar events when enabled' },
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
{ key: 'memory:read', label: 'Memory read', detail: 'Read memory when enabled' },
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
{ key: 'cookbook:read', label: 'Cookbook read', detail: 'List cookbook tasks + tail their tmux output' },
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Launch and stop cookbook serve tasks' },
];
function _renderTokenScopeRows(t) {
const have = new Set(t.scopes || []);
return _TOKEN_SCOPES.map(s => {
const action = (s.key.split(':')[1] || '').toLowerCase();
const pill = action === 'read'
? 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);'
: 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
const tool = s.label.replace(/\s+(read|write|draft|send|launch)$/i, '');
return `
<label style="display:flex;align-items:center;gap:8px;min-height:28px;padding:1px 0;">
<span class="settings-label" style="width:90px;flex-shrink:0;padding:0;font-size:12px;">${esc(tool)}</span>
<span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;box-sizing:border-box;${pill}">${esc(action)}</span>
<span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(s.detail)}</span>
<label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="adm-tok-scope" data-token-id="${esc(t.id)}" data-scope="${esc(s.key)}" ${have.has(s.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
</label>`;
}).join('');
}
async function loadTokens() {
const list = el('adm-tokenList');
if (!list) return;
try {
const res = await fetch('/api/tokens', { credentials: 'same-origin' });
const tokens = await res.json();
if (!tokens.length) { list.innerHTML = '<div class="admin-empty">No API tokens</div>'; return; }
if (!tokens.length) { list.innerHTML = '<div class="admin-empty" style="color:var(--accent, var(--red));opacity:0.7;font-size:10px;">No API tokens</div>'; return; }
list.innerHTML = tokens.map(t => `
<div class="admin-user-row">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
<span class="admin-user-name">${esc(t.name)}</span>
<span class="admin-badge">${esc(t.token_prefix)}...</span>
<span class="admin-badge" title="Allowed API scopes">${esc((t.scopes || ['chat']).join(', '))}</span>
${t.owner ? `<span style="font-size:0.75rem;opacity:0.5;">Owner: ${esc(t.owner)}</span>` : ''}
${t.last_used_at ? `<span style="font-size:0.75rem;opacity:0.5;">Last used: ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>'}
<div class="admin-user-row" data-adm-tok-row="${esc(t.id)}" style="display:block;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div class="admin-user-info" style="flex:1;min-width:0;flex-wrap:wrap;gap:0.3rem;">
<input type="text" class="adm-tok-rename" data-token-id="${esc(t.id)}" value="${esc(t.name || '')}" placeholder="Token name" style="font-size:13px;font-weight:600;padding:3px 6px;background:transparent;border:1px solid transparent;border-radius:4px;min-width:160px;" title="Click to rename">
<span class="admin-badge">${esc(t.token_prefix)}...</span>
${t.owner ? `<span style="font-size:0.75rem;opacity:0.5;">Owner: ${esc(t.owner)}</span>` : ''}
${t.last_used_at ? `<span style="font-size:0.75rem;opacity:0.5;">Last used: ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>'}
</div>
<button class="admin-btn-sm" data-adm-tok-toggle="${esc(t.id)}" style="opacity:0.75;">Permissions</button>
<button class="admin-btn-delete" data-adm-del-token="${esc(t.id)}">Revoke</button>
</div>
<div data-adm-tok-perm="${esc(t.id)}" style="display:none;margin-top:8px;padding:8px 4px 0;border-top:1px solid var(--border);">
${_renderTokenScopeRows(t)}
<div class="adm-tok-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;margin-top:4px;"></div>
</div>
<button class="admin-btn-delete" data-adm-del-token="${t.id}">Revoke</button>
</div>`).join('');
// Revoke
list.querySelectorAll('[data-adm-del-token]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await uiModule.styledConfirm('Revoke this API token? External integrations using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
await fetch(`/api/tokens/${btn.dataset.admDelToken}`, { method: 'DELETE', credentials: 'same-origin' });
loadTokens();
// Codex / Claude integration cards on the Integrations panel are
// backed by these tokens — let them re-render so the deleted token
// disappears there too.
try { window.dispatchEvent(new CustomEvent('odysseus-integrations-changed')); } catch (_) {}
});
});
// Toggle permissions panel
list.querySelectorAll('[data-adm-tok-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const panel = list.querySelector(`[data-adm-tok-perm="${btn.dataset.admTokToggle}"]`);
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? '' : 'none';
});
});
// Rename
list.querySelectorAll('.adm-tok-rename').forEach(input => {
const original = input.value;
const commit = async () => {
const name = (input.value || '').trim();
if (!name || name === original) return;
try {
const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) throw new Error('Save failed');
loadTokens();
} catch (_) { input.value = original; }
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
});
// Scope toggle change → PATCH the whole scopes array for this token.
list.querySelectorAll('.adm-tok-scope').forEach(cb => {
cb.addEventListener('change', async () => {
const tokenId = cb.dataset.tokenId;
const panel = list.querySelector(`[data-adm-tok-perm="${tokenId}"]`);
const msg = list.querySelector(`.adm-tok-scope-msg[data-token-id="${tokenId}"]`);
const scopes = Array.from(panel.querySelectorAll('.adm-tok-scope:checked')).map(input => input.dataset.scope);
try {
const r = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; setTimeout(() => { msg.textContent = ''; }, 1200); }
} catch (err) {
cb.checked = !cb.checked;
if (msg) { msg.textContent = (err && err.message) || 'Failed'; msg.style.color = 'var(--red)'; }
}
});
});
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load tokens</div>'; }
@@ -2236,11 +2488,20 @@ function initTokenForm() {
else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
});
const TOKEN_COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const TOKEN_CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
el('adm-tokenCopyBtn').addEventListener('click', () => {
const val = el('adm-tokenValue').textContent;
const btn = el('adm-tokenCopyBtn');
navigator.clipboard.writeText(val).then(() => {
el('adm-tokenCopyBtn').textContent = 'Copied!';
setTimeout(() => { el('adm-tokenCopyBtn').textContent = 'Copy'; }, 2000);
btn.innerHTML = TOKEN_CHECK_ICON;
btn.style.color = 'var(--accent, var(--red))';
btn.style.opacity = '1';
setTimeout(() => {
btn.innerHTML = TOKEN_COPY_ICON;
btn.style.color = '';
btn.style.opacity = '0.7';
}, 1600);
});
});
}
@@ -2467,33 +2728,258 @@ function initDangerZone() {
modalEl.querySelectorAll('[data-wipe-kind]').forEach(btn => {
btn.addEventListener('click', async () => {
const kind = btn.dataset.wipeKind;
const label = _LABELS[kind] || kind;
if (!await uiModule.styledConfirm(`Wipe ALL ${label}? This cannot be undone.`, { confirmText: 'Wipe', danger: true })) return;
if (!await uiModule.styledConfirm(`Really wipe every one of your ${label}?`, { confirmText: 'Yes, wipe everything', danger: true })) return;
btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Wiping…';
const isAll = kind === '__all__';
const label = isAll ? 'data across every category' : (_LABELS[kind] || kind);
if (!await uiModule.styledConfirm(`Delete ALL ${label}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
if (!await uiModule.styledConfirm(`Really delete every one of your ${label}?`, { confirmText: isAll ? 'Yes, delete everything' : 'Yes, delete everything', danger: true })) return;
btn.disabled = true;
const prevHtml = btn.innerHTML;
btn.innerHTML = isAll ? 'Deleting all…' : 'Deleting…';
if (_wipeMsg) { _wipeMsg.textContent = ''; _wipeMsg.className = ''; }
try {
const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (res.ok) {
if (_wipeMsg) { _wipeMsg.textContent = `Wiped ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; }
if (isAll) {
// Iterate every known category. Failures in one shouldn't stop
// the rest — record per-category counts and surface a summary.
const kinds = Object.keys(_LABELS);
const results = [];
for (const k of kinds) {
try {
const r = await fetch(`/api/admin/wipe/${k}`, { method: 'DELETE', credentials: 'same-origin' });
const d = await r.json().catch(() => ({}));
results.push({ k, ok: r.ok, count: d.count ?? 0, error: r.ok ? null : (d.detail || 'failed') });
} catch (e) {
results.push({ k, ok: false, count: 0, error: e.message });
}
}
const okCount = results.filter(r => r.ok).length;
const total = results.reduce((n, r) => n + (r.ok ? r.count : 0), 0);
const fails = results.filter(r => !r.ok).map(r => r.k);
if (_wipeMsg) {
if (!fails.length) {
_wipeMsg.textContent = `Deleted ${total} items across all ${okCount} categories.`;
_wipeMsg.className = 'admin-success';
} else {
_wipeMsg.textContent = `Deleted ${total} items; failed: ${fails.join(', ')}.`;
_wipeMsg.className = 'admin-error';
}
}
} else {
if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; }
const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (res.ok) {
if (_wipeMsg) { _wipeMsg.textContent = `Deleted ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; }
} else {
if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; }
}
}
} catch (e) {
if (_wipeMsg) { _wipeMsg.textContent = 'Request failed: ' + e.message; _wipeMsg.className = 'admin-error'; }
}
btn.disabled = false; btn.textContent = prev;
btn.disabled = false; btn.innerHTML = prevHtml;
});
});
}
/* ═══════════════════════════════════════════
TERMINAL LOGS VIEWER
═══════════════════════════════════════════ */
let logsPollInterval = null;
let isLogsPolling = false;
let cachedLogs = [];
let logsAbortController = null;
function renderLogs(isAutoPoll = false) {
const consoleContainer = el('log-console-container');
const levelSelect = el('log-level-select');
const searchInput = el('log-search-input');
if (!consoleContainer) return;
const levelFilter = levelSelect ? levelSelect.value : 'ALL';
const searchQuery = searchInput ? searchInput.value.trim().toLowerCase() : '';
let logs = cachedLogs;
// Filter by level locally
if (levelFilter !== 'ALL') {
logs = logs.filter(line => line.includes(` - ${levelFilter} - `));
}
// Filter by search query locally
if (searchQuery) {
logs = logs.filter(line => line.toLowerCase().includes(searchQuery));
}
if (logs.length === 0) {
consoleContainer.innerHTML = '<div class="settings-system-logs-placeholder">No logs found matching current filters.</div>';
return;
}
// Preserve scroll position if user is reading previous logs
const atBottom = consoleContainer.scrollHeight - consoleContainer.scrollTop - consoleContainer.clientHeight < 40;
consoleContainer.innerHTML = logs.map(line => {
let levelClass = 'log-line-default';
if (line.includes(' - INFO - ')) {
levelClass = 'log-line-info';
} else if (line.includes(' - WARNING - ')) {
levelClass = 'log-line-warning';
} else if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
levelClass = 'log-line-error';
} else if (line.includes(' - DEBUG - ')) {
levelClass = 'log-line-debug';
}
// XSS safe escape
const escaped = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<div class="log-line ${levelClass}">${escaped}</div>`;
}).join('');
if (!isAutoPoll || atBottom) {
consoleContainer.scrollTop = consoleContainer.scrollHeight;
}
}
async function loadLogs(isAutoPoll = false) {
const consoleContainer = el('log-console-container');
const limitSelect = el('log-limit-select');
if (!consoleContainer) return;
const limit = limitSelect ? limitSelect.value : 200;
if (logsAbortController) {
logsAbortController.abort();
}
logsAbortController = new AbortController();
const { signal } = logsAbortController;
try {
const res = await fetch(`/api/diagnostics/logs?limit=${limit}`, {
credentials: 'same-origin',
signal
});
if (!res.ok) {
if (!isAutoPoll) {
consoleContainer.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.style.color = 'var(--red)';
errDiv.style.fontWeight = '600';
errDiv.textContent = `Failed to load logs: HTTP ${res.status}`;
consoleContainer.appendChild(errDiv);
}
return;
}
const data = await res.json();
if (data.status !== 'success' || !data.logs) {
if (!isAutoPoll) {
consoleContainer.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.style.color = 'var(--red)';
errDiv.style.fontWeight = '600';
errDiv.textContent = 'Failed to parse logs data';
consoleContainer.appendChild(errDiv);
}
return;
}
cachedLogs = data.logs;
renderLogs(isAutoPoll);
} catch (err) {
if (err.name === 'AbortError') {
return; // Silently ignore deliberate abort
}
if (!isAutoPoll) {
consoleContainer.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.style.color = 'var(--red)';
errDiv.style.fontWeight = '600';
errDiv.textContent = `Error retrieving logs: ${err.message}`;
consoleContainer.appendChild(errDiv);
}
} finally {
if (logsAbortController?.signal === signal) {
logsAbortController = null;
}
}
}
function startLogsPolling() {
if (isLogsPolling) return;
isLogsPolling = true;
const toggle = el('log-auto-refresh-toggle');
if (toggle) toggle.checked = true;
logsPollInterval = setInterval(() => {
const modal = el('settings-modal');
const systemPanel = el('settings-modal')?.querySelector('[data-settings-panel="system"]');
// Safe self-cleanup if modal or panel is hidden/closed
if (!modal || modal.classList.contains('hidden') || !systemPanel || systemPanel.classList.contains('hidden')) {
stopLogsPolling();
return;
}
loadLogs(true);
}, 3000);
}
function stopLogsPolling() {
if (!isLogsPolling) return;
isLogsPolling = false;
if (logsPollInterval) {
clearInterval(logsPollInterval);
logsPollInterval = null;
}
const toggle = el('log-auto-refresh-toggle');
if (toggle) toggle.checked = false;
}
function initLogsView() {
const refreshBtn = el('log-refresh-btn');
const levelSelect = el('log-level-select');
const limitSelect = el('log-limit-select');
const searchInput = el('log-search-input');
const autoRefreshToggle = el('log-auto-refresh-toggle');
if (refreshBtn) refreshBtn.addEventListener('click', () => loadLogs(false));
if (levelSelect) levelSelect.addEventListener('change', () => renderLogs(false));
if (limitSelect) limitSelect.addEventListener('change', () => loadLogs(false));
if (searchInput) searchInput.addEventListener('input', () => renderLogs(false));
if (autoRefreshToggle) {
autoRefreshToggle.addEventListener('change', (e) => {
if (e.target.checked) {
startLogsPolling();
} else {
stopLogsPolling();
}
});
}
// Initial fetch on view loading
loadLogs(false);
}
/* ═══════════════════════════════════════════
INIT & REFRESH
═══════════════════════════════════════════ */
function initAll() {
modalEl = el('settings-modal');
const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, () => settingsModule.initIntegrations()];
const inits = [
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
() => settingsModule.initIntegrations()
];
for (const fn of inits) {
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
}
@@ -2507,6 +2993,7 @@ function refreshAll() {
loadBuiltinTools();
loadMcpServers();
loadTokens();
loadLogs(false);
}
/* ═══════════════════════════════════════════
@@ -2523,6 +3010,7 @@ export function open(tab) {
}
export function close() {
stopLogsPolling();
settingsModule.close();
}
+47 -1
View File
@@ -632,6 +632,28 @@ function _getModal() {
// ── Render dispatch ──
// Quick-add hint examples — the placeholder cycles through these every few
// seconds so users see different prompt shapes (events, deadlines, recurring).
const _QA_HINT_EXAMPLES = [
'return home to Ithaca 1pm tmrw',
'dinner with Penelope Friday 8pm',
'coffee with Athena 9am Saturday',
'call Telemachus tomorrow morning',
'dentist appointment 3pm next Tuesday',
'finish the wooden horse by Friday EOD',
'gym 7am every weekday',
'flight to Athens Sunday 6:30am',
'crew muster 10am daily',
'council on Ithaca Monday 2pm',
];
function _initQuickAddHintCycle() {
const span = document.getElementById('qa-hint-example');
if (!span) return;
// Pick one random example per calendar open — no interval cycling.
const idx = Math.floor(Math.random() * _QA_HINT_EXAMPLES.length);
span.textContent = _QA_HINT_EXAMPLES[idx];
}
// Stash the quick-add input's state (focus + caret + value) before a
// re-render so background fetches don't kick the user out mid-type. Picked
// up by _wireAll after the new DOM lands.
@@ -846,7 +868,7 @@ function _headerHTML() {
placeholder=" "
autocomplete="off"
/>
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — return home to Ithaca 1pm tmrw <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — <span class="qa-hint-example" id="qa-hint-example">return home to Ithaca 1pm tmrw</span> <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
<span class="cal-quickadd-status" id="cal-quickadd-status"></span>
</div>`;
}
@@ -1913,6 +1935,7 @@ function _wireAll(body) {
// ── Quick-add input ─────────────────────────────────────────────
const _qaInput = document.getElementById('cal-quickadd');
const _qaStatus = document.getElementById('cal-quickadd-status');
_initQuickAddHintCycle();
if (_qaInput && !_qaInput._wired) {
_qaInput._wired = true;
const _submitQA = async () => {
@@ -3139,6 +3162,29 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
// mode opens already expanded when there's any detail content to see.
titleInput?.addEventListener('focus', () => setExpanded(true), { once: true });
// Live time parse: typing a time like "11pm" or "15:30" into the title
// updates the hero clock + start input on the fly. The same parser still
// runs again on submit, but doing it live makes the hero clock track
// intent immediately instead of jumping at save.
if (titleInput) {
titleInput.addEventListener('input', () => {
if (document.getElementById('cal-f-allday')?.checked) return;
const tt = _parseTitleTime(titleInput.value);
if (!tt) return;
const startEl = document.getElementById('cal-f-start');
const endEl = document.getElementById('cal-f-end');
const newStart = `${String(tt.h).padStart(2, '0')}:${String(tt.m).padStart(2, '0')}`;
if (!startEl || startEl.value === newStart) return;
const toMin = (v) => { const p = (v || '').split(':'); return p.length === 2 ? (+p[0]) * 60 + (+p[1]) : null; };
const s0 = toMin(startEl.value), e0 = toMin(endEl?.value);
const dur = (s0 != null && e0 != null && e0 > s0) ? e0 - s0 : 60;
startEl.value = newStart;
const endMin = (tt.h * 60 + tt.m + dur) % 1440;
if (endEl) endEl.value = `${String(Math.floor(endMin / 60)).padStart(2, '0')}:${String(endMin % 60).padStart(2, '0')}`;
startEl.dispatchEvent(new Event('input'));
});
}
// Location → Apple Maps. The pin button next to the input is enabled
// only when there's a non-empty location, and its href tracks the live
// input value. Apple's universal URL opens the native Maps app on
+13
View File
@@ -787,6 +787,19 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
fd.append('active_doc_id', documentModule.getCurrentDocId());
}
// Active email context — when an email reader is open, pass its
// uid/folder/account so "reply", "summarize", "what does this say"
// resolve to the email the user is actually looking at instead of
// making the agent invent a new markdown draft with fake headers.
try {
const getEmailCtx = window.__odysseusGetActiveEmailContext;
const emCtx = typeof getEmailCtx === 'function' ? getEmailCtx() : null;
if (emCtx && emCtx.uid) {
fd.append('active_email_uid', String(emCtx.uid));
fd.append('active_email_folder', String(emCtx.folder || 'INBOX'));
if (emCtx.account) fd.append('active_email_account', String(emCtx.account));
}
} catch (_e) { /* best-effort */ }
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
const toggleState = Storage.loadToggleState();
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
+1 -1
View File
@@ -185,7 +185,7 @@ export function handleUIControl(uiData) {
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
import('./emailInbox.js').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply');
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
}).catch(function(e) {
console.warn('open_email_reply failed:', e);
});
+1 -1
View File
@@ -40,7 +40,7 @@ export const EVAL_PROMPTS = {
chat: [
// ── ★ Featured — prompts that have actually broken frontier models ──
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
{ sub: '★ Featured', label: 'Three jugs', answer: '4 pours: 7→5, 5→3, 3→7, 5→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
{ sub: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
+97
View File
@@ -0,0 +1,97 @@
// Per-backend × per-model install recipes for the Dependencies tab.
//
// Each entry says: when you're about to serve `model` on `backend`, here's
// the exact shell sequence to make the venv + install the right packages.
// Entries are matched first-hit; put the more specific patterns ABOVE the
// generic fallback for that backend.
// Recipes carry two variants per entry:
// variants.pip → install into the configured venv via uv/pip
// variants.docker → pull the official container image
//
// The renderer prepends a `source <venv>/bin/activate` for the pip variant
// (env_prefix handles activation for Run). The docker variant skips the
// activate line — `docker pull` doesn't need a venv.
const _RECIPES = [
// ── vllm ──────────────────────────────────────────────────────────────
// MiniMax M2/M2.7 — same as the generic vllm install/image for now;
// kept as its own entry so future model-specific patches land in one
// obvious place without touching the catch-all.
{
backend: 'vllm',
label: 'MiniMax M2 / M2.7',
match: (m) => /minimax[-_]?m\s?2(\.7)?/i.test(m || ''),
variants: {
pip: { commands: ['uv pip install -U vllm --torch-backend auto'] },
docker: { commands: ['docker pull vllm/vllm-openai:latest'] },
},
},
// Generic vllm fallback.
{
backend: 'vllm',
label: 'Any vLLM model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U vllm --torch-backend auto'] },
docker: { commands: ['docker pull vllm/vllm-openai:latest'] },
},
},
// ── sglang ────────────────────────────────────────────────────────────
{
backend: 'sglang',
label: 'Any SGLang model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U "sglang[all]" --torch-backend auto'] },
docker: { commands: ['docker pull lmsysorg/sglang:latest'] },
},
},
// ── llama.cpp ─────────────────────────────────────────────────────────
{
backend: 'llama_cpp',
label: 'Any GGUF model',
match: () => true,
variants: {
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] },
},
},
];
export const RECIPE_VARIANTS = ['pip', 'docker'];
export const RECIPE_DEFAULT_VARIANT = 'pip';
// Get the commands array for a recipe + variant. Falls back to pip when
// the requested variant isn't defined for the recipe.
export function recipeCommands(recipe, variant) {
if (!recipe) return [];
const v = (recipe.variants || {})[variant] || (recipe.variants || {}).pip;
return (v && v.commands) || [];
}
// Backends we surface a recipe panel for. Other rows in the Dependencies
// list keep the existing flat Install/Reinstall button without an expand
// affordance.
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'llama_cpp']);
// All recipe entries for a given backend, in catalog order. The first one
// is the model-specific match (when present); the last is always the
// generic fallback.
export function recipesForBackend(backend) {
return _RECIPES.filter((r) => r.backend === backend);
}
// Pick the best recipe for a backend + model id. Returns the catalog
// fallback when nothing more specific matches, or null if the backend
// isn't in the catalog at all.
export function pickRecipe(backend, modelId) {
const candidates = recipesForBackend(backend);
if (!candidates.length) return null;
for (const r of candidates) {
try { if (r.match(modelId)) return r; } catch (_) {}
}
return candidates[candidates.length - 1] || null;
}
+53 -14
View File
@@ -65,7 +65,13 @@ import spinnerModule from './spinner.js';
// ── Error diagnosis ──
function _openCookbookDependencies(pkgName = '') {
// Re-exported so callers (Launch-tab pre-flight) can deep-link into the
// Dependencies tab + auto-expand a specific backend's recipe panel and
// pre-select the model they were trying to launch.
export function openCookbookDependencies(pkgName = '', opts = {}) {
_openCookbookDependencies(pkgName, opts);
}
function _openCookbookDependencies(pkgName = '', opts = {}) {
const cookbook = window.cookbookModule;
if (cookbook && typeof cookbook.open === 'function') {
cookbook.open({ tab: 'Dependencies' });
@@ -94,6 +100,34 @@ function _openCookbookDependencies(pkgName = '') {
row.scrollIntoView({ block: 'center' });
row.classList.add('cookbook-pkg-flash');
setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 1800);
// Pre-flight deep link: auto-expand the recipe panel + pre-select
// the model the user was trying to launch. The dropdown values are
// now full model ids (sourced from _cachedModelIds), so we match by
// exact value first, then fall back to a substring match.
if (opts.expandRecipe) {
const caret = row.querySelector('[data-dep-recipe-toggle]');
if (caret && caret.getAttribute('aria-expanded') !== 'true') caret.click();
if (opts.model) {
const sel = document.querySelector(`[data-dep-recipe-pick="${CSS.escape(opts.expandRecipe)}"]`);
if (sel) {
const wanted = String(opts.model);
let matched = false;
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === wanted) {
sel.value = wanted; matched = true; break;
}
}
if (!matched) {
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value && wanted.includes(sel.options[i].value)) {
sel.value = sel.options[i].value; matched = true; break;
}
}
}
if (matched) sel.dispatchEvent(new Event('change'));
}
}
}
}
};
tryHighlight();
@@ -626,7 +660,24 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
// the full error+context for a forum/discord paste.
const toolbar = document.createElement('div');
toolbar.className = 'cookbook-diag-toolbar';
toolbar.style.cssText = 'display:flex;justify-content:flex-end;align-items:center;gap:4px;margin-bottom:-2px;';
// Left side carries the diagnosis text (message + suggestion); buttons
// stay on the right. Was a separate body row below the toolbar, but
// the message reads more like "this is what the toolbar is for" when
// it sits inline with Copy / × Dismiss.
toolbar.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin-bottom:-2px;';
const textWrap = document.createElement('div');
textWrap.style.cssText = 'flex:1;min-width:0;font-size:11px;line-height:1.35;';
const msg = document.createElement('div');
msg.className = 'cookbook-diag-message';
msg.textContent = diagnosis.message;
textWrap.appendChild(msg);
const suggestion = document.createElement('div');
suggestion.className = 'cookbook-diag-suggestion';
suggestion.textContent = suggestionText;
suggestion.style.cssText = 'opacity:0.75;margin-top:1px;';
textWrap.appendChild(suggestion);
toolbar.appendChild(textWrap);
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
@@ -660,18 +711,6 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
toolbar.appendChild(dismissBtn);
diag.appendChild(toolbar);
const body = document.createElement('div');
body.className = 'cookbook-diag-body';
const msg = document.createElement('div');
msg.className = 'cookbook-diag-message';
msg.textContent = diagnosis.message;
body.appendChild(msg);
const suggestion = document.createElement('div');
suggestion.className = 'cookbook-diag-suggestion';
suggestion.textContent = suggestionText;
body.appendChild(suggestion);
diag.appendChild(body);
const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => {
if (!fix || !button || button.dataset.busy) return;
button.dataset.busy = '1';
+376 -8
View File
@@ -31,6 +31,44 @@ import {
} from './cookbook.js';
import uiModule from './ui.js';
import spinnerModule from './spinner.js';
import { _loadTasks, _tmuxGracefulKill } from './cookbookRunning.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js';
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
// the Dependencies API reports. Used to look up "is this backend installed
// on the target server" before firing a launch.
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp' };
// Pre-launch: ask the deps API whether the chosen backend is present on
// the target server. Returns true if it's good to go, false if we should
// block and route the user into Dependencies.
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelName) {
const pkgName = _BACKEND_PKG[runBackend];
if (!pkgName) return true; // unknown backend — don't block
try {
const params = new URLSearchParams();
if (host) {
params.set('host', host);
if (port) params.set('ssh_port', String(port));
if (envPath) params.set('venv', envPath);
}
const r = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params : ''));
const d = await r.json();
const pkg = (d.packages || []).find(p => p.name === pkgName);
if (pkg && pkg.installed) return true;
} catch (_) {
// If we can't tell, don't block — the server's own serve route will
// surface a clearer error anyway.
return true;
}
const targetLabel = host || 'this server';
uiModule.showToast(
`${pkgName} not installed on ${targetLabel}. Opening Dependencies — pick your model and click Run.`,
6000
);
openCookbookDependencies(pkgName, { expandRecipe: pkgName, model: modelName });
return false;
}
// ── What Fits? (hardware model fitting) ──
@@ -127,7 +165,12 @@ export function _renderGpuToggles(system) {
_gpuToggleTotal = 0;
return;
}
if (!_gpuToggleTotal) _gpuToggleTotal = total;
// Update on every scan that returns a positive total — previously this
// only set on the first scan, so switching servers (e.g. local 1-GPU
// first, then a 4-GPU remote) left the Run-panel GPU buttons stuck on
// the original count. Zero/missing totals still don't clobber a known
// good value (avoids flicker during an in-flight re-probe).
if (total > 0) _gpuToggleTotal = total;
container._groups = groups;
if (container._activeGroup === undefined) container._activeGroup = 0; // auto = largest pool
@@ -159,8 +202,17 @@ export function _renderGpuToggles(system) {
// visual highlight. Before this, _activeCount stayed undefined → no
// gpu_count param sent → backend's fallback could rank against RAM on
// mixed-resource boxes ("tightest" sorted by RAM instead of GPU).
if (container._activeCount === undefined && validCounts.length) {
container._activeCount = maxGpu;
//
// On boxes where total RAM > total VRAM, default to RAM (count=0) instead
// — RAM is the dominant pool so it's the better starting filter.
if (container._activeCount === undefined) {
const ramGb = Number(system.total_ram_gb) || 0;
const vramGb = Number(system.gpu_vram_gb) || 0;
if (ramGb > vramGb) {
container._activeCount = 0;
} else if (validCounts.length) {
container._activeCount = maxGpu;
}
}
html += '<button class="hwfit-gpu-btn" data-count="0" title="CPU / RAM only">RAM</button>';
const hasExplicitCount = typeof container._activeCount === 'number';
@@ -363,7 +415,7 @@ function _scanSig() {
hk: _currentServerValue(),
u: document.getElementById('hwfit-usecase')?.value || '',
s: document.getElementById('hwfit-search')?.value?.trim() || '',
o: sortEl?.value || 'score',
o: sortEl?.value || 'newest',
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
q: document.getElementById('hwfit-quant')?.value || '',
c: _ctxValue(),
@@ -582,7 +634,7 @@ export async function _hwfitFetch(fresh = false) {
}).catch(() => {});
}
try {
const sortBy = document.getElementById('hwfit-sort')?.value || 'score';
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
const quantPref = document.getElementById('hwfit-quant')?.value || '';
const targetCtx = _ctxValue();
// Get active GPU count from toggles
@@ -710,7 +762,7 @@ export async function _hwfitFetch(fresh = false) {
// 1st click on a column = highest first; clicking it again = lowest first.
if (!isImageMode) {
const sortSel = document.getElementById('hwfit-sort');
const sortKey = sortSel?.value || 'score';
const sortKey = sortSel?.value || 'newest';
const asc = sortSel?.dataset.reverse === '1'; // reversed → ascending (lowest first)
if (sortKey === 'fit') {
// fit_level is categorical (perfect→good→marginal→too_tight), not numeric,
@@ -723,6 +775,18 @@ export async function _hwfitFetch(fresh = false) {
const as = Number(a.score) || 0, bs = Number(b.score) || 0;
return asc ? as - bs : bs - as;
});
} else if (sortKey === 'newest') {
// release_date is an ISO-ish "YYYY-MM-DD" string — lexical sort is
// chronological. Default direction: newest first (reverse=undefined).
data.models.sort((a, b) => {
const ad = String(a.release_date || ''), bd = String(b.release_date || '');
if (ad === bd) return 0;
// Empty dates land last regardless of direction so the column never
// floats undated rows above real releases.
if (!ad) return 1;
if (!bd) return -1;
return asc ? (ad < bd ? -1 : 1) : (ad < bd ? 1 : -1);
});
} else {
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
data.models.sort((a, b) => {
@@ -750,6 +814,80 @@ export async function _hwfitFetch(fresh = false) {
}
}
// Renders a non-blocking hardware visibility warning when Cookbook is using
// container-visible hardware that may not match the user's actual host machine.
function _renderHwVisibilityWarning(sys) {
const row = document.getElementById('hwfit-hw-row');
if (!row) return;
let box = document.getElementById('hwfit-hw-visibility-warning');
// Manual hardware is an explicit user override, so avoid showing stale
// container-detection warnings once the user has chosen a simulated profile.
const warning = sys?.manual_hardware ? null : sys?.hardware_visibility_warning;
if (!warning) {
if (box) box.remove();
return;
}
if (!box) {
box = document.createElement('div');
box.id = 'hwfit-hw-visibility-warning';
box.className = 'hwfit-loading hwfit-hw-visibility-warning';
row.insertAdjacentElement('afterend', box);
}
box.innerHTML = `
<div class="hwfit-hw-visibility-warning-title">${esc(warning.title || 'Hardware visibility note')}</div>
<div class="hwfit-hw-visibility-warning-body">${esc(warning.message || '')}</div>
<div class="hwfit-hw-visibility-warning-actions">
<button type="button" class="hwfit-gpu-btn" data-hw-action="manual">Edit manual hardware</button>
<button type="button" class="hwfit-gpu-btn" data-hw-action="rescan">Rescan</button>
<button type="button" class="hwfit-gpu-btn" data-hw-action="copy">Copy diagnostics</button>
</div>
`;
box.querySelector('[data-hw-action="manual"]')?.addEventListener('click', () => {
const panel = document.getElementById('hwfit-manual-panel');
if (panel) panel.classList.remove('hidden');
document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({
behavior: 'smooth',
block: 'center',
});
});
box.querySelector('[data-hw-action="rescan"]')?.addEventListener('click', () => {
_resetGpuToggleState();
_hwfitCache = null;
_hwfitFetch(true);
});
box.querySelector('[data-hw-action="copy"]')?.addEventListener('click', () => {
// Keep diagnostics copy/paste friendly for GitHub issues and Docker support.
const text = [
'Odysseus Cookbook hardware diagnostics',
`probe_scope=${sys?.probe_scope || ''}`,
`containerized=${sys?.containerized === true}`,
`backend=${sys?.backend || ''}`,
`has_gpu=${sys?.has_gpu === true}`,
`gpu_name=${sys?.gpu_name || ''}`,
`gpu_count=${sys?.gpu_count || 0}`,
`gpu_vram_gb=${sys?.gpu_vram_gb || ''}`,
`ram=${sys?.available_ram_gb || '?'} / ${sys?.total_ram_gb || '?'} GB`,
`cpu_cores=${sys?.cpu_cores || ''}`,
`cpu_name=${sys?.cpu_name || ''}`,
'',
'Useful checks:',
'docker compose exec odysseus nvidia-smi -L',
'docker compose exec odysseus cat /proc/meminfo | head',
'docker compose exec odysseus python -c "from services.hwfit.hardware import detect_system; import json; print(json.dumps(detect_system(fresh=True), indent=2))"',
].join('\n');
_copyText(text);
});
}
export function _hwfitRenderHw(el, sys) {
if (!el || !sys) return;
// Cache system info globally so other modules can read VRAM without refetching
@@ -838,6 +976,7 @@ export function _hwfitRenderHw(el, sys) {
+ chip('cores', cores)
+ chip('backend', esc(sys.backend || ''))
+ manualChip;
_renderHwVisibilityWarning(sys);
// Body click → toggle "off" (dimmed, still visible). Membership of
// _dismissedHwChips is what the ranker reads, so both add+remove
// here also flips the model list. The manual chip is excluded —
@@ -968,7 +1107,7 @@ function _modeLabel(model) {
export const _hwfitColumns = [
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
{ key: null, label: 'Model', cls: 'hwfit-name' },
{ key: 'newest', label: 'Model (latest)', cls: 'hwfit-name' },
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
@@ -998,7 +1137,7 @@ export function _hwfitRenderList(el, models) {
return;
}
const sortSel = document.getElementById('hwfit-sort');
const currentSort = sortSel?.value || 'score';
const currentSort = sortSel?.value || 'newest';
const isReversed = sortSel?.dataset.reverse === '1';
// Active budget for the Fit column label \u2014 make it obvious whether the
// ranking is against GPU or RAM so "tightest" can't be ambiguous on a
@@ -1027,6 +1166,13 @@ export function _hwfitRenderList(el, models) {
// (Budget tag removed — the GPU/RAM/N-GPU suffix next to "Fit" was noise;
// the toggle row already shows which budget is active.)
}
// The Model column's "(newest)" / "(oldest)" suffix flips with the sort
// direction so the user can see at a glance which way they're sorted.
if (col.key === 'newest' && col.key === currentSort) {
label = isReversed ? 'Model (oldest)' : 'Model (latest)';
} else if (col.key === 'newest') {
label = 'Model (latest)';
}
html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${label}${arrow}</span>`;
}
html += '</div>';
@@ -1181,6 +1327,72 @@ function _syncHostFromScanDropdown() {
return host;
}
// Minimum backend version a given model needs. Returns a semver string like
// "0.10.0" or null when the model has no known floor. Hardcoded for now —
// when the vLLM-recipes integration lands we can pull this from the upstream
// recipe page instead. Keep this conservative: a null return means "any
// installed version passes", so we don't false-positive launches.
function _minBackendVersion(modelName, backend) {
const n = (modelName || '').toLowerCase();
if (backend === 'vllm') {
// MiniMax M2 / M2.5 / M2.7 — minimax_m2 parser shipped in 0.10.0
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return '0.10.0';
// MiniMax M3 — newer parser registered in 0.11.x
if (n.includes('minimax') && n.includes('m3')) return '0.11.0';
// DeepSeek V3 / V3.1 / R1 — MoE expert-parallel paths matured in 0.7.0+
if (n.includes('deepseek') && (n.includes('v3') || n.includes('r1'))) return '0.7.0';
// Qwen3 reasoning models — qwen3 reasoning parser added in 0.7.0
if (n.includes('qwen3') && !n.includes('coder') && !n.includes('instruct')) return '0.7.0';
// GLM-4.5 / GLM-4.6 — glm45 reasoning parser added in 0.8.0
if (n.includes('glm-4.5') || n.includes('glm-4.6') || n.includes('glm-5')) return '0.8.0';
// gpt-oss reasoning models — gpt_oss parser
if (n.includes('gpt-oss')) return '0.10.0';
// Llama-4 multimodal — landed in 0.7.0
if (n.includes('llama-4') || n.includes('llama4')) return '0.7.0';
}
return null;
}
// Tiny semver compare: returns <0 / 0 / >0 like strcmp. Tolerates "0.10",
// "0.10.0", "0.10.0+cu124" — pre-release / build suffixes are stripped.
function _cmpSemver(a, b) {
const _parse = (s) => String(s || '').split(/[.+-]/).filter(p => /^\d+$/.test(p)).map(Number);
const A = _parse(a), B = _parse(b);
for (let i = 0; i < Math.max(A.length, B.length); i++) {
const av = A[i] || 0, bv = B[i] || 0;
if (av !== bv) return av - bv;
}
return 0;
}
// Map the detected GPU + the model's quant to SGLang's URL-hash params so
// the cookbook page lands on the right preset. SGLang supports:
// hw = b200 | b300 | gb200 | gb300 | mi300x | mi325x | mi350x | mi355x | h200
// quant = mxfp8 | bf16
// variant = default strategy = balanced nodes = single
// We only set what we can confidently infer; anything missing degrades to
// SGLang's own default (which is `h200` + bf16 single-node balanced).
function _sglangHashFor(modelData) {
const sys = (typeof _hwfitCache !== 'undefined' ? _hwfitCache?.system : null) || {};
const gpuName = String(sys.gpu_name || '').toLowerCase();
let hw = '';
if (/\bgb300/.test(gpuName)) hw = 'gb300';
else if (/\bgb200/.test(gpuName)) hw = 'gb200';
else if (/\bb300/.test(gpuName)) hw = 'b300';
else if (/\bb200/.test(gpuName)) hw = 'b200';
else if (/\bh200/.test(gpuName)) hw = 'h200';
else if (/mi355/.test(gpuName)) hw = 'mi355x';
else if (/mi350/.test(gpuName)) hw = 'mi350x';
else if (/mi325/.test(gpuName)) hw = 'mi325x';
else if (/mi300/.test(gpuName)) hw = 'mi300x';
const qRaw = String(modelData?.quant || '').toLowerCase();
// mxfp8 covers fp8 / mxfp8 / nvfp4; bf16 covers everything else cheap.
const quant = /fp8|mxfp|nvfp/.test(qRaw) ? 'mxfp8' : 'bf16';
const parts = ['variant=default', `quant=${quant}`, 'strategy=balanced', 'nodes=single'];
if (hw) parts.unshift(`hw=${hw}`);
return '#' + parts.join('&');
}
export function _expandModelRow(row, modelData) {
const list = row.closest('.hwfit-list');
if (!list) return;
@@ -1203,11 +1415,23 @@ export function _expandModelRow(row, modelData) {
const dlSource = _downloadSourceRepo(modelData, backend);
const hfUrl = `https://huggingface.co/${dlSource.repo}`;
// Official vendor recipe deep-links. These point to vLLM / SGLang's curated
// hardware-specific launch-command pages. They 404 for uncatalogued models \u2014
// a known tradeoff; user just gets the vendor's "model not found" page.
const _recipeRepo = modelData.name || '';
const _vllmUrl = _recipeRepo ? `https://recipes.vllm.ai/${_recipeRepo}` : '';
const _sglangUrl = _recipeRepo ? `https://docs.sglang.io/cookbook/autoregressive/${_recipeRepo}${_sglangHashFor(modelData)}` : '';
let html = `<div class="hwfit-action-panel" data-model-name="${esc(modelData.name)}">`;
html += `<div class="hwfit-panel-header">`;
html += `<span class="hwfit-panel-model">${esc(modelData.name)}${dlSource.kind ? ` <span style="opacity:0.5;font-size:10px;">(${esc(dlSource.kind)} ${esc(modelData.quant || '')})</span>` : (modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : '')}</span>`;
html += `<span class="hwfit-panel-badge">${esc(label)}</span>`;
html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View download source on HuggingFace">HF \u2197</a>`;
if (backend === 'vllm' && _vllmUrl) {
html += `<a href="${esc(_vllmUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="vLLM official recipe (curated launch command). 404s if this model isn't in vLLM's recipes catalog.">vLLM \u2197</a>`;
}
if (backend === 'sglang' && _sglangUrl) {
html += `<a href="${esc(_sglangUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="SGLang cookbook (hash pre-filled with your detected hardware). 404s if this model isn't in SGLang's cookbook catalog.">SGLang \u2197</a>`;
}
html += `</div>`;
html += `<div class="hwfit-panel-actions">`;
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
@@ -1276,6 +1500,133 @@ export function _expandModelRow(row, modelData) {
return;
}
// ─── Pre-launch: stop the model already serving on this host ───────
// Two servers can't share port 8000. Without this, the new launch
// silently collided and the user saw no feedback. We surface the
// conflict and offer to kill the running one first as the default
// action (it's almost always what the user wants).
try {
const _qrHostStr = _envState.remoteHost || '';
const _activeServes = _loadTasks().filter(t =>
t && t.type === 'serve'
&& (t.remoteHost || '') === _qrHostStr
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
);
if (_activeServes.length) {
const _names = _activeServes.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
const _ok = await window.styledConfirm?.(
`${_names.length} model${_names.length === 1 ? '' : 's'} already serving on ${_qrHostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
);
if (!_ok) return;
// Mark + kill each running serve, then wait briefly for the
// tmux session to actually go down before we kick off the new
// launch. Otherwise vLLM still races against the dying socket.
quickRunBtn.disabled = true;
quickRunBtn.textContent = 'Stopping…';
for (const t of _activeServes) {
try {
// Use that task's own Stop button if it's rendered (handles
// endpoint cleanup, Ollama unload, fade-out). Falls back to
// a direct tmux kill if the Active tab isn't in the DOM yet.
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
if (_stopBtn) {
_stopBtn.click();
} else {
await fetch('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxGracefulKill(t) }),
});
}
} catch (_killErr) { /* best-effort */ }
}
// Give the OS a beat to release port 8000.
await new Promise(r => setTimeout(r, 2500));
}
} catch (_e) { /* best-effort */ }
// ─── Pre-launch driver check ─────────────────────────────────────
// vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures
// surface as system.gpu_error from our hardware probe; "no GPU
// detected" is the other common case. Bail with a clear message
// before kicking off the long install/launch chain — otherwise the
// user watches `pip install vllm` finish, then sees a cryptic CUDA
// error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks
// so they skip this gate.)
const _qrBackendDetect = _detectBackend(modelData);
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
const _sys = _hwfitCache?.system || {};
if (_sys.gpu_error) {
uiModule.showError(`Can't launch: GPU driver error — ${_sys.gpu_error}. Reinstall or repair the NVIDIA driver, then re-scan.`);
return;
}
if (!_sys.has_gpu || !(_sys.gpu_count > 0)) {
uiModule.showError(`Can't launch: no GPU detected by nvidia-smi. ${_qrRunBackend === 'vllm' ? 'vLLM' : 'SGLang'} needs a working CUDA or ROCm device.`);
return;
}
}
// ─── Pre-launch install + version check ─────────────────────────
// Catches:
// a) "command not found" (binary not in PATH)
// b) "version too old" (model needs e.g. vllm >= 0.10.0 for the
// reasoning/tool parser registered for it).
// Both cases would otherwise fail 10s-3min into the launch with a
// cryptic shell error. Best-effort: a venv activated only by the
// launch wrapper can false-negative the PATH check, in which case
// the launch proceeds and the existing diagnosis layer handles it.
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
try {
const _qrHostStr = _envState.remoteHost || '';
const _coreCheck = _qrRunBackend === 'vllm'
? "command -v vllm >/dev/null 2>&1 && vllm --version 2>&1 | grep -oE '[0-9]+\\.[0-9]+(\\.[0-9]+)?' | head -1 || echo MISSING"
: "python3 -c 'import sglang, sys; sys.stdout.write(sglang.__version__)' 2>/dev/null || echo MISSING";
const _wrappedCheck = _qrHostStr
? `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${_qrHostStr} "bash -lc ${JSON.stringify(_coreCheck)}"`
: `bash -lc ${JSON.stringify(_coreCheck)}`;
const _chkRes = await fetch('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _wrappedCheck, timeout: 10 }),
});
if (_chkRes.ok) {
const _chk = await _chkRes.json();
const _stdout = String(_chk.stdout || '').trim();
const _stderr = String(_chk.stderr || '').trim();
const _out = `${_stdout}\n${_stderr}`;
if (_out.includes('MISSING')) {
const _pkg = _qrRunBackend === 'vllm' ? 'vLLM' : 'SGLang';
const _hint = _qrRunBackend === 'vllm'
? 'uv pip install -U vllm --torch-backend auto'
: "pip install -U 'sglang[all]'";
uiModule.showError(`Can't launch: ${_pkg} isn't installed${_qrHostStr ? ' on ' + _qrHostStr : ''}. Install it first:\n${_hint}`);
return;
}
// Version-floor check. _minBackendVersion returns null when this
// model has no known requirement — in which case any installed
// version passes.
const _minVer = _minBackendVersion(modelData.name, _qrRunBackend);
const _verMatch = _stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
const _curVer = _verMatch ? _verMatch[1] : '';
if (_minVer && _curVer && _cmpSemver(_curVer, _minVer) < 0) {
const _pkg = _qrRunBackend === 'vllm' ? 'vLLM' : 'SGLang';
const _hint = _qrRunBackend === 'vllm'
? 'uv pip install -U vllm --torch-backend auto'
: "pip install -U 'sglang[all]'";
uiModule.showError(`Can't launch: ${modelData.name} needs ${_pkg} ≥ ${_minVer}, but ${_curVer} is installed${_qrHostStr ? ' on ' + _qrHostStr : ''}. Upgrade:\n${_hint}`);
return;
}
}
} catch (_e) {
// Network/exec failed — fall through and let the launch try.
}
}
quickRunBtn.disabled = true;
quickRunBtn.textContent = 'Starting...';
@@ -1353,6 +1704,23 @@ export function _expandModelRow(row, modelData) {
// schema (repo_id + cmd) — sending `command`/`model` failed Pydantic
// validation (422), which is why Run silently did nothing.
const _srv = _serverByVal(_envState.remoteServerKey || host);
// Pre-flight: if the backend isn't installed on the target server,
// route the user into Dependencies → recipe panel for that backend
// instead of launching into an obvious "command not found" failure.
const _ok = await _ensureBackendInstalled(
runBackend,
host,
(_srv && _srv.port) || undefined,
_envState.envPath || '',
modelData.name,
);
if (!_ok) {
quickRunBtn.disabled = false;
quickRunBtn.textContent = 'Run';
return;
}
const payload = {
repo_id: modelData.name,
cmd: cmd,
+348 -60
View File
@@ -8,6 +8,7 @@ import spinnerModule from './spinner.js';
import { providerLogo } from './providers.js';
import { makeWindowDraggable } from './windowDrag.js';
import { _diagnose, _showDiagnosis, _clearDiagnosis, _runQuickCmd, ERROR_PATTERNS } from './cookbook-diagnosis.js';
import { RECIPE_BACKENDS, recipesForBackend, pickRecipe, recipeCommands, RECIPE_DEFAULT_VARIANT } from './cookbook-deps-recipes.js';
import { _hwfitCache, _hwfitDebounce, _hwfitFetch, _hwfitInit, _hwfitRenderList, _hwfitRenderHw, _renderGpuToggles, _expandModelRow, _fitColors, _hwfitColumns, _cachedModelIds, _gpuToggleTotal, _resetGpuToggleState } from './cookbook-hwfit.js';
// Sub-modules
@@ -233,22 +234,39 @@ function _detectModelOptimizations(modelName) {
const n = (modelName || '').toLowerCase();
const opts = { envVars: [], flags: [], tips: [] };
// Qwen3.5 MoE models
// Qwen3.5 MoE models — MoE-specific env vars + expert-parallel.
// The --reasoning-parser flag is added uniformly below via
// _detectReasoningParser, no longer hardcoded here.
if (n.includes('qwen3.5') || n.includes('qwen3-') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) {
opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1', 'VLLM_USE_FLASHINFER_SAMPLER=0', 'OMP_NUM_THREADS=4');
opts.flags.push('--enable-expert-parallel', '--reasoning-parser qwen3');
opts.flags.push('--enable-expert-parallel');
opts.tips.push('MoE optimizations: expert parallel + flashinfer MoE kernels');
}
// Qwen3 MoE (non-3.5)
else if (n.includes('qwen3') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) {
opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1');
opts.flags.push('--enable-expert-parallel', '--reasoning-parser qwen3');
opts.flags.push('--enable-expert-parallel');
opts.tips.push('MoE optimizations: expert parallel');
}
// DeepSeek MoE
else if (n.includes('deepseek') && (n.includes('v3') || n.includes('r1'))) {
// DeepSeek MoE — V3 / V3.1 / V4 (and future Vx), R1 / R2 reasoning.
// Anything v-{integer} or r-{integer} family from DeepSeek is MoE in
// current architectures. These models also require fp8 KV cache to
// fit at meaningful context with current tensor-parallel layouts —
// the launch crashes otherwise (--kv-cache-dtype auto → bf16 OOMs).
else if (n.includes('deepseek') && /\b(v[3-9]|v\d{2,}|r[1-9])\b/.test(n)) {
opts.flags.push('--enable-expert-parallel');
opts.tips.push('MoE expert parallel for DeepSeek');
opts.kvCacheDtype = 'fp8';
opts.tips.push('fp8 KV cache required — bf16 OOMs at usable context');
}
// Reasoning parser — applies independently of MoE detection. Without this
// flag, models like MiniMax-M2.x, DeepSeek-R1, Qwen3 reasoning, GLM-4.x,
// gpt-oss leak <think> blocks as plain text instead of separating them
// into the reasoning_content channel.
const _reasoningParser = _detectReasoningParser(modelName);
if (_reasoningParser) {
opts.flags.push(`--reasoning-parser ${_reasoningParser}`);
opts.tips.push(`Reasoning parser (${_reasoningParser}): splits <think> tokens into a separate channel`);
}
// Speculative decoding — pick the right MTP method per model family.
// opts.spec.{method,tokens} seed the UI dropdown/input; the actual flag is
@@ -257,7 +275,7 @@ function _detectModelOptimizations(modelName) {
if (n.includes('qwen3-next') || (n.includes('qwen3.5') && (n.includes('a10b') || n.includes('a22b')))) {
specDefault = { method: 'qwen3_next_mtp', tokens: 2 };
} else if (
(n.includes('deepseek') && (n.includes('v3') || n.includes('v3.1') || n.includes('r1'))) ||
(n.includes('deepseek') && /\b(v[3-9]|v\d{2,}|r[1-9])\b/.test(n)) ||
n.includes('kimi-k2') || n.includes('kimi_k2') ||
n.includes('glm-4.5') || n.includes('glm4.5') ||
n.includes('minimax-m1') || n.includes('minimax_m1')
@@ -273,6 +291,36 @@ function _detectModelOptimizations(modelName) {
return opts;
}
/** Detect the right vLLM --reasoning-parser based on model name.
* Returns the parser slug (matches vLLM's official list) or null when the
* model isn't a reasoning model. Without the right parser, thinking tokens
* leak as plain text instead of being split into a separate channel.
* Source: vllm/reasoning/__init__.py registered parsers.
*/
export function _detectReasoningParser(modelName) {
const n = (modelName || '').toLowerCase();
// MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2
// before plain "minimax" so M2.x doesn't fall through to a wrong parser.
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2';
// DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non-
// thinking) skip this — they're not reasoning models.
if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1';
// Qwen3 / Qwen3.5 reasoning models. Qwen3-Coder + Qwen3-Instruct don't
// emit <think> blocks, so skip the parser there.
if (n.includes('qwen3') && !n.includes('coder') && !n.includes('instruct')) return 'qwen3';
// GLM-4 / GLM-4.5 / GLM-4.6 with reasoning.
if (n.includes('glm-4') || n.includes('glm-5')) return 'glm45';
// OpenAI gpt-oss family.
if (n.includes('gpt-oss')) return 'gpt_oss';
// Hunyuan A13B reasoning.
if (n.includes('hunyuan') && n.includes('a13b')) return 'hunyuan_a13b';
// IBM Granite reasoning.
if (n.includes('granite') && (n.includes('reason') || n.includes('think'))) return 'granite';
// InternLM reasoning.
if (n.includes('internlm')) return 'internlm';
return null;
}
/** Detect the right vLLM tool-call-parser based on model name.
* Qwen tool-call formats split by generation:
* - Qwen3-Coder → qwen3_coder (XML <tool_call> with named params)
@@ -416,7 +464,10 @@ export function _buildServeCmd(f, modelName, backend) {
const _py3Bin = _venvBin ? `${_venvBin}python3` : 'python3';
let cmd = '';
if (backend === 'vllm') {
const gpuId = f.gpu_id?.trim() || '';
// GPU list comes from the Row-1 button strip (data-field="gpus") —
// the bare "auto" input that used to back gpu_id is gone, and the
// button strip is the only source for which devices to pin.
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `;
if (f.moe_env) {
const _opts = _detectModelOptimizations(modelName);
@@ -458,7 +509,10 @@ export function _buildServeCmd(f, modelName, backend) {
cmd += ` --speculative-config '{"method":"${_specMethod}","num_speculative_tokens":${_specToks}}'`;
}
} else if (backend === 'sglang') {
const gpuId = f.gpu_id?.trim() || '';
// GPU list comes from the Row-1 button strip (data-field="gpus") —
// the bare "auto" input that used to back gpu_id is gone, and the
// button strip is the only source for which devices to pin.
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `;
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
if (_extraEnv) cmd += _extraEnv + ' ';
@@ -475,7 +529,9 @@ export function _buildServeCmd(f, modelName, backend) {
if (f.enforce_eager) cmd += ' --disable-cuda-graph';
} else if (backend === 'llamacpp') {
const ggufPath = f._gguf_path || 'model.gguf';
const gpuId = f.gpu_id?.trim() || '';
// GPU list — read from gpus (button strip); fall back to gpu_id for
// backward-compat with older saved presets that pre-date the removal.
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
const py = _isWindows() ? 'python' : 'python3';
// CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command
// mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to
@@ -737,6 +793,22 @@ async function _fetchDependencies() {
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
};
// Per-package inline glyphs — same accent-coloured marks used in the
// Backend picker on the Run page, so the Dependencies row visually
// matches the engine you're configuring. Unknown packages get no
// icon (the name alone is fine for librosa, hf_transfer, etc.).
const _DEP_GLYPHS = {
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
llama_cpp: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>',
ollama: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 0 1 12 0v4a4 4 0 0 1-8 0v-1"/><circle cx="10" cy="9" r="1"/><circle cx="14" cy="9" r="1"/></svg>',
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
};
const _depGlyphHtml = (name) => {
const g = _DEP_GLYPHS[name];
return g ? `<span class="cookbook-dep-glyph" aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));margin-right:5px;vertical-align:-2px;">${g}</span>` : '';
};
const _depRow = (pkg) => {
const isLocal = pkg.target === 'local';
const isSystemDep = pkg.kind === 'system';
@@ -757,9 +829,16 @@ async function _fetchDependencies() {
} else if (pkg.name === 'sglang' && pkg.installed) {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
}
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
// append a caret button that toggles a per-row recipe panel below.
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
const recipeCaret = hasRecipe
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
: '';
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
+ `<div class="cookbook-dep-info">`
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
+ `<div class="memory-item-title">${_depGlyphHtml(pkg.name)}${esc(pkg.name)}</div>`
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
+ note
+ updateNote
@@ -767,9 +846,65 @@ async function _fetchDependencies() {
+ _rebuildBtn
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
+ `</div>`;
+ recipeCaret
+ `</div>`
+ recipePanel;
};
// Prepend the configured venv's activate line (pip variant only) so
// the user sees a paste-ready sequence; Run keeps using env_prefix to
// activate the same venv before the pip command. Docker variant skips
// the activate line — `docker pull` doesn't need a venv.
function _recipeDisplayText(commands, variant) {
if (variant === 'docker') return commands.join('\n');
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
const activate = envPath
? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}`
: '# (activate your venv first)';
return [activate, ...commands].join('\n');
}
// Per-backend recipe panel (model picker + commands + Copy/Run).
// Lives directly below the row it expands and starts collapsed.
// The model picker lists every downloaded model from _cachedModelIds
// (the same set the Launch tab uses); pickRecipe() then finds the
// best-matching recipe for whatever the user selects, with the
// backend's generic entry as the fallback.
function _recipePanelHtml(backend) {
const candidates = recipesForBackend(backend);
if (!candidates.length) return '';
const downloadedIds = _cachedModelIds ? Array.from(_cachedModelIds).sort() : [];
const modelOptions = downloadedIds.length
? downloadedIds.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('')
: '';
// "Other" entry: user types/pastes an id, OR uses the generic fallback
// when no models have been downloaded yet.
const otherOpt = `<option value="">Other (generic ${esc(backend)} install)</option>`;
const opts = modelOptions + otherOpt;
// Initial recipe: the generic fallback (matches first time, no model id).
const initial = pickRecipe(backend, '') || candidates[0];
const initialVariant = RECIPE_DEFAULT_VARIANT;
const initialCmds = recipeCommands(initial, initialVariant);
const rightActive = initialVariant === 'docker' ? ' mode-right' : '';
return `<div class="cookbook-dep-recipe-panel" data-dep-recipe-panel="${esc(backend)}" data-dep-recipe-active-variant="${esc(initialVariant)}" style="display:none;margin:-4px 0 8px;padding:8px 12px 10px;background:rgba(0,0,0,0.04);border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:11px;opacity:0.75;flex-shrink:0;">Serving which model?</span>
<select class="settings-select cookbook-dep-recipe-pick" data-dep-recipe-pick="${esc(backend)}" style="flex:1;font-size:11px;padding:3px 6px;">${opts}</select>
<div class="mode-toggle${rightActive}" data-dep-recipe-variants="${esc(backend)}" style="flex-shrink:0;">
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip/uv</button>
<button type="button" class="mode-toggle-btn${initialVariant === 'docker' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="docker" aria-pressed="${initialVariant === 'docker'}">Docker</button>
</div>
</div>
<div style="position:relative;">
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
<button type="button" id="recipe-copy-${esc(backend)}" class="cookbook-dep-recipe-copy" data-dep-recipe-copy="${esc(backend)}" title="Copy" aria-label="Copy" style="position:absolute;top:6px;right:6px;padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</div>
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;">
<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-recipe-run" data-dep-recipe-run="${esc(backend)}" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run</button>
</div>
</div>`;
}
const _section = (title, note, items) =>
items.length
? `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>` + items.map(_depRow).join('')
@@ -866,7 +1001,7 @@ async function _fetchDependencies() {
}
// Wire install buttons (not-installed packages)
list.querySelectorAll('.cookbook-dep-install').forEach(btn => {
list.querySelectorAll('.cookbook-dep-install:not(.cookbook-dep-recipe-run)').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pipName = btn.dataset.depPip;
@@ -875,6 +1010,135 @@ async function _fetchDependencies() {
});
});
// ── Recipe panel wiring (per-backend dropdown with model + commands) ──
// Caret toggle: shows/hides the panel directly below the backend row.
list.querySelectorAll('[data-dep-recipe-toggle]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeToggle;
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
if (!panel) return;
const open = panel.style.display === 'none' || !panel.style.display;
panel.style.display = open ? 'block' : 'none';
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
const caret = btn.querySelector('svg');
if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
});
});
// Re-render the <pre> for a backend using the currently-active variant
// (pip / docker) and the currently-picked model. Used by every input
// that changes which install sequence we should show.
function _refreshRecipePre(backend) {
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
if (!panel) return;
const variant = panel.dataset.depRecipeActiveVariant || RECIPE_DEFAULT_VARIANT;
const sel = panel.querySelector('[data-dep-recipe-pick]');
const recipe = pickRecipe(backend, (sel && sel.value) || '');
const cmds = recipeCommands(recipe, variant);
const pre = panel.querySelector('[data-dep-recipe-cmds]');
if (pre) {
pre.textContent = _recipeDisplayText(cmds, variant);
pre.dataset.depRecipeInstall = cmds.join('\n');
}
}
// Model select: pickRecipe matches the model id against the catalog.
list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => {
sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick));
});
// Variant toggle (Pip/uv vs Docker): mirrors the agent/chat mode-toggle
// pattern — buttons get .active, container gets .mode-right when the
// right slot is selected so the sliding pill animates over.
list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeVariant;
const variant = btn.dataset.variant;
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
if (!panel) return;
panel.dataset.depRecipeActiveVariant = variant;
const container = panel.querySelector('.mode-toggle[data-dep-recipe-variants]');
if (container) container.classList.toggle('mode-right', variant === 'docker');
panel.querySelectorAll('[data-dep-recipe-variant]').forEach(b => {
const on = b.dataset.variant === variant;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
_refreshRecipePre(backend);
});
});
// Copy: drop the visible command block on the clipboard.
list.querySelectorAll('[data-dep-recipe-copy]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeCopy;
const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`);
if (!pre) return;
try {
await navigator.clipboard.writeText(pre.textContent);
uiModule.showToast('Copied');
} catch {
// Fallback for non-secure contexts: select the pre's text so
// the user can Ctrl+C themselves.
const sel = window.getSelection(); const range = document.createRange();
range.selectNodeContents(pre); sel.removeAllRanges(); sel.addRange(range);
}
});
});
// Run: launch the install command(s) as a tmux task on the currently-
// selected deps server. Activation comes from env_prefix (same plumbing
// the Install button uses) so the install lands in the configured venv
// instead of a fresh .venv in some random CWD.
list.querySelectorAll('[data-dep-recipe-run]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeRun;
const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`);
if (!pre) return;
// Use the install-only command list (no activate line) — the
// displayed source line is for the user's reading; env_prefix
// handles it for the actual run.
const installRaw = pre.dataset.depRecipeInstall || pre.textContent;
const cmd = installRaw.split('\n').map(s => s.trim()).filter(Boolean).join(' && ');
const depsSel = document.getElementById('hwfit-deps-server');
if (depsSel) _applyServerSelection(depsSel.value);
const targetHost = _envState.remoteHost || 'local';
// Build env_prefix from the configured envPath (matches _installDep).
let envPrefix = '';
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
const reqBody = {
repo_id: `${backend} setup`,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
env_prefix: envPrefix || undefined,
platform: _envState.platform || undefined,
};
try {
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
return;
}
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
_addTask(data.session_id, `${backend} setup`, 'download', payload);
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
} catch (err) {
uiModule.showToast('Run failed: ' + err.message);
}
});
});
// Wire the ⋮ menu on installed packages — currently just "Update".
function _showDepMenu(anchor) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
@@ -1404,16 +1668,49 @@ function _wireTabEvents(body) {
const dlFoldBody = document.getElementById('cookbook-dl-tab-fold-body');
const dlFoldChevron = document.getElementById('cookbook-dl-tab-chevron');
if (dlFold && dlFoldBody && dlFoldChevron) {
const _setFolded = (folded, persist = true) => {
// Toggle via class so CSS transition animates the height/opacity
// — display:none was an instant on/off and felt jarring.
dlFoldBody.classList.toggle('is-folded', folded);
dlFoldChevron.textContent = folded ? '▸' : '▾';
dlFold.classList.toggle('is-folded', folded);
if (persist) {
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '1' : '0'); } catch {}
}
};
dlFold.addEventListener('click', () => {
const folded = dlFoldBody.style.display === 'none';
dlFoldBody.style.display = folded ? '' : 'none';
dlFoldChevron.textContent = folded ? '▾' : '▸';
// Toggle is-folded class on the h2 so the line under it only shows when
// the section is collapsed (the body's content normally provides
// separation; with no body visible, the line gives the h2 definition).
dlFold.classList.toggle('is-folded', !folded);
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {}
const folded = dlFoldBody.classList.contains('is-folded');
_setFolded(!folded);
});
// Auto-fold on any downward scroll inside the cookbook modal,
// and auto-expand when the user scrolls all the way back to the
// top of whichever scroller they're in. The chevron ▸ still
// toggles manually.
const _maybeFold = () => {
if (dlFoldBody.classList.contains('is-folded')) return;
_setFolded(true, /* persist */ false);
};
const _maybeExpand = () => {
if (!dlFoldBody.classList.contains('is-folded')) return;
_setFolded(false, /* persist */ false);
};
// Capture phase so scrolls on nested scrollers (.hwfit-list,
// .cookbook-body, .modal-content) all hit us.
const _modal = dlFold.closest('#cookbook-modal') || document;
const _lastY = new WeakMap();
_modal.addEventListener('scroll', (e) => {
const tgt = e.target;
if (!tgt || typeof tgt.scrollTop !== 'number') return;
// Ignore scrolls that originate INSIDE the Direct Download body
// (e.g. the Trending models list) — those are local to the
// section and shouldn't auto-fold the section that owns them.
if (dlFoldBody.contains && (tgt === dlFoldBody || dlFoldBody.contains(tgt))) return;
const y = tgt.scrollTop;
const prev = _lastY.get(tgt) || 0;
if (y > prev) _maybeFold();
else if (y <= 0) _maybeExpand();
_lastY.set(tgt, y);
}, true);
}
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
const hfArrow = document.getElementById('cookbook-hf-latest-arrow');
@@ -1571,9 +1868,9 @@ function _wireTabEvents(body) {
document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange);
}
// Browse Ollama library — popular models from ollama.com via cached backend
// proxy. Click a row → fills the download input with `<name>:<size>` so the
// existing Download button kicks off `ollama pull`.
// Browse Ollama library popup removed — Engine = Ollama in the
// Scan / Download filter covers this use case. The handler below is a
// no-op now because the elements no longer exist.
const olToggle = document.getElementById('cookbook-ollama-toggle');
const olArrow = document.getElementById('cookbook-ollama-arrow');
const olList = document.getElementById('cookbook-ollama-list');
@@ -1774,8 +2071,8 @@ function _renderRecipes() {
// Tabs
html += '<div class="cookbook-tabs">';
html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="vertical-align:-1px;margin-right:3px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>';
html += '<button class="cookbook-tab active" data-backend="Search"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="7 14 12 19 17 14"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="5" y1="21" x2="19" y2="21"/></svg>Download</button>';
html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Serve</button>';
html += '<button class="cookbook-tab" data-backend="Dependencies"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Dependencies</button>';
html += '<button class="cookbook-tab" data-backend="Settings"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>';
html += '</div>';
@@ -1788,9 +2085,9 @@ function _renderRecipes() {
// State persisted to localStorage so the fold survives reloads.
const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })();
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">';
html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? '▸' : '▾'}</span></h2>`;
html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Direct Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? '▸' : '▾'}</span></h2>`;
html += '</div>';
html += `<div id="cookbook-dl-tab-fold-body" style="${_dlTabFolded ? 'display:none;' : ''}">`;
html += `<div id="cookbook-dl-tab-fold-body" class="${_dlTabFolded ? 'is-folded' : ''}">`;
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download from <a href="https://huggingface.co/models" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:1px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>HuggingFace</a> by pasting model link, or download directly in the Scan section below.</p>';
html += '<div class="hwfit-container" id="hwfit-container">';
@@ -1820,42 +2117,34 @@ function _renderRecipes() {
// silently sending downloads to the wrong server. An empty selection means Local; the user
// chooses a remote server explicitly via the dropdown.
// Manual download input
html += `<div style="margin-top:7px;margin-bottom:2px;display:flex;gap:4px;align-items:center;">`;
// Manual download input — server picker on the same row as the repo input,
// on the left. The standalone "add server" button is gone (use Settings).
html += `<div class="cookbook-dl-input" style="margin-top:7px;display:flex;gap:4px;align-items:center;">`;
if (_es.servers.length > 1) {
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;position:relative;top:0px;">`;
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;flex-shrink:0;">`;
html += _buildServerOpts(true);
html += `</select>`;
} else {
html += `<input type="hidden" id="hwfit-dl-server" value="local" />`;
}
html += `<button class="memory-toolbar-btn cookbook-dl-add-server" title="Add server in Settings" style="height:28px;">add server</button>`;
html += `</div>`;
html += `<div class="cookbook-dl-input" style="margin-top:0;">`;
html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, qwen2.5:14b, or HF URL" />`;
html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, qwen2.5:14b, or HF URL" style="flex:1;min-width:0;" />`;
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
html += `</div>`;
// Browse Ollama library — fetches popular models from ollama.com via the
// /api/cookbook/ollama/library cached proxy, click → fills the input with
// `<name>:<size>` so the existing Download button kicks off `ollama pull`.
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
html += `<div style="display:flex;gap:4px;align-items:center;">`;
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-ollama-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
html += `<span id="cookbook-ollama-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">▸</span>`;
html += `<span style="pointer-events:none;">Browse Ollama library</span>`;
html += `</button>`;
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-ollama-refresh" title="Refresh" style="height:26px;width:26px;padding:0;border-radius:4px;">↻</button>`;
html += `</div>`;
html += `<div id="cookbook-ollama-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
html += `</div>`;
// Ollama-library browse used to live here as its own collapsible dropdown,
// but that duplicated the Engine filter (which already has Ollama). The
// standalone UI is gone — to find Ollama models, set Engine = Ollama in
// the Scan / Download section below.
// Latest HF models that fit — collapsible card list
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
html += `<div style="margin-top:5px;position:relative;top:-11px;">`;
html += `<div style="display:flex;gap:4px;align-items:center;">`;
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">\u25B8</span>`;
html += `<span style="pointer-events:none;">Trending models that fit your hardware</span>`;
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:28px;font-size:11px;display:flex;align-items:center;gap:6px;border-radius:5px;">`;
// Trending-up icon (accent) so the section reads as "what's hot".
html += `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;pointer-events:none;"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>`;
html += `<span style="pointer-events:none;flex:1;">Trending models that fit your hardware</span>`;
// Chevron moved to the RIGHT \u2014 collapsed = pointing right, expanded
// = rotated 90deg into a down chevron (handled by existing toggle CSS).
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;opacity:0.6;font-size:11px;">\u25B8</span>`;
html += `</button>`;
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-refresh" title="Refresh" style="height:26px;width:26px;padding:0;border-radius:4px;">\u21BB</button>`;
html += `</div>`;
html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
html += `</div>`;
@@ -1876,9 +2165,10 @@ function _renderRecipes() {
// Image tab removed — text→image gen is gone from this build (only inpaint
// remains, which uses its own settings panel). Vision (multimodal) stays.
html += '<option value="multimodal">Vision</option></select>';
// Engine sits next to the type filter so the "what category / which serving
// path" filters live together; Quant + Context are storage-format and budget
// levers, grouped to the right.
// Search moved next to the Type filter so the two primary picks
// (what category + free text) sit together; the more advanced
// levers (Engine / Quant / Context) live to the right.
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
html += '<span class="hwfit-engine-wrap">';
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">';
html += '<option value="">Engine</option>';
@@ -1893,7 +2183,7 @@ function _renderRecipes() {
// quant for every model instead of silently filtering to Q4.
html += '<span class="hwfit-quant-wrap">';
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
html += '<option value="" selected>Quant: All</option>';
html += '<option value="" selected>Quant</option>';
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
@@ -1906,21 +2196,19 @@ function _renderRecipes() {
html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">';
html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
html += '<output id="hwfit-context-label">50k</output></label>';
// Search lives at the far right of the toolbar so the controls (Type/Quant/
// Engine/Context) read as a row of compact filters followed by free-text.
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
html += '</div>';
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
html += _buildServerOpts(false);
html += '</select>';
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
// Scan/refresh button (icon-only) where the quant dropdown used to sit.
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">↻ RESCAN</button>';
// (Rescan button removed — Edit handles manual hardware updates;
// automatic re-probe runs on container restart.)
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
// Sort state — the clickable column headers read/write this (pewds' original
// sort paradigm). Newest is reachable by clicking the Model column header.
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
html += '<option value="newest" selected>Latest</option>';
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
html += '<option value="speed">Speed</option><option value="params">Params</option>';
html += '<option value="context">Context</option></select>';
+100 -44
View File
@@ -808,7 +808,7 @@ function _winSessionCmd(task, tmuxArgs) {
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
}
function _tmuxGracefulKill(task) {
export function _tmuxGracefulKill(task) {
if (_isWindows(task)) {
const host = task.remoteHost;
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
@@ -825,6 +825,48 @@ function _tmuxGracefulKill(task) {
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
}
// Force-kill escalation: SIGKILL the tmux pane's owning PID and any children,
// then nuke the session. Use AFTER the graceful kill when the process is
// still detected — vLLM sometimes ignores SIGINT during model init, and a
// stuck CUDA context can survive `tmux kill-session` alone.
export function _tmuxForceKill(task) {
if (_isWindows(task)) {
// Windows graceful path already does Stop-Process -Force, so the same
// command serves as the "force" variant.
return _tmuxGracefulKill(task);
}
const sid = task.sessionId;
const inner =
`PIDS=$(tmux list-panes -t ${sid} -F "#{pane_pid}" 2>/dev/null); ` +
`if [ -n "$PIDS" ]; then ` +
` for P in $PIDS; do ` +
` pkill -KILL -P "$P" 2>/dev/null; ` +
` kill -9 "$P" 2>/dev/null; ` +
` done; ` +
`fi; ` +
`tmux kill-session -t ${sid} 2>/dev/null`;
if (task.remoteHost) {
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
}
return inner;
}
// Returns a shell snippet that prints "ALIVE" if the tmux session still
// exists (or its main PID is still listed in /proc), "DEAD" otherwise.
// Used by the Stop-all escalation to decide whether to force-kill.
export function _tmuxIsAliveCheck(task) {
if (_isWindows(task)) {
// Skip the check on Windows — the graceful path already force-kills.
return null;
}
const sid = task.sessionId;
const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`;
if (task.remoteHost) {
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
}
return inner;
}
function _shQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
}
@@ -1643,7 +1685,7 @@ export function _renderRunningTab() {
runTab.className = 'cookbook-tab';
runTab.dataset.backend = 'Running';
const _errCount = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
runTab.innerHTML = `Running${activeCountHtml}${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
runTab.innerHTML = `Active${activeCountHtml}${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
tabBar.insertBefore(runTab, tabBar.firstChild);
runTab.addEventListener('click', () => {
tabBar.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active'));
@@ -1654,7 +1696,7 @@ export function _renderRunningTab() {
});
} else if (runTab) {
const _errCount2 = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
runTab.innerHTML = tasks.length ? `Running${activeCountHtml}${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Running';
runTab.innerHTML = tasks.length ? `Active${activeCountHtml}${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Active';
if (!hasContent) {
if (runTab.classList.contains('active')) {
const wfTab = tabBar.querySelector('.cookbook-tab[data-backend="Search"]');
@@ -1669,9 +1711,13 @@ export function _renderRunningTab() {
group = document.createElement('div');
group.className = 'cookbook-group hidden';
group.dataset.backendGroup = 'Running';
group.innerHTML = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">' +
// No `flex:1` on the card — with overflow:visible (forced via #cookbook-modal
// .cookbook-group > .admin-card), flex:1 collapsed the card to body height
// and the body's scrollHeight stopped tracking the overflowing children.
// Sized-to-content means cookbook-body's overflow-y:auto kicks in naturally.
group.innerHTML = '<div class="admin-card" style="display:flex;flex-direction:column;">' +
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
'<h2 style="margin:0;padding:0;line-height:1;">Running <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
'<h2 style="margin:0;padding:0;line-height:1;">Active <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
'</div>' +
'<p class="memory-desc doclib-desc" style="margin-top:6px;">Active downloads and serving processes.</p>' +
'</div>';
@@ -1751,7 +1797,7 @@ export function _renderRunningTab() {
// green when reachable, red if any serve task on it is crashed/unreachable.
const _secDot = (key && allTasks.some(_serveTaskFailed)) ? 'fail' : 'ok';
const _dotTitle = key ? (_secDot === 'fail' ? 'Server not responding' : 'Reachable') : 'Local (this machine)';
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}">Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}">Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
}
}
@@ -1762,9 +1808,21 @@ export function _renderRunningTab() {
btn.addEventListener('click', async (e) => {
e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP)
const host = btn.dataset.clearServer;
if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
const allTasks = _loadTasks();
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
// Bail with a clear message instead of silently doing nothing when
// every task on this server is still running (nothing finished to
// clear yet) — the previous behavior looked like the button was dead.
if (!toRemove.length) {
const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length;
const _msg = stillRunning
? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.`
: `No finished tasks on ${_serverName(host)}.`;
if (window.uiModule?.showToast) window.uiModule.showToast(_msg);
else alert(_msg);
return;
}
if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
_saveTasks(remaining);
// Fade/slide each finished card out (same exit as the per-card clear)
@@ -2100,57 +2158,43 @@ export function _renderRunningTab() {
dropdown.className = 'cookbook-task-dropdown';
const items = [];
// ── Run section ─────────────────────────────────────────────
// Queued download: let the user jump the queue and start it immediately
// (downloads otherwise run one-at-a-time per server).
if (task.type === 'download' && task.status === 'queued') {
items.push({ label: 'Start now', action: 'start-now', custom: () => {
items.push({ group: 'run', label: 'Start now', action: 'start-now', custom: () => {
_startQueuedDownload(task);
_renderRunningTab();
}});
}
if (task.status !== 'running' && task.status !== 'queued') {
items.push({ label: 'Reconnect', action: 'reconnect' });
items.push({ group: 'run', label: 'Reconnect tmux', action: 'reconnect' });
}
if (task.status === 'running') {
items.push({ label: 'Stop', action: 'stop', danger: true });
items.push({ group: 'run', label: 'Stop', action: 'stop', danger: true });
}
items.push({ label: 'Restart', action: 'retry' });
// Edit serve — open the full serve panel (same as the edit icon),
// switching to this task's server first so the model is found.
items.push({ group: 'run', label: 'Restart', action: 'retry' });
// ── Edit section ────────────────────────────────────────────
// Merged "Edit & relaunch" — opens the structured serve panel
// pre-filled with this task's config. The old standalone "Edit
// cmd & relaunch" raw-text dialog is now reachable from inside
// that panel (Show command). Single entry-point per task.
if (task.type === 'serve' && task.payload?.repo_id) {
items.push({ label: 'Edit in serve panel', action: 'edit-panel', tooltip: 'Open the full Serve config panel pre-filled with this task — pick a different backend, change GPUs, edit env vars, then Launch from there', custom: () => _openEdit() });
items.push({ group: 'edit', label: 'Edit & relaunch', action: 'edit-panel', tooltip: 'Open the Serve config panel pre-filled with this task — pick a different backend, change GPUs, edit env vars or the raw cmd, then Launch.', custom: () => _openEdit() });
}
// Save serve — save current launch config as a preset.
if (task.type === 'serve' && task.payload?._cmd) {
items.push({ label: 'Save serve', action: 'save', custom: () => {
items.push({ group: 'edit', label: 'Save serve', action: 'save', custom: () => {
if (!_saveTaskAsPreset(task)) { uiModule.showToast('Already saved'); return; }
uiModule.showToast('Saved to presets');
_renderRunningTab();
}});
}
// Edit command — only meaningful for serve tasks that aren't running.
// Lets the user tweak flags after a crash/error and relaunch.
if (task.type === 'serve' && task.status !== 'running' && task.payload?._cmd) {
items.push({ label: 'Edit cmd & relaunch', action: 'edit', tooltip: 'Edit the raw vllm/llama-server cmd string in a dialog and relaunch immediately on the same host', custom: async () => {
const newCmd = await _promptEditServeCmd(task.payload._cmd);
if (newCmd == null) return; // cancelled
try {
await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
});
} catch {}
_removeTask(task.sessionId);
// Relaunch on the task's OWN host, not the current global selection.
_launchServeTask(task.name, task.payload.repo_id, newCmd, task.payload._fields, task.remoteHost || '');
}});
}
// ── Endpoint section ────────────────────────────────────────
// Manual endpoint registration — fallback for when auto-add fails
// (e.g. probe timeout on a remote that's slow). Forces adding this
// serve to the model-endpoints list regardless of prior flag state.
if (task.type === 'serve' && task.payload?._cmd) {
items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
items.push({ group: 'endpoint', label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
const host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
@@ -2195,31 +2239,32 @@ export function _renderRunningTab() {
}
}});
}
// ── Copy section ────────────────────────────────────────────
if (_isWindows(task)) {
const host = task.remoteHost;
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
const logCmd = host
? `ssh ${_sshPrefix(_getPort(task))}${host} "powershell -Command \\"Get-Content '${sd}\\${task.sessionId}.log' -Wait\\""`
: `powershell -Command "Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${task.sessionId}.log') -Wait"`;
items.push({ label: 'Copy log cmd', action: 'copy-tmux', custom: () => {
items.push({ group: 'copy', label: 'Copy log cmd', action: 'copy-tmux', custom: () => {
_copyText(logCmd);
}});
} else {
// Just the tmux command itself — no ssh wrapper.
const tmuxAttach = `tmux attach -t ${task.sessionId}`;
items.push({ label: 'Copy tmux', action: 'copy-tmux', custom: () => {
items.push({ group: 'copy', label: 'Copy tmux', action: 'copy-tmux', custom: () => {
_copyText(tmuxAttach);
}});
}
if (_shouldOfferCrashReport(task)) {
items.push({ label: 'Copy crash report', action: 'copy-crash-report', custom: () => {
items.push({ group: 'copy', label: 'Copy crash report', action: 'copy-crash-report', custom: () => {
const out = (el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
_copyText(_buildCrashReport(task, out));
uiModule.showToast('Copied crash report');
}});
}
// Copy the last 50 lines of the task's output/log.
items.push({ label: 'Copy last 50 lines', action: 'copy-log', custom: () => {
items.push({ group: 'copy', label: 'Copy last 50 lines', action: 'copy-log', custom: () => {
const out = (el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
const last = out.split('\n').slice(-50).join('\n');
if (!last.trim()) {
@@ -2233,8 +2278,10 @@ export function _renderRunningTab() {
// the live tmux session and (for serve tasks) deletes the
// matching model-endpoint, THEN animates the task card out.
// Just "Remove" hid that it stops the live serve too.
// ── Danger section ──────────────────────────────────────────
const _isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
items.push({
group: 'danger',
label: _isLive ? 'Stop and remove' : 'Remove',
action: 'kill',
tooltip: _isLive
@@ -2242,10 +2289,8 @@ export function _renderRunningTab() {
: 'Remove this row',
danger: true,
});
// Cancel = mobile-only dismiss item. Same pattern as the email kebab:
// the `dropdown-cancel-mobile` class is hidden on desktop and styled
// as a separated bottom row on mobile (border-top + extra padding).
items.push({ label: 'Cancel', action: 'cancel', mobileOnly: true, custom: () => {} });
// Cancel = mobile-only dismiss item. Same pattern as the email kebab.
items.push({ group: 'danger', label: 'Cancel', action: 'cancel', mobileOnly: true, custom: () => {} });
const _MENU_ICONS = {
'start-now': '<polygon points="6 4 20 12 6 20 6 4"/>',
@@ -2262,7 +2307,18 @@ export function _renderRunningTab() {
kill: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
cancel: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
};
let _lastGroup = null;
for (const item of items) {
// Insert a thin divider whenever the group changes, so the
// user can visually scan Run / Edit / Endpoint / Copy / Danger
// blocks instead of one long undifferentiated list.
if (item.group && _lastGroup && item.group !== _lastGroup) {
const sep = document.createElement('div');
sep.className = 'cookbook-dropdown-divider';
sep.style.cssText = 'height:1px;margin:4px 6px;background:color-mix(in srgb, var(--fg) 12%, transparent);pointer-events:none;';
dropdown.appendChild(sep);
}
_lastGroup = item.group || _lastGroup;
const div = document.createElement('div');
div.className = 'dropdown-item-compact'
+ (item.danger ? ' cookbook-dropdown-danger' : '')
@@ -2652,7 +2708,7 @@ async function _reconnectTask(el, task) {
// capture-pane lets the existing _reconnectTask flow pick up
// the real state (running, finished, or truly dead).
const _reconnectFix = {
label: 'Reconnect',
label: 'Reconnect tmux',
action: () => {
_updateTask(task.sessionId, { status: 'running' });
el.dataset.status = 'running';
+310 -46
View File
@@ -9,6 +9,7 @@ import spinnerModule from './spinner.js';
import { providerLogo } from './providers.js';
import { modelColor } from './chatRenderer.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js';
// Shared state/functions injected by init()
let _envState;
@@ -546,7 +547,14 @@ function _rerenderCachedModels() {
: (_es.gpus || detectedGpuIds));
const tpOpts = [1,2,4,8].map(n => `<option${defaultTp==String(n)?' selected':''}>${n}</option>`).join('');
const dtypeOpts = ['auto','float16','bfloat16'].map(d => `<option value="${d}"${sv('dtype','auto')===d?' selected':''}>${d}</option>`).join('');
const vllmKvCacheOpts = ['auto','fp8'].map(d => `<option value="${d}"${sv('vllm_kv_cache_dtype','auto')===d?' selected':''}>${d}</option>`).join('');
// KV cache default — most models are fine on auto, but a few
// (e.g. DeepSeek-V3/V4/R1 MoE) need fp8 explicitly or the launch
// OOMs. _detectModelOptimizations seeds opts.kvCacheDtype for
// those families; honour it unless the user has a saved override.
const _kvOptsCheck = _detectModelOptimizations(repo);
const _kvAutoDefault = (_kvOptsCheck && _kvOptsCheck.kvCacheDtype) || 'auto';
const _kvSelected = sv('vllm_kv_cache_dtype', _kvAutoDefault);
const vllmKvCacheOpts = ['auto','fp8'].map(d => `<option value="${d}"${_kvSelected===d?' selected':''}>${d}</option>`).join('');
const _l = (name, tip) => `<span>${name}<span class="hwfit-hint" title="${tip}">?</span></span>`;
const _ggufChoices = _runnableGgufFiles(m);
const _savedGguf = String(sv('gguf_file', '') || '');
@@ -572,12 +580,22 @@ function _rerenderCachedModels() {
const _arrowTitle = _modelPresets.length > 0
? `${_modelPresets.length} saved launch config${_modelPresets.length === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete`
: `No saved launch configs for ${_repoShort} yet — click Save to add one`;
let _slotsHtml = `<div class="cookbook-serve-slots cookbook-saved-split">`
// Wrap the Save split in a <label> so it picks up the same "field
// title + ?-help" treatment as Backend / venv / Port / GPUs sitting
// beside it in Row 1. Button text is "Save" (the action), label is
// "Settings" (what the saved blob represents).
let _slotsHtml = `<label>${_l('Settings','Saved launch configurations for this model — click ▾ to load or delete')}`
+ `<div class="cookbook-serve-slots cookbook-saved-split">`
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-save" title="Save current config"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="${esc(_arrowTitle)}">${_arrowLabel}</button>`
+ `</div>`;
+ `</div></label>`;
let panelHtml = `<div class="hwfit-serve-panel">`;
// Runtime-readiness note pinned at the top of the serve area so the
// user sees "vLLM ready on …" before scrolling into the configure
// form. Hidden until the readiness probe returns. The × button
// dismisses it for this panel only (re-shows on re-expand).
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin:0 0 8px;padding:6px 28px 6px 10px;border-radius:5px;background:color-mix(in srgb, var(--fg) 4%, transparent);border:1px solid color-mix(in srgb, var(--border) 60%, transparent);position:relative;"><span class="hwfit-serve-runtime-text"></span><button type="button" class="hwfit-serve-runtime-close" title="Dismiss" aria-label="Dismiss" style="position:absolute;top:-8px;right:5px;background:none;border:0;color:inherit;cursor:pointer;padding:2px 4px;line-height:1;font-size:13px;opacity:0.6;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>`;
// Warn when serving a model whose download hasn't fully completed —
// the user CAN still hit Launch (vLLM/llama-server will start, then
// crash trying to read missing shards), but they should know.
@@ -596,9 +614,19 @@ function _rerenderCachedModels() {
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
panelHtml += `<label>${_l('Backend','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<select class="hwfit-sf" data-field="backend">${backendOpts}</select></label>`;
// Custom Backend picker — native <select> can't host SVG inside
// options, so we render a button + menu that show the backend logo
// beside its name. The hidden <select.hwfit-sf data-field="backend">
// stays as the source-of-truth so every existing change handler
// (updateBackendVisibility, runtime readiness, command builder)
// still fires via dispatchEvent('change') on selection.
panelHtml += `<label>${_l('Backend','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:28px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="host" value="${esc(_es.remoteHost || '')}" />`;
panelHtml += `<label>${_l('venv','Path to Python venv or conda env activate script')}<input type="text" class="hwfit-sf hwfit-sf-wide" data-field="venv" value="${esc(sv('venv', _es.envPath || _srvVenv || ''))}" placeholder="~/venv" /></label>`;
// Dtype lives in Row 1 (next to venv) — it's the first knob people
// change when matching the model to the box, so it earns top-row
// real estate over Row 2's launch-tuning controls.
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
const defaultPort = defaultBackend === 'ollama' ? '11434' : _nextAvailablePort();
panelHtml += `<label>${_l('Port','HTTP port for the API server')}<input type="text" class="hwfit-sf" data-field="port" value="${esc(sv('port', defaultPort))}" /></label>`;
const _activeGpus = (defaultGpus || '').split(',').map(s => s.trim()).filter(Boolean);
@@ -609,12 +637,16 @@ function _rerenderCachedModels() {
const on = _activeGpus.includes(String(i));
_gpuBtnsHtml += `<button type="button" class="cookbook-gpu-btn${on ? ' active' : ''}" data-gpu="${i}">${i}</button>`;
}
panelHtml += `<label>${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
// Save / saved-configs split button — moved into Row 1 (next to GPUs)
// so it shares the same baseline as the rest of the top controls.
// GPUs button strip moved to Row 2 (next to GPU Mem) below. 4px
// margin on the left, 8px on the right — extra 4px right-side gap
// separates the GPU chiclets from the GPU Mem field that follows
// (asked-for breathing room; 4px on either side felt cramped on
// the GPU-Mem boundary).
const _gpusLabelHtml = `<label class="hwfit-gpus-label" style="margin:0 8px 0 4px;">${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
// Save / saved-configs split button — sits at the right end of Row 1.
panelHtml += _slotsHtml;
panelHtml += `</div>`;
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin-top:-4px;"></div>`;
// (hwfit-serve-runtime-note moved to the top of the panel — see above.)
if (_ggufChoices.length > 1) {
// Show the GGUF File dropdown for BOTH llama.cpp and Ollama — Ollama
// also needs to know which exact .gguf to import via the new
@@ -631,15 +663,22 @@ function _rerenderCachedModels() {
// TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else
// (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch)
// moved to the Advanced fold below to keep this row scannable.
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
// Order: TP → Context → Max Seqs → GPUs → GPU Mem.
// Dtype moved up to Row 1. GPUs moved here next to GPU Mem so the
// "which devices + how much of them" decisions sit adjacent. Max
// Seqs follows Context per the "request-shape" cluster.
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
// ctx resets to the model's max on every panel open (the real ctx slider
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
panelHtml += `<label>${_l('Context','Max tokens per request — resets to the model max on every open. Lower = less VRAM')}<input type="text" class="hwfit-sf" data-field="ctx" value="${esc(m.context_length || m.context || '20000')}" /></label>`;
panelHtml += `<label>${_l('GPU','Which GPU to use. Leave empty for default')}<input type="text" class="hwfit-sf" data-field="gpu_id" value="${esc(sv('gpu_id', ''))}" placeholder="auto" style="width:50px;" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('Max Seqs','Maximum concurrent requests. Lower = less memory. Default 4 — prosumer GPUs often OOM on vLLM default 256 during CUDA graph capture.')}<input type="text" class="hwfit-sf" data-field="max_seqs" value="${esc(sv('max_seqs', '4'))}" placeholder="4" /></label>`;
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
// GPU "auto" field removed — the GPU button strip below already
// writes data-field="gpus" (the canonical comma-separated device
// list) and the command builders now read from that single source.
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`;
// GPUs button strip at the far right of Row 2.
panelHtml += _gpusLabelHtml;
panelHtml += `</div>`;
// ── Advanced (collapsed by default) ──
// Everything below the fold is tuning users only touch occasionally:
@@ -667,7 +706,10 @@ function _rerenderCachedModels() {
// tuning, or any other KEY=VALUE pair that doesn't have a dedicated
// field. After the venv activate runs, $VIRTUAL_ENV / $PATH / etc. are
// already exported so they expand correctly here.
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang" style="flex:1 1 100%;">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). Example: CUDACXX=$VIRTUAL_ENV/lib/python3.10/site-packages/nvidia/cuda_nvcc/bin/nvcc — points flashinfer at the venv-bundled nvcc when the system one is too old for your GPU.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(sv('extra_env',''))}" placeholder="CUDACXX=/path/to/nvcc NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
// grid-column: 1 / -1 makes Env span every column of the Advanced
// row's CSS grid (the old flex:1 1 100% did nothing in a grid
// container — left an empty trailing column gap on wide modals).
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang" style="grid-column:1 / -1;">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). Example: CUDACXX=$VIRTUAL_ENV/lib/python3.10/site-packages/nvidia/cuda_nvcc/bin/nvcc — points flashinfer at the venv-bundled nvcc when the system one is too old for your GPU.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(sv('extra_env',''))}" placeholder="CUDACXX=/path/to/nvcc NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
panelHtml += `</div>`;
// Advanced llama.cpp row (Batch / UBatch — moved out of Core for the
// same "rarely touched" reason as the vLLM extras above).
@@ -686,11 +728,36 @@ function _rerenderCachedModels() {
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
panelHtml += `</div>`;
// Row 3: Checkboxes (vLLM)
// Order: Trust Remote → Auto Tool → Reasoning Parser (when the
// model has one) → Enforce Eager → Prefix Caching. Reasoning
// Parser was previously in a separate row below; the user wanted
// it inline with the other vLLM toggles between Auto Tool and
// Enforce Eager so the "what the model needs" decisions sit
// together at the top.
const _opts2_row3 = _detectModelOptimizations(repo);
const _rp_flag = _opts2_row3.flags.find(f => f.includes('--reasoning-parser'));
const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : '';
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm hwfit-backend-sglang">`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',false)?' checked':''} /> Trust Remote Code${_h('Allow model to run custom code from HuggingFace')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',false)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
if (_rp_name) panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name}" /> Reasoning Parser <span class="hwfit-parser-tag">${_rp_name}</span></label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
// Inline the previously-second vLLM checks row so Expert Parallel /
// Speculative / MoE Env sit next to Prefix Caching with no gap. All
// three are vLLM-only — class-gated so they hide on SGLang.
if (_opts2_row3.flags.includes('--enable-expert-parallel')) panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel" /> Expert Parallel</label>`;
{
const _specDef = _opts2_row3.spec || { method: 'mtp', tokens: 3 };
const _specMethod = sv('spec_method', _specDef.method);
const _specTokens = sv('spec_tokens', String(_specDef.tokens));
const _specMethods = ['mtp', 'qwen3_next_mtp', 'eagle', 'medusa', 'ngram'];
if (!_specMethods.includes(_specMethod)) _specMethods.unshift(_specMethod);
const _specOpts = _specMethods.map(m =>
`<option value="${m}"${m === _specMethod ? ' selected' : ''}>${m}</option>`).join('');
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="speculative" /> Speculative <select class="hwfit-sf hwfit-spec-method" data-field="spec_method" title="vLLM --speculative-config method">${_specOpts}</select><input type="number" class="hwfit-sf hwfit-spec-tokens hwfit-spec-tokens-bare" data-field="spec_tokens" value="${esc(_specTokens)}" min="1" max="10" title="num_speculative_tokens" style="width:44px;" /><span class="hwfit-help-chip hwfit-help-chip-inline" title="MTP / speculative decoding is supported on a few model families only — turn it on when the model card explicitly recommends it. On supported models it can boost inference throughput up to ~3×; on unsupported models it will either be ignored or fail to launch." style="margin-left:6px;">?</span></label>`;
}
if (_opts2_row3.envVars.length) panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`;
panelHtml += `</div>`;
// Row 2c: llama.cpp fit/perf flags (set by Auto profiles, editable by hand)
const _kvOpts = ['', 'q4_0', 'q8_0', 'f16'].map(k => `<option value="${k}"${sv('cache_type','')===k?' selected':''}>${k||'default'}</option>`).join('');
@@ -739,33 +806,16 @@ function _rerenderCachedModels() {
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers">`;
panelHtml += `<label>Harmonize GPU${_h('Separate GPU for img2img/harmonize. Leave empty to use same GPU')}<input type="text" class="hwfit-sf" data-field="diff_harmonize_gpu" value="${esc(sv('diff_harmonize_gpu', ''))}" placeholder="auto" style="width:50px;" /></label>`;
panelHtml += `</div>`;
// Row 4: Extra args
panelHtml += `<div class="hwfit-serve-extra">`;
panelHtml += `<label>Extra args<input type="text" class="hwfit-sf" data-field="extra" value="${esc(sv('extra', ''))}" placeholder="--flag value" /></label>`;
panelHtml += `</div>`;
// Model-specific optimizations. The checks row always renders for the
// vLLM backend so the Speculative (MTP) control is ALWAYS reachable —
// even for models the auto-detector doesn't recognize. Expert-parallel,
// reasoning-parser and MoE-env still only appear when auto-detected.
const _opts2 = _detectModelOptimizations(repo);
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm">`;
if (_opts2.flags.includes('--enable-expert-parallel')) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel" /> Expert Parallel</label>`;
if (_opts2.flags.some(f => f.includes('--reasoning-parser'))) { const rp = _opts2.flags.find(f => f.includes('--reasoning-parser')).split(' ')[1]; panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${rp}" /> Reasoning Parser <span class="hwfit-parser-tag">${rp}</span></label>`; }
{
// Speculative decoding (vLLM --speculative-config). Default OFF; the
// method/token defaults come from auto-detection when available,
// else fall back to MTP/3. Toggling the checkbox is what actually
// adds the flag at launch (see cookbook.js command builder).
const _specDef = _opts2.spec || { method: 'mtp', tokens: 3 };
const _specMethod = sv('spec_method', _specDef.method);
const _specTokens = sv('spec_tokens', String(_specDef.tokens));
const _specMethods = ['mtp', 'qwen3_next_mtp', 'eagle', 'medusa', 'ngram'];
if (!_specMethods.includes(_specMethod)) _specMethods.unshift(_specMethod);
const _specOpts = _specMethods.map(m =>
`<option value="${m}"${m === _specMethod ? ' selected' : ''}>${m}</option>`).join('');
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="speculative" /> Speculative <select class="hwfit-sf hwfit-spec-method" data-field="spec_method" title="vLLM --speculative-config method">${_specOpts}</select><span class="hwfit-numstep"><button type="button" class="hwfit-numstep-btn" data-step="-1" tabindex="-1" aria-label="Decrease">‹</button><input type="number" class="hwfit-sf hwfit-spec-tokens" data-field="spec_tokens" value="${esc(_specTokens)}" min="1" max="10" title="num_speculative_tokens" /><button type="button" class="hwfit-numstep-btn" data-step="1" tabindex="-1" aria-label="Increase">›</button></span><span class="hwfit-help-chip hwfit-help-chip-inline" title="MTP / speculative decoding is supported on a few model families only — turn it on when the model card explicitly recommends it. On supported models it can boost inference throughput up to ~3×; on unsupported models it will either be ignored or fail to launch." style="margin-left:6px;">?</span></label>`;
}
if (_opts2.envVars.length) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`;
// Expert Parallel / Speculative / MoE Env moved into Row 3 above so
// the vLLM-only toggles sit next to Prefix Caching with no gap.
// Extra args sits below the vLLM checks (Reasoning Parser + Spec)
// so it reads as "after the advanced toggles, any other flags".
panelHtml += `<div class="hwfit-serve-extra">`;
panelHtml += `<label>Extra args<input type="text" class="hwfit-sf" data-field="extra" value="${esc(sv('extra', ''))}" placeholder="--flag value" /></label>`;
panelHtml += `</div>`;
// ── End Advanced fold ──
panelHtml += `</details>`;
@@ -958,37 +1008,183 @@ function _rerenderCachedModels() {
if (ok === false) clearInterval(_vramTimer);
}, 4000);
// Show/hide backend-specific sections
// Backend icons — accent color, rendered via currentColor. vLLM gets
// a stylized double-V mark, the others fall back to a recognizable
// glyph for the engine family. Shown beside each option in the
// custom picker so the dropdown lists "[V] vLLM", "[⚡] SGLang", etc.
const _BACKEND_GLYPHS = {
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>',
ollama: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 0 1 12 0v4a4 4 0 0 1-8 0v-1"/><circle cx="10" cy="9" r="1"/><circle cx="14" cy="9" r="1"/></svg>',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
};
// ── Custom Backend picker wiring ────────────────────────────────
// Reads the option list from the hidden <select.hwfit-backend-source>
// so the canonical (value, label) pairs come from one place.
const _backendPicker = panel.querySelector('[data-backend-picker]');
const _backendSource = panel.querySelector('.hwfit-backend-source');
const _backendBtn = panel.querySelector('[data-backend-btn]');
const _backendMenu = panel.querySelector('[data-backend-menu]');
const _backendBtnLabel = panel.querySelector('[data-backend-label]');
const _backendBtnIconSlot = _backendBtn?.querySelector('[data-backend-icon-slot]');
function _setBackendBtnState(v) {
if (!_backendBtn) return;
const opt = _backendSource?.querySelector(`option[value="${CSS.escape(v)}"]`);
const label = opt ? opt.textContent : v;
if (_backendBtnLabel) _backendBtnLabel.textContent = label;
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] || _BACKEND_GLYPHS.vllm;
}
function _renderBackendMenu() {
if (!_backendMenu || !_backendSource) return;
const items = Array.from(_backendSource.options).map(o => ({ value: o.value, label: o.textContent }));
_backendMenu.innerHTML = items.map(it => `
<button type="button" role="option" class="hwfit-backend-item" data-value="${it.value}" style="all:unset;display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px;border-radius:5px;font-size:12px;cursor:pointer;color:var(--fg);box-sizing:border-box;">
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] || _BACKEND_GLYPHS.vllm}</span>
<span class="hwfit-backend-item-label" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${it.label}</span>
</button>
`).join('');
// Hover styling (no global CSS rule — keep it self-contained).
_backendMenu.querySelectorAll('.hwfit-backend-item').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = ''; });
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const v = btn.dataset.value;
if (_backendSource && _backendSource.value !== v) {
_backendSource.value = v;
_backendSource.dispatchEvent(new Event('change', { bubbles: true }));
}
_setBackendBtnState(v);
_closeBackendMenu();
});
});
}
function _openBackendMenu() {
if (!_backendMenu || !_backendBtn) return;
_backendMenu.hidden = false;
_backendBtn.setAttribute('aria-expanded', 'true');
}
function _closeBackendMenu() {
if (!_backendMenu || !_backendBtn) return;
_backendMenu.hidden = true;
_backendBtn.setAttribute('aria-expanded', 'false');
}
if (_backendBtn) {
_backendBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (_backendMenu.hidden) _openBackendMenu();
else _closeBackendMenu();
});
document.addEventListener('click', (ev) => {
if (!_backendMenu.hidden && !_backendPicker?.contains(ev.target)) _closeBackendMenu();
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && !_backendMenu.hidden) {
ev.stopPropagation();
_closeBackendMenu();
}
}, { capture: true });
}
_renderBackendMenu();
_setBackendBtnState(_backendSource?.value || defaultBackend);
function updateBackendVisibility() {
const b = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
panel.querySelectorAll('[class*="hwfit-backend-"]').forEach(el => {
// Skip the entire backend-picker subtree — the picker's own
// classes (`hwfit-backend-picker`, `-btn`, `-menu`, `-item`,
// `-btn-icon`, `-btn-label`, `-item-icon`, `-item-label`) all
// match the wildcard and would get hidden as if they were
// "backend-specific form sections", which left the dropdown
// looking empty / collapsed.
if (el.closest('.hwfit-backend-picker')) return;
const show = el.classList.contains(`hwfit-backend-${b}`);
el.style.display = show ? '' : 'none';
});
_setBackendBtnState(b);
}
updateBackendVisibility();
async function updateRuntimeReadinessNote() {
const note = panel.querySelector('.hwfit-serve-runtime-note');
if (!note) return;
// Mirror the message into a small chip next to the model title at
// the top of the card, so the readiness state is visible without
// having to look down into the panel body.
// Clean up any title chip from previous versions — the readiness
// text now lives inside the panel at the top, not in the card title.
const card = panel.closest('.doclib-card, .memory-item');
const titleEl = card ? card.querySelector('.memory-item-title') : null;
const titleChip = titleEl ? titleEl.querySelector('.hwfit-serve-runtime-chip') : null;
if (titleChip) titleChip.remove();
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
const noteText = note.querySelector('.hwfit-serve-runtime-text');
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) {
note.style.display = 'none';
note.textContent = '';
_writeNote('');
return;
}
// Wire dismiss once per note element.
const _closeBtn = note.querySelector('.hwfit-serve-runtime-close');
if (_closeBtn && !_closeBtn._wired) {
_closeBtn._wired = true;
_closeBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
note.style.display = 'none';
panel._runtimeNoteDismissed = true;
});
}
// If the user dismissed it earlier on this panel, don't re-show.
if (panel._runtimeNoteDismissed) return;
const seq = (panel._runtimeReadinessSeq || 0) + 1;
panel._runtimeReadinessSeq = seq;
note.style.display = '';
note.textContent = 'Checking runtime on selected server...';
_writeNote('Checking runtime on selected server…');
note.style.borderColor = '';
note.style.color = 'var(--fg-muted)';
try {
const { pkg, target } = await _fetchServeRuntimePackage(panel, backend);
if (panel._runtimeReadinessSeq !== seq) return;
note.textContent = _runtimeNoteText(backend, pkg, target);
note.style.color = pkg?.installed ? 'var(--fg-muted)' : 'var(--red)';
_writeNote(_runtimeNoteText(backend, pkg, target));
if (!pkg?.installed) {
note.style.color = 'var(--red)';
note.style.borderColor = 'color-mix(in srgb, var(--red) 40%, transparent)';
note.style.background = 'color-mix(in srgb, var(--red) 8%, transparent)';
// Append an accent-color link straight to the Dependencies
// recipe panel for this backend so the user has one click
// to the fix instead of hunting for the right row.
if (noteText) {
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', diffusers: 'diffusers' }[backend]);
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
const link = document.createElement('a');
link.href = '#';
link.textContent = ' Install in Dependencies →';
link.style.cssText = 'color:var(--accent, var(--red));text-decoration:underline;font-weight:600;margin-left:4px;';
link.addEventListener('click', (ev) => {
ev.preventDefault();
if (pkgName) openCookbookDependencies(pkgName, { expandRecipe: pkgName, model: repo });
});
noteText.appendChild(link);
}
} else {
// Healthy / ready → green so the user reads "good to go" at a
// glance instead of scanning fg-muted for a state.
note.style.color = 'var(--green, #4caf50)';
note.style.borderColor = 'color-mix(in srgb, var(--green, #4caf50) 40%, transparent)';
note.style.background = 'color-mix(in srgb, var(--green, #4caf50) 8%, transparent)';
}
} catch (err) {
if (panel._runtimeReadinessSeq !== seq) return;
note.textContent = `Runtime readiness unavailable: ${err?.message || err}`;
_writeNote(`Runtime readiness unavailable: ${err?.message || err}`);
note.style.color = 'var(--fg-muted)';
}
}
@@ -1688,15 +1884,39 @@ function _rerenderCachedModels() {
// Cancel button — collapses the serve config panel (same effect as
// tapping the row to toggle it shut). Mobile users wanted an explicit
// "back out" affordance next to Launch.
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
ev.stopPropagation();
const _collapsePanel = () => {
panel._cleanupRuntimeReadiness?.();
panel.remove();
item.classList.remove('doclib-card-expanded');
item.style.flexDirection = '';
item.style.alignItems = '';
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
};
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
ev.stopPropagation();
_collapsePanel();
});
// Esc anywhere on the page closes the open serve panel. Skips when
// the user is typing in a field — they want Esc to deselect / blur
// those, not collapse the form they're configuring.
const _onEscClose = (ev) => {
if (ev.key !== 'Escape') return;
if (!panel.isConnected) {
document.removeEventListener('keydown', _onEscClose, true);
return;
}
const t = ev.target;
const inField = t && (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable
);
if (inField) return;
// Skip when one of the dropdown/menu popovers is open — the
// popovers handle their own Esc and use stopPropagation, so any
// Esc that bubbles here means nothing else claimed it.
ev.stopPropagation();
_collapsePanel();
};
document.addEventListener('keydown', _onEscClose, true);
// Launch button
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
@@ -1751,6 +1971,50 @@ function _rerenderCachedModels() {
else serveState[el.dataset.field] = el.value;
});
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
// Pre-launch: check our own task list for a serve already running
// on this host. Offer to stop+launch as the default action — the
// SSH-based port probe below is more thorough but it can miss
// when SSH glitches or `ss` isn't installed. This catches the
// common case instantly without waiting for a network round-trip.
try {
const _runningMod = await import('./cookbookRunning.js');
const _hostStr = _envState.remoteHost || '';
const _active = (_runningMod._loadTasks ? _runningMod._loadTasks() : []).filter(t =>
t && t.type === 'serve'
&& (t.remoteHost || '') === _hostStr
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
);
if (_active.length) {
const _names = _active.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
const _ok = await window.styledConfirm(
`${_active.length} model${_active.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
{ title: 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
);
if (!_ok) { _restoreLaunchBtn(); return; }
// Kill each active serve; prefer the rendered Stop button so
// endpoint cleanup + Ollama unload run normally. Fall back to
// a raw tmux kill when the Active tab isn't in the DOM.
for (const t of _active) {
try {
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
const _btn = _el?.querySelector('.cookbook-task-action-stop');
if (_btn) {
_btn.click();
} else if (_runningMod._tmuxGracefulKill) {
await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
});
}
} catch (_killErr) { /* best-effort */ }
}
// Give the OS a beat to release port 8000.
await new Promise(r => setTimeout(r, 2500));
}
} catch (_e) { /* best-effort */ }
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
if (backendWarning) {
_restoreLaunchBtn();
+181 -22
View File
@@ -2472,6 +2472,8 @@ import * as Modals from './modalManager.js';
}
// Hide toolbar items that have no clean WYSIWYG equivalent in email (Code).
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; });
// Show email-only toolbar items (AI reply button).
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'inline-flex'; });
if (emailHeader) emailHeader.style.display = '';
if (emailActions) emailActions.style.display = '';
// Emails have their own complete footer (Close / More / Send), so hide the
@@ -2864,6 +2866,8 @@ import * as Modals from './modalManager.js';
if (emailActions) emailActions.style.display = 'none';
// Restore toolbar items that were hidden for email (Code dropdown).
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; });
// Re-hide email-only toolbar items (AI reply button).
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'none'; });
// Restore the generic documents action bar + its bottom footer (Close /
// Copy / Export) for non-email docs.
const docActions = document.getElementById('doc-editor-actions');
@@ -3206,7 +3210,95 @@ import * as Modals from './modalManager.js';
renderTabs();
}
async function _aiReply() {
// Fast/Full + optional context popover for the doc-editor email Reply button.
// Mirrors the email reader's AI reply choice popover so the UX is identical:
// textarea for an optional steering note, then Fast (lightning) or Full
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) {
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
_docAiReplyChoiceMenu = null;
}
}
function _showDocAiReplyChoice(btn) {
_closeDocAiReplyChoice();
if (!btn) return;
const rect = btn.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'doc-ai-reply-choice';
const menuMaxW = Math.min(240, window.innerWidth - 16);
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
const estHeight = 150;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const top = (spaceBelow >= estHeight || spaceBelow >= spaceAbove)
? Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8))
: Math.max(8, rect.top - estHeight - 6);
menu.style.cssText = [
'position:fixed',
`left:${left}px`,
`top:${top}px`,
`max-width:${menuMaxW}px`,
'box-sizing:border-box',
'z-index:10060',
'display:flex',
'gap:6px',
'padding:6px',
'background:var(--bg,#111)',
'border:1px solid var(--border,#333)',
'border-radius:7px',
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
].join(';');
menu.innerHTML = `
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<div style="display:flex;align-items:center;gap:4px;">
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Fast
</button>
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
Full
</button>
</div>
</div>
`;
const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
ev.preventDefault();
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice();
await _aiReply({ mode, noteHint });
});
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
_closeDocAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
// Esc to close.
const escClose = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
document.removeEventListener('keydown', escClose, true);
_closeDocAiReplyChoice();
}
};
document.addEventListener('keydown', escClose, true);
}
async function _aiReply(opts = {}) {
const { mode = 'auto', noteHint = '' } = (opts || {});
const to = document.getElementById('doc-email-to')?.value?.trim() || '';
const subject = document.getElementById('doc-email-subject')?.value?.trim() || '';
const textarea = document.getElementById('doc-editor-textarea');
@@ -3251,32 +3343,43 @@ import * as Modals from './modalManager.js';
if (btn) { btn.disabled = true; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Drafting...'; }
try {
// Empty-compose path: if there's no original body, send a placeholder
// so the backend's "no body" guard doesn't fail. The user_hint carries
// the user's compose intent; the model uses To/Subject + that hint.
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
const fastFlag = mode === 'ai-reply-fast' ? true
: mode === 'ai-reply-full' ? false
: shouldUseFastAiReply();
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: to,
subject: subject,
original_body: currentBody,
original_body: bodyForApi,
model: currentModel,
session_id: currentSessionId,
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: shouldUseFastAiReply(),
fast: fastFlag,
user_hint: noteHint || '',
}),
});
const data = await res.json();
if (data.success && data.reply) {
const cleanReply = cleanAiReplyText(data.reply);
const lines = currentBody.split('\n');
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
let newBody = '';
if (quoteIdx > 0) {
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
} else {
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
}
let cleanReply = cleanAiReplyText(data.reply);
// Strip any "On <date>, <name> wrote:" attribution + everything
// after it from the AI's output — the model sometimes re-quotes
// the original thread, and we already have the real quote in
// currentBody. Without this, AI's invented quote stacked on top
// of the real one and looked like the history had been "edited".
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
// Never overwrite the existing draft (user's typed text + the
// quoted history below it). Always prepend the AI suggestion so
// the user can read it, copy parts, or delete it — but their
// own work and the original quote are untouched.
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
await _streamEmailBodyText(textarea, newBody);
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
} else {
@@ -3285,7 +3388,7 @@ import * as Modals from './modalManager.js';
} catch (e) {
if (uiModule) uiModule.showError('Failed to generate AI reply');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Reply'; }
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;margin-left:4px;">Reply</span>'; }
}
}
@@ -3813,7 +3916,6 @@ import * as Modals from './modalManager.js';
<button id="doc-export-pdf-btn" class="doc-action-icon-btn" title="Export PDF" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg> <span style="font-size:11px;">Export PDF</span></button>
<button id="doc-pdf-view-btn" class="doc-action-icon-btn" title="Toggle PDF view" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <span style="font-size:11px;">PDF</span></button>
<select id="doc-language-select" class="doc-language-select">
<option value="">type</option>
<option value="python">python</option>
<option value="javascript">javascript</option>
<option value="typescript">typescript</option>
@@ -3851,22 +3953,24 @@ import * as Modals from './modalManager.js';
</button>
<div id="doc-email-fields" class="doc-email-fields">
<div class="email-field" style="position:relative">
<label>To</label>
<span class="email-field-prefix">To</span>
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
</div>
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
<label>Cc</label>
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
<span class="email-field-prefix">Cc</span>
<input type="text" id="doc-email-cc" placeholder="cc@example.com, example2" autocomplete="off" />
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
<button type="button" class="email-cc-close" data-cc-close title="Hide Cc/Bcc" aria-label="Hide Cc/Bcc"><svg width="11" height="11" 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 class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
<label>Bcc</label>
<span class="email-field-prefix">Bcc</span>
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
<button type="button" class="email-cc-close" data-cc-close title="Hide Cc/Bcc" aria-label="Hide Cc/Bcc"><svg width="11" height="11" 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 class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
<div class="email-field" style="position:relative"><span class="email-field-prefix">Subject</span><input type="text" id="doc-email-subject" placeholder="" /></div>
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
</div>
@@ -3879,13 +3983,14 @@ import * as Modals from './modalManager.js';
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
<div class="md-toolbar-items" id="md-toolbar-items">
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</span>
<span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run">
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
</span>
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
<span class="md-toolbar-sep"></span>
@@ -4395,6 +4500,24 @@ import * as Modals from './modalManager.js';
}
});
}
// Ctrl+Alt+M (and Cmd+Opt+M on mac) flips Edit ↔ Preview on a markdown
// doc. Bound once globally; gated on the doc panel being open and the
// active doc being markdown so it doesn't fire while the user is typing
// in a non-markdown context.
if (!window._docMdToggleBound) {
window._docMdToggleBound = true;
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && (e.key === 'm' || e.key === 'M' || e.code === 'KeyM')) {
if (!isOpen) return;
const doc = activeDocId && docs.get(activeDocId);
const lang = (doc?.language || 'markdown').toLowerCase();
if (lang !== 'markdown') return;
e.preventDefault();
toggleMarkdownPreview();
_syncHeaderActions();
}
});
}
document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => {
document.getElementById('doc-email-more-menu').style.display = 'none';
_saveDraft();
@@ -4409,7 +4532,11 @@ import * as Modals from './modalManager.js';
document.getElementById('doc-email-more-menu').style.display = 'none';
_scheduleSend(anchor);
});
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_showDocAiReplyChoice(ev.currentTarget);
});
const collapseBtn = document.getElementById('doc-email-collapse-btn');
if (collapseBtn && !collapseBtn._emailCollapseWired) {
@@ -4489,6 +4616,25 @@ import * as Modals from './modalManager.js';
_syncEmailHeaderSummary();
});
// Cc/Bcc close — X buttons inside the Cc and Bcc fields hide both
// rows + clear their inputs + restore the Cc opener on the To row.
document.querySelectorAll('[data-cc-close]').forEach(closeBtn => {
closeBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const ccRow = document.getElementById('doc-email-cc-row');
const bccRow = document.getElementById('doc-email-bcc-row');
const ccInput = document.getElementById('doc-email-cc');
const bccInput = document.getElementById('doc-email-bcc');
if (ccRow) ccRow.style.display = 'none';
if (bccRow) bccRow.style.display = 'none';
if (ccInput) ccInput.value = '';
if (bccInput) bccInput.value = '';
const ccToggle = document.getElementById('doc-email-show-cc');
if (ccToggle) ccToggle.style.display = '';
_syncEmailHeaderSummary();
});
});
// Autocomplete for To / Cc / Bcc — typed fragment after the last
// comma triggers contact search; Enter / Tab / click on a suggestion
// appends "<email>, " so the user can keep typing more recipients.
@@ -8527,6 +8673,19 @@ import * as Modals from './modalManager.js';
// `body:has(.doc-editor-pane.doc-fullscreen) .doc-divider-collapse` slides
// it into a forced-inside position). Hiding the divider here would hide
// the chevron with it.
// Hide the tab bar during the layout shift so any in-flight smooth
// scroll / reflow doesn't visibly "fly" the active tab across the
// pane as it expands. Restored after the layout settles.
const tabBar = document.getElementById('doc-tab-bar');
if (tabBar) {
tabBar.style.visibility = 'hidden';
clearTimeout(tabBar._fsHideTimer);
tabBar._fsHideTimer = setTimeout(() => {
tabBar.style.visibility = '';
}, 240);
}
if (pane.classList.contains('doc-fullscreen')) {
pane.classList.remove('doc-fullscreen');
if (container) container.style.display = '';
+16 -20
View File
@@ -22,8 +22,8 @@ const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
const _archiveIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _deleteIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _starIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
const _starIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replySeparator = '---------- Previous message ----------';
@@ -74,6 +74,11 @@ window.addEventListener('email-answered', (e) => {
item.classList.remove('email-unread');
const check = item.querySelector('.email-done-check');
if (check) check.classList.add('active');
// Auto-mark from sending a reply — flash the row so the user sees the
// state change without staring at it. Class self-removes after the
// animation so it doesn't replay on re-renders.
item.classList.add('email-auto-done-flash');
setTimeout(() => item.classList.remove('email-auto-done-flash'), 1200);
});
});
let _loading = false;
@@ -113,19 +118,19 @@ export function init(documentModule) {
} catch (_) {}
if (opts.compose) { _composeNew(); return; }
if (opts.email) {
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply');
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
}
},
});
_watchDocOpenToReDockEmail();
}
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply') {
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', prefilledBody = '') {
if (!uid) return;
const previousFolder = _currentFolder;
_currentFolder = folder || 'INBOX';
try {
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply');
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply', '', prefilledBody || '');
} finally {
_currentFolder = previousFolder || _currentFolder;
}
@@ -525,11 +530,6 @@ function _createEmailItem(em) {
</div>
<div class="email-subject">${_esc(em.subject)}${unreadIcon}${attachIcon}${tagPills}${spamTag}</div>
</div>
<div class="email-menu-wrap">
<button class="hamburger email-menu-btn" title="Actions">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
</div>
`;
// Click sender name → filter list to that sender
@@ -562,17 +562,10 @@ function _createEmailItem(em) {
// Click to open — do NOT close sidebar
item.addEventListener('click', (e) => {
if (e.target.closest('.email-menu-wrap')) return;
if (item.dataset.swipeBlock === '1') return;
_openEmail(em, item);
});
const menuWrap = item.querySelector('.email-menu-wrap');
menuWrap.addEventListener('click', (e) => {
e.stopPropagation();
_showEmailMenu(em, menuWrap, item);
});
// Swipe left to archive (mobile). Mirrors sidebar-layout.js swipe pattern.
if ('ontouchstart' in window) {
let startX = 0, startY = 0, dx = 0, dy = 0, swiping = false, swiped = false;
@@ -580,7 +573,6 @@ function _createEmailItem(em) {
const VERT_CANCEL = 30; // px vertical motion cancels swipe (treat as scroll)
item.addEventListener('touchstart', (e) => {
if (e.target.closest('.email-menu-wrap')) return;
const t = e.touches[0];
startX = t.clientX; startY = t.clientY;
dx = 0; dy = 0; swiping = true; swiped = false;
@@ -638,10 +630,13 @@ function _createEmailItem(em) {
return item;
}
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
let aiSuggestedBody = null;
// Body pre-fill from the agent's open_email_reply tool call takes the
// same insertion slot as an AI-suggested body — both land just before
// the quoted-original block.
let aiSuggestedBody = (typeof prefilledBody === 'string' && prefilledBody.trim()) ? prefilledBody.trim() : null;
if (wantsAiReply) {
// Fall through to reply-all (not plain reply) so the generated AI
// draft addresses everyone on the original thread. On single-
@@ -698,6 +693,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
uid: String(em.uid || ''),
folder: _currentFolder,
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
user_hint: (noteHint || '').trim() || undefined,
}),
});
const result = await res.json();
+1470 -218
View File
File diff suppressed because it is too large Load Diff
+82 -4
View File
@@ -18,6 +18,80 @@ let selectedIds = new Set();
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
// Sort-option icons for the custom Memory sort picker (and Skills picker
// once it reuses the same markup). Each value maps to a 13px Feather-style
// SVG so the icon visually distinguishes Newest / Oldest / A-Z / Most used.
const _MEMORY_SORT_ICONS = {
newest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
oldest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/><polyline points="12 7 12 12 16 14"/></svg>',
alpha: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h6"/><path d="M3 10h6"/><path d="M3 16h4"/><path d="M14 4l4 12"/><path d="M16 12h4"/><polyline points="17 18 21 14 17 10"/><line x1="21" y1="14" x2="13" y2="14"/></svg>',
uses: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>',
};
function _memorySortIcon(value) {
return _MEMORY_SORT_ICONS[value] || _MEMORY_SORT_ICONS.newest;
}
function _renderMemorySortPickerCurrent() {
const sel = document.getElementById('memory-sort');
const btn = document.getElementById('memory-sort-btn');
if (!sel || !btn) return;
const value = sel.value || 'newest';
const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`);
const label = opt ? opt.textContent : value;
const iconWrap = btn.querySelector('.memory-sort-icon-cur');
const labelEl = btn.querySelector('.memory-sort-label');
if (iconWrap) iconWrap.innerHTML = _memorySortIcon(value);
if (labelEl) labelEl.textContent = label;
}
function _initMemorySortPicker() {
const sel = document.getElementById('memory-sort');
const picker = document.getElementById('memory-sort-picker');
const btn = document.getElementById('memory-sort-btn');
const menu = document.getElementById('memory-sort-menu');
if (!sel || !picker || !btn || !menu || picker._wired) return;
picker._wired = true;
const items = Array.from(sel.children)
.filter(o => o.tagName === 'OPTION')
.map(o => ({ value: o.value, label: o.textContent }));
menu.innerHTML = items.map(it => `
<button type="button" role="option" class="memory-sort-item" data-value="${it.value}">
<span class="memory-sort-item-icon">${_memorySortIcon(it.value)}</span>
<span class="memory-sort-item-label">${it.label}</span>
</button>
`).join('');
const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
const open = () => { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open(); else close();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.memory-sort-item');
if (!item) return;
sel.value = item.dataset.value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
_renderMemorySortPickerCurrent();
close();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !picker.contains(e.target)) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !menu.hidden) {
e.stopPropagation();
close();
}
}, { capture: true });
_renderMemorySortPickerCurrent();
}
function _ensureNewMemoryCategorySelect() {
const sel = document.getElementById('new-memory-category');
if (!sel || sel.dataset.wired === '1') return;
@@ -334,13 +408,16 @@ export async function loadMemories() {
// ---- Bulk select mode ----
const _SELECT_BTN_DOT_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
function enterSelectMode() {
selectMode = true;
selectedIds.clear();
const bulkBar = document.getElementById('memory-bulk-bar');
const selectBtn = document.getElementById('memory-select-btn');
if (bulkBar) bulkBar.classList.remove('hidden');
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.innerHTML = _SELECT_BTN_X_SVG + 'Cancel'; }
updateBulkCount();
renderMemoryList();
}
@@ -352,7 +429,7 @@ function exitSelectMode() {
const selectBtn = document.getElementById('memory-select-btn');
const selectAll = document.getElementById('memory-select-all');
if (bulkBar) bulkBar.classList.add('hidden');
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.innerHTML = _SELECT_BTN_DOT_SVG + 'Select'; }
if (selectAll) selectAll.checked = false;
renderMemoryList();
}
@@ -449,7 +526,7 @@ export async function tidyMemories() {
const data = await res.json();
if ((data.removed || 0) === 0) {
if (tidySpinner) tidySpinner.destroy();
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
showToast('Already clean');
return;
}
@@ -492,7 +569,7 @@ export async function tidyMemories() {
tidyBtn.disabled = false;
tidyBtn.style.border = '';
tidyBtn.style.background = '';
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
}
}
}
@@ -1387,6 +1464,7 @@ document.addEventListener('DOMContentLoaded', () => {
renderMemoryList();
});
}
_initMemorySortPicker();
const tidyBtn = document.getElementById('memory-tidy-btn');
if (tidyBtn) tidyBtn.addEventListener('click', tidyMemories);
+5 -1
View File
@@ -302,6 +302,7 @@ function _anchorLeftDock(content) {
}
}
export function collapseSidebarToRail() { return _collapseSidebarToRail(); }
function _collapseSidebarToRail() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
@@ -808,7 +809,10 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
handle.style.bottom = '0';
handle.style.width = '10px';
handle.style.cursor = 'col-resize';
handle.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
// Invisible at rest, accent stripe fades in on hover (see
// .edge-dock-resize-handle CSS rule).
handle.style.background = 'transparent';
handle.style.transition = 'background 0.18s ease';
handle.style.pointerEvents = 'auto';
handle.style.touchAction = 'none';
handle.style.display = 'none';

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