87 Commits

Author SHA1 Message Date
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
andrewemer 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
cirim 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 Hsu 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 Pai 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
RaresKeY 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 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 Castelluccio 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
Achilleas90 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
KYDNO 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
Abhishek Kumbhar a172522d87 fix(integrations): prevent blank API integrations (#3840)
* fix(integrations): validate unified API form fields

* fix(integrations): validate API integration fields server-side
2026-06-15 15:40:36 +09:00
Verdell-Nikon cd41de8043 Fix pinned skill prompt submission race (#3841) 2026-06-15 15:39:44 +09:00
Max Hsu fb9e023381 fix(cookbook): point HF token hint at Cookbook -> Settings, not Settings -> Cookbook (#3864)
The 'HF token: NOT SET' shell hint shown when downloading a gated/private
model told users to add a token under 'Odysseus Settings -> Cookbook ->
HuggingFace Token'. There is no Cookbook section under the app Settings;
the HuggingFace Token field lives under the Cookbook page's Settings tab
(static/js/cookbook.js — data-backend="Settings" group). Following the
old hint led nowhere. Reverse the path to match the real UI.

Fixes #3829

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:38:08 +09:00
Max Hsu 65c7321ace fix(cookbook): recover completed downloads from DOWNLOAD_OK in background reconciler (#4000)
The dashboard background status reconciler (_pollBackgroundStatus) only
recovered "done" for dependency installs when the backend reported a
finished task as "stopped". A real model download whose tmux pane is
gone after DOWNLOAD_OK (so the dead-session check misses the landed
snapshot) fell through to `task.type === 'download' ? 'crashed'`, so a
completed download was shown as crashed (and stalled on the Serve tab).

Recover "done" from the terminal DOWNLOAD_OK sentinel, mirroring the
dep-install recovery already present. The background poll runs blind, so
it keys off the conclusive exit-0 sentinel only — not the `/snapshots/`
path, which can be printed mid-stream for multi-file downloads and would
risk marking an incomplete download done.

Fixes #3897

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:36:39 +09:00
DL Techy 2966ad6ef6 fix(ui): Prevent Enter key from triggering submission on mobile devices (#3970)
- Add check for mobile screen width (<= 768px) to prevent accidental submissions via the Enter key.
- Update event listeners in static/app.js and static/js/chat.js to respect this constraint.
2026-06-15 15:34:24 +09:00
Vishnu d6a3c9a0fe fix(utility): use utility model for background tasks (auto-title, memory audit) instead of chat model (#4027) 2026-06-15 15:33:19 +09:00
adabarbulescu 7ebbc15377 feat: add Sun/Mon week-start setting to calendar (#3875) (#4031)
- Add WEEKDAYS_SUN export to calendar/utils.js for Sun-first column order
- Add localStorage-persisted _weekStartSun state (key: cal-week-start)
- Update _monthRange, _weekRange, _renderMonth, _renderWeek, _renderYear
  to respect the week-start preference
- Add 'Week starts on' toggle (Mon/Sun button chips) in Calendar Settings
- Setting takes effect immediately without closing the settings panel
2026-06-15 15:30:25 +09:00
Ashvin 23837f4571 fix(cookbook): report dead finished downloads as completed instead of stopped (#4025)
When a download's tmux pane is gone, the status endpoint trusted only the
HF-cache probe to tell completed from stopped. The probe derives its cache
root from its own environment, but the download runner exports
HF_HOME=<local_dir> (the #2722 fix), so custom-dir downloads land in
<local_dir>/hub where the probe never looks - and ollama pulls don't touch
the HF cache at all. Finished downloads were reported as stopped forever,
and tasks already persisted as completed were demoted back to stopped on
the next poll. This is the backend half of #3897, deliberately left out of
the frontend fix in #4000.

- honor the conclusive runner markers first: DOWNLOAD_OK -> completed
  (keeping the "Fetching 0 files" error guard), DOWNLOAD_FAILED -> error
- pass the task's local_dir through to the cache probes so they check the
  cache the download actually wrote to, keeping the env-var fallback for
  default-cache downloads
- move the probe scripts and marker classification into
  routes/cookbook_output.py (dependency-free) with behavioral tests

Fixes #4017
2026-06-15 15:26:55 +09:00
Dividesbyzer0 b28aa1f2c4 fix(cookbook): allow local Windows Diffusers serving (#4077) 2026-06-15 15:21:01 +09:00
Dividesbyzer0 33c26bab88 fix(agent): parse raw json web search calls (#4088) 2026-06-15 15:19:38 +09:00
cyq e52d078ea1 fix(agent): detect Polish web lookup intent (#4091) 2026-06-15 15:19:03 +09:00
nsgds 7ae6133d7f fix(agent): don't let a materialized default budget defeat context-window scaling (#4122)
* fix(agent): don't let a materialized default budget defeat context scaling

#1230 scales agent_input_token_budget to the model's context window unless
the user explicitly set a budget, detected via is_setting_overridden(). But
the settings-save path materializes every DEFAULT_SETTINGS key into
settings.json (load_settings merges defaults; handlers persist the merged
dict), so the persisted default 6000 reads as "overridden" and the budget
code takes the min(6000, ctx) branch — silently re-capping long-context
models at 6000 for anyone who has ever saved a setting. This reintroduces
the exact regression #1170/#1230 set out to fix.

Add is_setting_customized() (saved value != default) and gate the scaling
on it instead of mere presence. A persisted default is not a user choice.

is_setting_overridden has exactly one consumer (this budget path), so the
change is contained. Tests cover the materialized-default regression, a
deliberately-chosen budget still being honoured, and the absent-key case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): rework context-budget fix per review (#4122)

Address RaresKeY's review:

P2 (explicitness): is_setting_customized treated a saved value equal to the
default as "not explicit", which ALSO blocked a user from deliberately pinning
the default budget. Reframe the default value itself as the AUTO sentinel —
agent_input_token_budget == DEFAULT_BUDGET means "scale to the model's context
window", any other value is an explicit cap. A materialized default still reads
as auto (fixing the original regression), and any non-default value the user
chooses is now honoured. Drop the now-unused is_setting_customized helper.

P2 (fallback context): auto-scaling trusted get_context_length() even when it
returned only the bare DEFAULT_CONTEXT fallback (no endpoint-reported / known
window), over-allocating on self-hosted/proxy setups. Add get_context_length_known()
(also returns whether the window was actually discovered); the budget block
passes 0 when unknown so auto-scaling stays conservative instead of inflating to
an unproven window.

hard_max stays auto-only — a deliberate explicit budget wins (#1190); kept that
contract and answered the reviewer's question rather than silently reversing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(agent): lock the materialized-default budget regression (review on #4121)

Per WGlynn's review on the issue: add an end-to-end regression that saves an
UNRELATED setting (which makes the settings-save path materialize the budget
default into settings.json) and asserts the budget still auto-scales rather than
re-reading as an explicit 6000 cap — locking the exact reopening shut.

To make the test bite the production decision (not just re-derive it), extract
`budget_is_explicit()` into src/context_budget.py and use it from the agent loop.
It keys off value-vs-default (the default is the auto sentinel), NOT settings
presence — which is the whole point, since the save path materializes defaults.

Note: after this PR's rework, is_setting_overridden has ZERO production callers,
so the merged-dict materialization smell can't reach any setting through a
presence check today (WGlynn's durability concern).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): bind the budget context window to its own provenance (review #4122)

RaresKeY caught a correctness bug in the fallback-context guard: stream_agent_loop
kept only the `known` flag from get_context_length_known() and budgeted off the
passed-in `context_length`, which can come from a *different* lookup. Two failures:
- local endpoints are re-queried, so the passed value can be a stale DEFAULT_CONTEXT
  fallback while the fresh probe proves the real (smaller) served context — we'd
  scale off the stale value;
- callers that don't pass context_length (scheduled tasks, teacher escalation,
  skill test runs, bg_monitor) were capped at 6000 even when a long window is
  discoverable.

Extract budget_context_for_model() which returns the freshly-probed window when
known else 0, binding the flag to the value it proves; the agent loop uses it.
Regression tests cover the stale-fallback, no-arg-caller, and probe-error paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(agent): fix stale budget comments + tighten to the contract (review #4122)

- settings.py: an explicit budget is clamped to the window only — hard_max is
  auto-only (#1190); drop the incorrect "and to hard_max".
- is_setting_overridden docstring: drop the stale "adaptive budgets" example;
  point value-sensitive callers at context_budget.budget_is_explicit.
- Tighten the budget-block comments to the contract (default = auto sentinel,
  non-default = explicit cap, hard_max = auto-only ceiling).

Comment/docstring-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(agent): correct budget issue citations (#1190 → merged #1230/#1273)

The context-budget contract (auto-sentinel, explicit budgets honoured,
hard_max auto-only) merged via #1230#1190 was the earlier, closed,
superseded PR. Re-point the contract comments at #1230 (the live source,
already cited for the auto-sentinel two lines up in settings.py).

The configurable hard_max setting (`agent_input_token_hard_max`) was a
reviewer requirement first raised on #1190, omitted from the merged #1230,
and actually added in #1273 — credit #1273 for it and correct the test
comment's history (it previously implied this PR completed the requirement).

Comment/docstring-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:17:28 +09:00
Dividesbyzer0 589fcd314a fix(image): patch realesrgan torchvision compatibility (#4110) 2026-06-15 15:16:41 +09:00
cyq 5e0cdb6cbb fix(mcp): share oauth redirect URI (#4087) 2026-06-15 15:15:53 +09:00
Max Hsu 039431f5ea fix(mcp): detect npx cache entries before probing (#4034) 2026-06-15 15:14:48 +09:00
cyq aac589ee49 fix(cookbook): diagnose sglang native deps (#4112) 2026-06-15 15:14:37 +09:00
Dividesbyzer0 8cff1f87ee fix(cookbook): stop local Windows process trees
Track the inner Bash runner PID for local Windows Cookbook tasks and stop the full child process tree during cleanup.
2026-06-15 15:12:48 +09:00
Dividesbyzer0 ec4f91afdd fix(cookbook): normalize llama-cpp-python cache types
Map llama-cpp-python --type_k/--type_v cache names to integer enum values after serve-command validation while preserving native llama-server flags.
2026-06-15 15:12:18 +09:00
George R. 268bc1d1a6 docs(readme): document optional uv install workflow
Add an optional uv install and local lockfile workflow to the README while keeping pip as the default documented path.
2026-06-15 15:12:04 +09:00
Dividesbyzer0 7f571c8f7e fix(agent): keep gpt-oss on text tool mode
Treat gpt-oss local OpenAI-compatible models as text/fenced-tool models unless the endpoint explicitly declares native tool support.
2026-06-15 15:11:52 +09:00
cirim 056d1fb960 fix(llm): make connect timeout configurable
Use a configurable LLM_CONNECT_TIMEOUT for call and stream connect budgets instead of the previous hard-coded 3s default.
2026-06-15 15:11:38 +09:00
hemant singh faf27c4a90 feat(chat): confirm before deleting a message
Use the existing styledConfirm modal before destructive chat message deletion so accidental clicks can be cancelled.
2026-06-15 15:11:12 +09:00
Kenny Van de Maele ebbcdc15af fix(governance): drop catch-all CODEOWNERS rule
Remove the repository-wide single-owner CODEOWNERS rule so enabling Code Owner review no longer makes every ordinary PR require the owner personally.
2026-06-15 15:10:37 +09:00
Muhammed Midlaj 4b0a977988 fix(models): probe /v1/models for path-less LM Studio endpoints
Probe /v1/models for path-less OpenAI-compatible model endpoints and surface clearer LM Studio diagnostics with the actual probed URL.
2026-06-15 15:09:50 +09:00
Ichimaki 29180c4731 fix(ui): prevent email reader button label overflow
Remove fixed widths from email reader action buttons so Reply/Forward/AI Reply/Summary labels fit on desktop and mobile.
2026-06-15 15:09:33 +09:00
Boudbois2271 54690997ec fix(calendar): treat same-day list_events range as full day
Expand zero-width or inverted list_events windows to one day so start=end single-day queries return that day's events.
2026-06-15 15:09:19 +09:00
Wes Huber be046dd29a fix(cookbook): preserve state during lifecycle tick
Log malformed cookbook state and re-read fresh state before writing scheduled-stop mutations so concurrent UI changes are preserved.
2026-06-15 15:07:03 +09:00
Dominik Masur 4d070ef4cb docs(research): polish query placeholder text
Tighten the research query placeholder wording.
2026-06-15 15:06:39 +09:00
Catalin Iliescu 59af91cb22 docs: clarify ALLOWED_ORIGINS for proxied deployments
Document ALLOWED_ORIGINS as exact cross-origin client origins and clarify that same-origin reverse-proxy access usually needs no CORS entry.
2026-06-15 15:06:27 +09:00
TimHoogervorst e39c9fbbd5 fix(modalSnap): adjust edge dock stripe z-index
Lower the edge dock resize stripe z-index so it no longer overlays unrelated UI while remaining interactive.
2026-06-15 15:06:14 +09:00
Dividesbyzer0 ece6cebc03 fix(cookbook): create bin dir before llama-server link
Ensure ~/bin exists before the llama.cpp accelerated build script creates the llama-server link.
2026-06-15 15:03:55 +09:00
holden093 4c41834dc7 fix(youtube): consolidate duplicate handler
Make src.youtube_handler a compatibility wrapper around services.youtube.youtube_handler so transcript state, URL parsing, and timeout behavior no longer diverge.
2026-06-15 15:03:41 +09:00
holden093 96052c5e8a fix(agent): add contacts domain to tool classifier
Add a contacts domain rule pack and deterministic contact intent detection so contact prompts surface resolve_contact/manage_contact tools.
2026-06-15 15:03:19 +09:00
adabarbulescu afc81bdd7b fix: drop thinking deltas from background agent loops
Skip thinking-only deltas when accumulating background, scheduled-task, and teacher captured reply text.
2026-06-15 15:03:09 +09:00
osmanakkawi 71ccd59b54 fix(chat): make resend message non-destructive
Keep normal resend from truncating session history while preserving replace-from-here behavior for regenerate flows.
2026-06-15 15:02:48 +09:00
Ashvin b20cea347a fix(hwfit): serve profiles for sub-8192 context models
Allow serve-profile generation for models whose trained context window is below 8192 while preserving the 8K shrink floor for larger models.
2026-06-15 15:02:22 +09:00
Dividesbyzer0 a07fe35936 fix(agent): honor explicit web search requests
Promote explicit web-search phrasing to tool use and keep web_search/web_fetch available for that turn even when the stale web toggle is false.
2026-06-15 15:02:10 +09:00
RaresKeY a7766d0b7f fix(agent): honor auth-disabled tool access after setup
Check explicit auth-disabled mode before configured-admin ownership checks so single-user mode keeps full agent tool access after setup.
2026-06-15 15:01:48 +09:00
nopoz 6824fbb729 fix(gallery): validate upstream result image URLs
Validate image URLs returned by upstream diffusion/OpenAI responses before server-side fetches to prevent SSRF through result image retrieval.
2026-06-15 15:01:28 +09:00
nopoz f14ea6d67d fix(codex): validate stored SSH host and port
Validate cookbook task remoteHost and sshPort values before building SSH shell commands in the Codex bridge.
2026-06-15 15:01:03 +09:00
Tom 59efa8a44b fix(personal): confine remove_directory_from_rag to PERSONAL_DIR
Resolve remove_directory_from_rag paths through the same PERSONAL_DIR confinement helper used by add_directory_to_rag before removal sinks are reached.
2026-06-15 15:00:35 +09:00
Piyush Joshi dbd1e6572f fix(cookbook): resolve Serve button clipping
Allow expanded Serve cards to grow naturally within the Cookbook Serve group so the parent scroll area exposes the Launch and Cancel buttons.
2026-06-15 15:00:22 +09:00
Tom 2857723e47 fix(security): restrict API-key encryption key file to 0o600
Lock the API key encryption key file to owner-only permissions on creation and when reading existing keys, with regression coverage for permissions and encryption roundtrip.
2026-06-15 15:00:11 +09:00
adabarbulescu 011e6b07a5 fix(calendar): prevent invalid same-day timed events
Auto-advance overnight end dates in the calendar form and reject timed events whose end datetime is not after the start datetime.
2026-06-15 14:59:25 +09:00
adabarbulescu 4e0b65491e fix(calendar): align week-view event times with local display time
Use local/display-time helpers for week-view event placement, editing, drag, and resize so timezone-aware events line up with what the user sees.
2026-06-15 14:59:14 +09:00
Michael a633611823 fix(agent): let retrieval run for non-English low-signal queries
Allow non-workspace low-signal prompts to fall through to tool retrieval so non-English requests are not limited to always-available tools.
2026-06-15 14:58:56 +09:00
garrach 6d756215a2 fix: respect user scroll-up in thinking section
Only auto-scroll the live thinking panel while the user is near the bottom, so manual scroll-up is preserved during streaming.
2026-06-15 14:57:59 +09:00
Catalin Iliescu 7dedc51d9f fix(tests): isolate webhook task reference imports
Isolate src.database/src.webhook_manager imports in test_webhook_task_refs so collection does not leak stubbed modules into later tests.
2026-06-15 14:57:47 +09:00
Tom 9fd85f67e8 docs(readme): note Apple Silicon Docker GPU limitation
Clarify in the Docker install section that Apple Silicon Docker cannot use Metal GPU acceleration for Cookbook model serving and point users to the native Apple Silicon path.
2026-06-15 14:54:51 +09:00
els-hub 21ff44e9e8 perf(email): run blocking IMAP routes in threadpool
Fixes #4232

Convert email search and archive handlers from async def to sync def so FastAPI runs their blocking IMAP I/O in the threadpool instead of the event loop.
2026-06-15 14:54:13 +09:00
nickorlabs 2e99825a29 chore: align secrets env ignore patterns
Align git and Docker ignore patterns for secrets.env artifacts while preserving the intended encrypted-file workflow.
2026-06-15 14:49:46 +09:00
173 changed files with 8011 additions and 807 deletions
+6
View File
@@ -10,6 +10,12 @@ dist/
build/ build/
.env .env
.env.bak.* .env.bak.*
# Secrets: keep plaintext and every transient secrets.env variant out of
# the build context. If an encrypted secrets.env is used, it is mounted
# at runtime — never baked into the image. Mirrored in .gitignore.
secrets.env
secrets.env.*
!secrets.env.example
/data/ /data/
/logs/ /logs/
.git/ .git/
+7
View File
@@ -190,3 +190,10 @@ SEARXNG_INSTANCE=http://localhost:8080
# These overlays only expose the GPU devices. The slim Odysseus image # These overlays only expose the GPU devices. The slim Odysseus image
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM, # still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
# llama-cpp-python, etc.) before models can actually serve on GPU. # llama-cpp-python, etc.) before models can actually serve on GPU.
# ============================================================
# Storage Paths (Docker Compose)
# ============================================================
# APP_DATA_DIR=./data
# APP_LOGS_DIR=./logs
+7 -6
View File
@@ -1,8 +1,9 @@
# Code owners. # Code owners.
# #
# Every file is owned by the maintainer, so that when branch protection has # Intentionally empty for now. The catch-all rule that mapped every path to a
# "Require review from Code Owners" turned on, no pull request can be merged # single owner froze all merges the moment "Require review from Code Owners"
# without the maintainer's review. This is the human gate that backs up the # was enabled, because no other maintainer's approval could satisfy the gate.
# automated security checks. See docs/security-ci.md for how to turn it on. # A per-area ownership map (security/auth, CI, frontend, agent internals, with
# multiple named owners per line) is being worked out in issue #593; once
* @pewdiepie-archdaemon # agreed it replaces this file. Until then, required reviews and the security
# CI gate (docs/security-ci.md) remain in force via branch protection.
+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) name: Python syntax (compileall)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.11" python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps. # Byte-compile sources — catches syntax errors without installing deps.
@@ -32,10 +32,10 @@ jobs:
name: JS syntax (node --check) name: JS syntax (node --check)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: "20" node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib). # 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. # ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -81,7 +81,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi fi
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
with: with:
python-version: "3.11" python-version: "3.11"
+3 -3
View File
@@ -45,17 +45,17 @@ jobs:
language: [python, javascript-typescript] language: [python, javascript-typescript]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: none build-mode: none
- name: Perform CodeQL analysis - name: Perform CodeQL analysis
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
+3 -3
View File
@@ -52,7 +52,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
security-events: write # upload SARIF to the Security tab security-events: write # upload SARIF to the Security tab
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
@@ -119,7 +119,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results - 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: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-image category: trivy-image
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
@@ -55,7 +55,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
# Full history so a secret committed in an earlier commit (and later # Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git. # deleted) is still caught -- deletion does not remove it from Git.
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
persist-credentials: false persist-credentials: false
+12
View File
@@ -14,6 +14,15 @@ venv/
.env .env
.env.bak.* .env.bak.*
!.env.example !.env.example
# Local uv lockfile (optional, per-platform — see "Faster installs with uv" in README)
requirements.lock
# SOPS workflow — encrypted `secrets.env` is intentionally committable,
# but every variant (plaintext, manual decrypt copy, editor backup)
# must stay out of git. Mirrored in .dockerignore so the same artifacts
# also cannot enter image build layers.
secrets.env.*
!secrets.env.example
# Data — all user data stays local # Data — all user data stays local
data/ data/
@@ -61,6 +70,9 @@ output.txt.txt
*.tiff *.tiff
*.pdf *.pdf
# …except shipped static assets
!static/icons/*.png
# …except shipped demo assets in docs/ that the README links to. # …except shipped demo assets in docs/ that the README links to.
!docs/*.jpg !docs/*.jpg
!docs/*.jpeg !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. # System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes, # openssh-client is required for Cookbook remote server tests, setup, probes,
+32
View File
@@ -12,6 +12,8 @@
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. 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.
[![Packaging status](https://repology.org/badge/vertical-allrepos/odysseus-ai.svg)](https://repology.org/project/odysseus-ai/versions)
## Features ## 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> - **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> - **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>
@@ -73,6 +75,10 @@ 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` `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. 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 ### Native Linux / macOS
```bash ```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git git clone https://github.com/pewdiepie-archdaemon/odysseus.git
@@ -333,6 +339,25 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) | | `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). | | `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 ### Outlook / Office 365 email
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
@@ -364,6 +389,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
4. Keep raw service and model ports internal-only. 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`. 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: Common internal-only ports from the default docs/compose setup:
@@ -395,8 +421,11 @@ Key settings:
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. | | `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_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_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 | | `AUTH_ENABLED` | `true` | Enable/disable login |
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. | | `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. | | `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 | | `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. | | `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
@@ -440,6 +469,9 @@ docs/ landing page (index.html) + preview clips
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents), All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`. `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).
## Star History ## Star History
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left"> <a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left">
+31 -3
View File
@@ -69,10 +69,37 @@ from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_imag
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
# ========= LOGGING ========= # ========= LOGGING =========
logging.basicConfig( import logging.handlers
level=logging.INFO, from core.constants import DATA_DIR
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
_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__) logger = logging.getLogger(__name__)
# ========= APP ========= # ========= APP =========
@@ -140,6 +167,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
"/api/cookbook/setup", # remote pacman/apt installs "/api/cookbook/setup", # remote pacman/apt installs
"/api/upload", # large files "/api/upload", # large files
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout "/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. Config stored in data/auth.json. Uses bcrypt directly.
""" """
import enum
import json import json
import os import os
import secrets 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")) 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: class AuthManager:
"""Manages multi-user password + session-token auth system.""" """Manages multi-user password + session-token auth system."""
@@ -387,6 +397,69 @@ class AuthManager:
logger.info(f"Updated privileges for '{username}': {current}") logger.info(f"Updated privileges for '{username}': {current}")
return True 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: def change_password(self, username: str, current_password: str, new_password: str) -> bool:
username = username.strip().lower() username = username.strip().lower()
if username not in self.users: 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 # NULL for local calendars and for CalDAV calendars created before
# multi-account support was added (treated as "use any configured account"). # multi-account support was added (treated as "use any configured account").
account_id = Column(String, nullable=True, index=True) 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") 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 # 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. # a UI event whose write-back failed) and must NOT be pruned by the sync.
origin = Column(String, nullable=True, index=True) 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") 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): class Integration(TimestampMixin, Base):
"""An external service connection (email, RSS, webhook, etc.).""" """An external service connection (email, RSS, webhook, etc.)."""
__tablename__ = "integrations" __tablename__ = "integrations"
@@ -1767,6 +1785,7 @@ def init_db():
_migrate_add_calendar_is_utc() _migrate_add_calendar_is_utc()
_migrate_add_calendar_origin() _migrate_add_calendar_origin()
_migrate_add_calendar_account_id() _migrate_add_calendar_account_id()
_migrate_add_caldav_sync_columns()
_migrate_chat_messages_fts() _migrate_chat_messages_fts()
_migrate_encrypt_email_passwords() _migrate_encrypt_email_passwords()
_migrate_encrypt_signatures() _migrate_encrypt_signatures()
@@ -2067,6 +2086,31 @@ def _migrate_add_calendar_account_id():
pass 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(): def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table.""" """Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3 import sqlite3
+5 -5
View File
@@ -16,18 +16,18 @@ services:
ports: ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes: volumes:
- ./data:/app/data:z - ${APP_DATA_DIR:-./data}:/app/data:z
- ./logs:/app/logs:z - ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here; # Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys. # 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 # Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface. # 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.) # Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a # land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines. # container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z - ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts: extra_hosts:
# Lets the container reach local services on the Docker host, including # Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434. # Ollama at http://host.docker.internal:11434.
+5 -5
View File
@@ -15,18 +15,18 @@ services:
ports: ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes: volumes:
- ./data:/app/data:z - ${APP_DATA_DIR:-./data}:/app/data:z
- ./logs:/app/logs:z - ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here; # Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys. # 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 # Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface. # 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.) # Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a # land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines. # container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z - ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts: extra_hosts:
# Lets the container reach local services on the Docker host, including # Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434. # Ollama at http://host.docker.internal:11434.
+5 -5
View File
@@ -4,18 +4,18 @@ services:
ports: ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes: volumes:
- ./data:/app/data:z - ${APP_DATA_DIR:-./data}:/app/data:z
- ./logs:/app/logs:z - ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here; # Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys. # 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 # Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface. # 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.) # Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a # land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines. # container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z - ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts: extra_hosts:
# Lets the container reach local services on the Docker host, including # Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434. # 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.
+14 -1
View File
@@ -141,7 +141,20 @@ if (-not (Find-GitBash)) {
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow 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-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
Write-Host "Press Ctrl+C to stop." Write-Host "Press Ctrl+C to stop."
Write-Host "" Write-Host ""
+2 -3
View File
@@ -93,16 +93,15 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if category_filter: if category_filter:
msg += f" in category '{category_filter}'" msg += f" in category '{category_filter}'"
return [TextContent(type="text", text=msg + ".")] return [TextContent(type="text", text=msg + ".")]
lines = [f"Found {len(memories)} memory entries:\n"] lines = [f"Found {len(memories)} memory entries:\n"]
for m in memories[:100]: for m in memories:
cat = m.get("category", "fact") cat = m.get("category", "fact")
mid = m.get("id", "?")[:8] mid = m.get("id", "?")[:8]
text = m.get("text", "") text = m.get("text", "")
if len(text) > 150: if len(text) > 150:
text = text[:150] + "..." text = text[:150] + "..."
lines.append(f"- [{cat}] `{mid}` — {text}") 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))] return [TextContent(type="text", text="\n".join(lines))]
elif action == "add": elif action == "add":
+12 -9
View File
@@ -5,16 +5,16 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.98.0" "@anthropic-ai/sdk": "^0.104.1"
}, },
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.3.2" "@antithesishq/bombadil": "^0.5.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
"version": "0.98.0", "version": "0.104.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
"integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", "integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
@@ -33,11 +33,14 @@
} }
}, },
"node_modules/@antithesishq/bombadil": { "node_modules/@antithesishq/bombadil": {
"version": "0.3.2", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz",
"integrity": "sha512-ATy1w9ZY5gbny1H8DFc7rxZitT7DLLLFDiGcRZe+8TQiUrV5tLO+IJGOVNNLp3RpCqjZqSsxGiKoQsx31ipV1g==", "integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"bin": {
"bombadil": "bin/bombadil.js"
}
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.29.7", "version": "7.29.7",
+2 -2
View File
@@ -4,9 +4,9 @@
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git" "url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
}, },
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.3.2" "@antithesishq/bombadil": "^0.5.0"
}, },
"dependencies": { "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 # 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 # [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
# the dependency-age discussion in issue #485. # 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-multipart
python-dotenv python-dotenv
httpx httpx
pydantic>=2.0 pydantic>=2.13.4
pydantic-settings>=2.0 pydantic-settings>=2.14.1
SQLAlchemy SQLAlchemy
pypdf pypdf
beautifulsoup4 beautifulsoup4
+31 -1
View File
@@ -12,7 +12,7 @@ import re
from pathlib import Path from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text 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.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
from src.rate_limiter import RateLimiter from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings from src.settings_scrub import scrub_settings
@@ -73,6 +73,11 @@ class DeleteUserRequest(BaseModel):
class RenameUserRequest(BaseModel): class RenameUserRequest(BaseModel):
username: str username: str
class SetAdminRequest(BaseModel):
is_admin: bool
class SetOpenRegistrationRequest(BaseModel): class SetOpenRegistrationRequest(BaseModel):
enabled: bool enabled: bool
@@ -487,6 +492,31 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
invalidator() invalidator()
return {"ok": True, "username": new_username, "renamed_self": old_username == user} 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) @router.post("/signup-toggle", deprecated=True)
async def toggle_signup(request: Request): async def toggle_signup(request: Request):
""" """
+65 -29
View File
@@ -11,7 +11,7 @@ from pydantic import BaseModel
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_
from dateutil.rrule import rrulestr 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.auth_helpers import require_user
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES 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 ::") raise ValueError("malformed compound UID: missing base before ::")
return base 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 ── # ── Pydantic models ──
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -843,13 +891,13 @@ def setup_calendar_routes() -> APIRouter:
return {"ok": False, "error": str(e)[:200]} return {"ok": False, "error": str(e)[:200]}
@router.post("/sync") @router.post("/sync")
async def sync_caldav_endpoint(request: Request): async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
"""Pull events from the configured CalDAV server into local DB. """Sync events with the configured CalDAV server.
Returns counts + any per-calendar errors. Called by the frontend Returns counts + any per-calendar errors. Called by the frontend
on calendar open and by the periodic scheduler loop.""" on calendar open and by the periodic scheduler loop."""
owner = _require_user(request) owner = _require_user(request)
from src.caldav_sync import sync_caldav from src.caldav_sync import sync_caldav_direction
return await sync_caldav(owner) return await sync_caldav_direction(owner, direction)
@router.delete("/calendars/{cal_id}") @router.delete("/calendars/{cal_id}")
@@ -1002,19 +1050,12 @@ def setup_calendar_routes() -> APIRouter:
is_utc=_is_utc and not data.all_day, is_utc=_is_utc and not data.all_day,
rrule=data.rrule or "", rrule=data.rrule or "",
color=data.color or None, color=data.color or None,
caldav_sync_pending="create" if cal.source == "caldav" else None,
) )
db.add(ev) db.add(ev)
db.commit() db.commit()
if cal.source == "caldav": if cal.source == "caldav":
# Push the new event to the remote so it appears on the user's await _push_caldav_event_after_commit(owner, uid, "create")
# 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 "",
})
return {"ok": True, "uid": uid} return {"ok": True, "uid": uid}
except HTTPException: except HTTPException:
raise raise
@@ -1060,15 +1101,12 @@ def setup_calendar_routes() -> APIRouter:
ev.rrule = data.rrule ev.rrule = data.rrule
if data.color is not None: if data.color is not None:
ev.color = data.color if data.color else 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() db.commit()
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first() if is_caldav:
if cal and cal.source == "caldav": await _push_caldav_event_after_commit(owner, base_uid, "update")
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 "",
})
return {"ok": True} return {"ok": True}
except HTTPException: except HTTPException:
raise raise
@@ -1089,15 +1127,13 @@ def setup_calendar_routes() -> APIRouter:
db = SessionLocal() db = SessionLocal()
try: try:
ev = _get_or_404_event(db, base_uid, owner) ev = _get_or_404_event(db, base_uid, owner)
# Capture what the remote push needs BEFORE the row is gone. is_caldav = ev.calendar and ev.calendar.source == "caldav"
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first() if is_caldav:
_is_caldav = bool(_cal and _cal.source == "caldav") _record_caldav_delete_tombstone(db, ev, owner)
_cal_id, _ev_uid = ev.calendar_id, ev.uid
db.delete(ev) db.delete(ev)
db.commit() db.commit()
if _is_caldav: if is_caldav:
from src.caldav_writeback import writeback_event await _push_caldav_event_after_commit(owner, base_uid, "delete")
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
return {"ok": True} return {"ok": True}
except HTTPException: except HTTPException:
raise raise
+44 -5
View File
@@ -159,9 +159,17 @@ async def auto_name_session(session_manager, sess):
return return
owner = getattr(sess, "owner", None) owner = getattr(sess, "owner", None)
t_url, t_model, t_headers = resolve_task_endpoint( t_url, t_model, t_headers = resolve_task_endpoint(owner=owner)
sess.endpoint_url, sess.model, sess.headers, owner=owner, if not t_model:
) # If no task/utility model is configured at all, fall back to
# the session's own model so auto-naming still works even on
# minimal setups.
from src.endpoint_resolver import resolve_endpoint
_fallback = resolve_endpoint("default", owner=owner)
if _fallback and _fallback[1]:
t_url, t_model, t_headers = _fallback
else:
t_url, t_model, t_headers = sess.endpoint_url, sess.model, sess.headers
if not t_model: if not t_model:
logger.debug("[auto-name] No model provided, skipping") logger.debug("[auto-name] No model provided, skipping")
return return
@@ -497,6 +505,29 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
return None 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( async def build_chat_context(
sess, sess,
request, request,
@@ -562,9 +593,17 @@ async def build_chat_context(
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"), 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?
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True 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 use_rag_val = False
# If pre-fetched search context was provided (compare mode), skip live web search # If pre-fetched search context was provided (compare mode), skip live web search
@@ -587,7 +626,7 @@ async def build_chat_context(
incognito=incognito, incognito=incognito,
use_skills=skills_enabled, 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_kwargs["use_rag"] = use_rag_val
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs) preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
+6 -1
View File
@@ -696,7 +696,12 @@ def setup_chat_routes(
# by default without having to send allow_bash in every request. # by default without having to send allow_bash in every request.
if allow_bash is not None and str(allow_bash).lower() != "true": if allow_bash is not None and str(allow_bash).lower() != "true":
disabled_tools.add("bash") disabled_tools.add("bash")
if allow_web_search is not None and str(allow_web_search).lower() != "true": _explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
if (
allow_web_search is not None
and str(allow_web_search).lower() != "true"
and not _explicit_web_intent
):
disabled_tools.add("web_search") disabled_tools.add("web_search")
disabled_tools.add("web_fetch") disabled_tools.add("web_fetch")
+18 -6
View File
@@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse
from src.auth_helpers import require_authenticated_request, require_user from src.auth_helpers import require_authenticated_request, require_user
from src.tool_implementations import do_manage_notes from src.tool_implementations import do_manage_notes
from src.constants import COOKBOOK_STATE_FILE from src.constants import COOKBOOK_STATE_FILE
from routes._validators import validate_remote_host, validate_ssh_port
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"} COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
@@ -36,6 +37,21 @@ DOCS_WRITE_SCOPES = {"documents:write"}
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"} WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
def _ssh_prefix_for_task(task: dict) -> tuple[str, str]:
"""Resolve a cookbook task's stored SSH target into ``(host, port_flag)``.
``host`` is ``""`` for a local task. ``remoteHost`` / ``sshPort`` come from
cookbook_state.json and get interpolated into an ``ssh`` command string, so
validate them the same way the cookbook routes do. A tampered entry with
shell metacharacters in ``remoteHost`` is rejected with 400 rather than
injected.
"""
host = validate_remote_host((task.get("remoteHost") or "").strip() or None) or ""
ssh_port = validate_ssh_port((task.get("sshPort") or "").strip() or None) or ""
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
return host, port_flag
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs): async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
"""Run an existing route handler with request.state.current_user temporarily """Run an existing route handler with request.state.current_user temporarily
set to ``owner`` so its internal get_current_user/require_user calls see set to ``owner`` so its internal get_current_user/require_user calls see
@@ -486,8 +502,7 @@ def setup_codex_routes(
task = next((t for t in tasks if t.get("sessionId") == session_id), None) task = next((t for t in tasks if t.get("sessionId") == session_id), None)
if task is None: if task is None:
raise HTTPException(404, "task not found") raise HTTPException(404, "task not found")
host = (task.get("remoteHost") or "").strip() host, port_flag = _ssh_prefix_for_task(task)
ssh_port = (task.get("sshPort") or "").strip()
# Prefer the persisted log file over the tmux pane. The pane gets # Prefer the persisted log file over the tmux pane. The pane gets
# overwritten by the post-crash neofetch banner + bash prompt the # overwritten by the post-crash neofetch banner + bash prompt the
# moment vllm exits; the log file is the raw stdout/stderr and # moment vllm exits; the log file is the raw stdout/stderr and
@@ -499,7 +514,6 @@ def setup_codex_routes(
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi" f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
) )
if host: if host:
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
import shlex import shlex
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}" cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
else: else:
@@ -561,10 +575,8 @@ def setup_codex_routes(
state = _read_cookbook_state() state = _read_cookbook_state()
tasks = state.get("tasks") or [] tasks = state.get("tasks") or []
task = next((t for t in tasks if t.get("sessionId") == session_id), None) task = next((t for t in tasks if t.get("sessionId") == session_id), None)
host = ((task or {}).get("remoteHost") or "").strip() host, port_flag = _ssh_prefix_for_task(task or {})
ssh_port = ((task or {}).get("sshPort") or "").strip()
if host: if host:
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\"" cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
else: else:
cmd = f"tmux kill-session -t {session_id}" cmd = f"tmux kill-session -t {session_id}"
+10 -2
View File
@@ -45,10 +45,14 @@ def _save_settings(settings):
def _get_carddav_config(): def _get_carddav_config():
import os import os
settings = _load_settings() 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 { return {
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")), "url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")), "username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
"password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")), "password": password,
} }
@@ -785,7 +789,11 @@ def setup_contacts_routes():
except ValueError as e: except ValueError as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
else: 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) _save_settings(settings)
# Force re-fetch # Force re-fetch
_contact_cache["fetched_at"] = None _contact_cache["fetched_at"] = None
+63 -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)"', ' 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', ' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
'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 "$@"; }', 'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }',
] ]
@@ -573,6 +578,36 @@ _GGUF_PRELUDE_RE = re.compile(
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)") _OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$") _OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$") _OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
_LLAMA_CPP_PYTHON_GGML_TYPES = {
"f32": "0",
"f16": "1",
"q4_0": "2",
"q4_1": "3",
"q5_0": "6",
"q5_1": "7",
"q8_0": "8",
"q8_1": "9",
"q2_k": "10",
"q3_k": "11",
"q4_k": "12",
"q5_k": "13",
"q6_k": "14",
"q8_k": "15",
"iq2_xxs": "16",
"iq2_xs": "17",
"iq3_xxs": "18",
"iq1_s": "19",
"iq4_nl": "20",
"iq3_s": "21",
"iq2_s": "22",
"iq4_xs": "23",
"mxfp4": "39",
"nvfp4": "40",
"q1_0": "41",
}
_LLAMA_CPP_PYTHON_TYPE_FLAG_RE = re.compile(
r"(?P<flag>--type_[kv])(?P<sep>\s+|=)(?P<quote>['\"]?)(?P<value>[A-Za-z0-9_]+)(?P=quote)"
)
def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]: def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]:
@@ -604,6 +639,22 @@ def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -
return f"[{host}]" if bracketed_host else host, port return f"[{host}]" if bracketed_host else host, port
def _normalize_llama_cpp_python_cache_types(cmd: str | None) -> str | None:
"""Map llama.cpp KV cache type names to llama-cpp-python's integer enum."""
if not cmd or "llama_cpp.server" not in cmd:
return cmd
def repl(match: re.Match[str]) -> str:
value = match.group("value")
mapped = _LLAMA_CPP_PYTHON_GGML_TYPES.get(value.lower())
if not mapped:
return match.group(0)
quote = match.group("quote")
return f"{match.group('flag')}{match.group('sep')}{quote}{mapped}{quote}"
return _LLAMA_CPP_PYTHON_TYPE_FLAG_RE.sub(repl, cmd)
def _check_serve_binary(seg: str) -> None: def _check_serve_binary(seg: str) -> None:
"""Validate that a single command segment starts with an allowlisted binary """Validate that a single command segment starts with an allowlisted binary
(after skipping leading env-var assignments like `CUDA_VISIBLE_DEVICES=0`).""" (after skipping leading env-var assignments like `CUDA_VISIBLE_DEVICES=0`)."""
@@ -742,6 +793,7 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
runner_lines.append(' done') runner_lines.append(' done')
# rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA # rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA
# or HIP attempt) doesn't cause the next configure to reuse stale settings. # or HIP attempt) doesn't cause the next configure to reuse stale settings.
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cd ~/llama.cpp && rm -rf build') runner_lines.append(' cd ~/llama.cpp && rm -rf build')
runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then') runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then')
runner_lines.append(' if command -v hipconfig &>/dev/null; then') runner_lines.append(' if command -v hipconfig &>/dev/null; then')
@@ -1046,6 +1098,16 @@ def _diagnose_serve_output(text: str) -> dict | None:
"vLLM is not installed or not in PATH on this server.", "vLLM is not installed or not in PATH on this server.",
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}], [{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
), ),
(
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
r"Please ensure sgl_kernel is properly installed",
"SGLang native dependencies are missing on this server.",
[
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
],
),
( (
r"sglang.*command not found|No module named sglang|SGLang is not installed", r"sglang.*command not found|No module named sglang|SGLang is not installed",
"SGLang is not installed or not in PATH on this server.", "SGLang is not installed or not in PATH on this server.",
+56
View File
@@ -4,6 +4,62 @@ Kept dependency-free (no FastAPI / SQLAlchemy imports) so the behavior can be
unit-tested without standing up the whole app. unit-tested without standing up the whole app.
""" """
import re
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# Probe scripts for the dead-session download check, run as
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
# cache_root is the task's custom download dir, '' for the default HF cache.
# It has to be passed explicitly: the download runner exports
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
# the probe process's own environment knows nothing about it.
HF_CACHE_COMPLETE_PROBE = (
"import os,sys;"
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"snap=os.path.join(d,'snapshots');"
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
HF_CACHE_INCOMPLETE_PROBE = (
"import os,sys;"
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)"
)
def classify_dead_download(full_snapshot: str):
"""Resolve a dead download session's status from its runner markers.
The runner prints DOWNLOAD_OK only after exiting 0 (and DOWNLOAD_FAILED
otherwise), so the markers stay trustworthy after the tmux pane is gone.
Returns (status, zero_files), or None when the snapshot carries no marker
and the caller has to fall back to the cache probe. Same precedence as
the live-session branch: DOWNLOAD_OK wins, except a "Fetching 0 files"
run is an error (nothing matched the include/quant pattern).
"""
if not full_snapshot:
return None
if "DOWNLOAD_OK" in full_snapshot:
if _FETCHING_ZERO_FILES_RE.search(full_snapshot):
return ("error", True)
return ("completed", False)
if "DOWNLOAD_FAILED" in full_snapshot:
return ("error", False)
return None
def error_aware_output_tail(full_snapshot: str, status: str) -> str: def error_aware_output_tail(full_snapshot: str, status: str) -> str:
"""Return the trailing slice of a task log for the status response. """Return the trailing slice of a task log for the status response.
+41 -33
View File
@@ -30,7 +30,10 @@ from core.platform_compat import (
which_tool, which_tool,
) )
from routes.shell_routes import TMUX_LOG_DIR from routes.shell_routes import TMUX_LOG_DIR
from routes.cookbook_output import error_aware_output_tail from routes.cookbook_output import (
error_aware_output_tail, classify_dead_download,
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,6 +49,7 @@ from routes.cookbook_helpers import (
_diagnose_serve_output, run_ssh_command_async, _diagnose_serve_output, run_ssh_command_async,
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache, _ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_normalize_llama_cpp_python_cache_types,
ModelDownloadRequest, ServeRequest, ModelDownloadRequest, ServeRequest,
) )
@@ -54,7 +58,7 @@ _HF_TOKEN_STATUS_SNIPPET = (
'echo "[odysseus] HF token: applied"; ' 'echo "[odysseus] HF token: applied"; '
'else ' 'else '
'echo "[odysseus] HF token: NOT SET — gated/private models will be denied. ' 'echo "[odysseus] HF token: NOT SET — gated/private models will be denied. '
'Add one in Odysseus Settings -> Cookbook -> HuggingFace Token."; ' 'Add one in Odysseus Cookbook -> Settings -> HuggingFace Token."; '
'fi' 'fi'
) )
@@ -170,6 +174,16 @@ def setup_cookbook_routes() -> APIRouter:
"vLLM is not installed or not in PATH on this server.", "vLLM is not installed or not in PATH on this server.",
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}], [{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
), ),
(
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
r"Please ensure sgl_kernel is properly installed",
"SGLang native dependencies are missing on this server.",
[
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
],
),
( (
r"sglang.*command not found|No module named sglang|SGLang is not installed", r"sglang.*command not found|No module named sglang|SGLang is not installed",
"SGLang is not installed or not in PATH on this server.", "SGLang is not installed or not in PATH on this server.",
@@ -353,7 +367,11 @@ def setup_cookbook_routes() -> APIRouter:
# all output to the log the poller reads. Paths handed to bash use # all output to the log the poller reads. Paths handed to bash use
# POSIX form + shell-quoting so drive paths / spaces survive. # POSIX form + shell-quoting so drive paths / spaces survive.
inner = TMUX_LOG_DIR / f"{session_id}_run.sh" inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
inner.write_text("\n".join(bash_lines) + "\n", encoding="utf-8") pp = shlex.quote(pid_path.as_posix())
inner.write_text(
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
encoding="utf-8",
)
lp = shlex.quote(log_path.as_posix()) lp = shlex.quote(log_path.as_posix())
ip = shlex.quote(inner.as_posix()) ip = shlex.quote(inner.as_posix())
script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path = TMUX_LOG_DIR / f"{session_id}.sh"
@@ -1211,6 +1229,7 @@ def setup_cookbook_routes() -> APIRouter:
# many downstream `"engine" in req.cmd` membership checks can't hit # many downstream `"engine" in req.cmd` membership checks can't hit
# `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400). # `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400).
req.cmd = _validate_serve_cmd(req.cmd) or "" req.cmd = _validate_serve_cmd(req.cmd) or ""
req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or ""
req.cmd = _venv_safe_local_pip_install_cmd( req.cmd = _venv_safe_local_pip_install_cmd(
req.cmd, req.cmd,
local=not bool(req.remote_host), local=not bool(req.remote_host),
@@ -2620,30 +2639,20 @@ def setup_cookbook_routes() -> APIRouter:
def _cookbook_tasks_status_sync(): def _cookbook_tasks_status_sync():
import subprocess import subprocess
def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool: def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "", cache_root: str = "") -> bool:
"""Best-effort check for a completed HF cache entry. """Best-effort check for a completed HF cache entry.
tmux output can stop at a stale progress line if the pane/session tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker. disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files In that case, trust the cache shape: a snapshot directory with files
and no *.incomplete blobs means HuggingFace finished materializing the and no *.incomplete blobs means HuggingFace finished materializing the
model. model. cache_root is the task's custom download dir — the runner
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
not wherever this probe's environment says.
""" """
if not repo_id or "/" not in repo_id: if not repo_id or "/" not in repo_id:
return False return False
py = ( cmd = ["python3", "-c", HF_CACHE_COMPLETE_PROBE, repo_id, cache_root or ""]
"import os,sys;"
"repo=sys.argv[1];"
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"snap=os.path.join(d,'snapshots');"
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
cmd = ["python3", "-c", py, repo_id]
try: try:
if remote_host: if remote_host:
ssh_base = ["ssh"] ssh_base = ["ssh"]
@@ -2657,7 +2666,7 @@ def setup_cookbook_routes() -> APIRouter:
except Exception: except Exception:
return False return False
def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool: def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "", cache_root: str = "") -> bool:
"""Best-effort check for resumable HF partial blobs. """Best-effort check for resumable HF partial blobs.
A lost SSH/tmux session can leave a real download still incomplete. A lost SSH/tmux session can leave a real download still incomplete.
@@ -2666,16 +2675,7 @@ def setup_cookbook_routes() -> APIRouter:
""" """
if not repo_id or "/" not in repo_id: if not repo_id or "/" not in repo_id:
return False return False
py = ( cmd = ["python3", "-c", HF_CACHE_INCOMPLETE_PROBE, repo_id, cache_root or ""]
"import os,sys;"
"repo=sys.argv[1];"
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)"
)
cmd = ["python3", "-c", py, repo_id]
try: try:
if remote_host: if remote_host:
ssh_base = ["ssh"] ssh_base = ["ssh"]
@@ -2880,7 +2880,7 @@ def setup_cookbook_routes() -> APIRouter:
and ( and (
".incomplete" in full_snapshot ".incomplete" in full_snapshot
or bool(re.search(r'model-\d+-of-\d+\.[A-Za-z0-9_.-]+:\s+(?:[0-9]|[1-8][0-9])%', full_snapshot)) or bool(re.search(r'model-\d+-of-\d+\.[A-Za-z0-9_.-]+:\s+(?:[0-9]|[1-8][0-9])%', full_snapshot))
or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or "")) or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or ""), _payload.get("local_dir") or "")
) )
) )
if is_alive or (local_win_task and full_snapshot): if is_alive or (local_win_task and full_snapshot):
@@ -2921,11 +2921,19 @@ def setup_cookbook_routes() -> APIRouter:
else: else:
status = "running" status = "running"
else: else:
# Session is dead — check if it completed or crashed # Session is dead — check if it completed or crashed. The
if ( # runner markers in the retained output are conclusive
# (DOWNLOAD_OK only prints after exit 0), so check them before
# the cache probe, which can't see ollama pulls at all.
marker = classify_dead_download(full_snapshot) if task_type == "download" else None
if marker is not None:
status, download_zero_files = marker
if status == "completed" and not progress_text:
progress_text = "Download complete"
elif (
task_type == "download" task_type == "download"
and not download_has_incomplete_evidence and not download_has_incomplete_evidence
and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")) and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or ""), _payload.get("local_dir") or "")
): ):
status = "completed" status = "completed"
if not progress_text: if not progress_text:
+26 -1
View File
@@ -1,12 +1,13 @@
"""Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research.""" """Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research."""
import logging import logging
import os
from typing import Dict, Any from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Form, Request from fastapi import APIRouter, HTTPException, Form, Request
from services.youtube.youtube_handler import extract_youtube_id, extract_transcript_async 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 from core.middleware import require_admin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,6 +29,30 @@ def setup_diagnostics_routes(
from src.service_health import collect_service_health from src.service_health import collect_service_health
return await collect_service_health(rag_manager, memory_vector) 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") @router.get("/api/db/stats")
async def get_database_stats(request: Request) -> Dict[str, Any]: async def get_database_stats(request: Request) -> Dict[str, Any]:
require_admin(request) require_admin(request)
+7 -2
View File
@@ -1087,7 +1087,10 @@ def setup_email_routes():
return {"contacts": [], "error": "Mail operation failed"} return {"contacts": [], "error": "Mail operation failed"}
@router.get("/search") @router.get("/search")
async def search_emails( # Sync def: the body is blocking IMAP I/O with no awaits. As `async def` it ran
# directly on the event loop and stalled the whole app during a search; as a sync
# def FastAPI runs it in a threadpool, keeping the loop responsive.
def search_emails(
q: str = Query(""), q: str = Query(""),
folder: str = Query("INBOX"), folder: str = Query("INBOX"),
limit: int = Query(50), limit: int = Query(50),
@@ -1736,7 +1739,9 @@ def setup_email_routes():
return {"success": False, "error": "Mail operation failed"} return {"success": False, "error": "Mail operation failed"}
@router.post("/archive/{uid}") @router.post("/archive/{uid}")
async def archive_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)): # Sync def: blocking IMAP I/O with no awaits — see search_emails above. Runs in a
# threadpool instead of blocking the event loop.
def archive_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
"""Move email to Archive folder.""" """Move email to Archive folder."""
try: try:
with _imap(account_id, owner=owner) as conn: with _imap(account_id, owner=owner) as conn:
+47 -14
View File
@@ -19,6 +19,7 @@ from src.upload_limits import (
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES, GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
) )
from src.constants import GENERATED_IMAGES_DIR from src.constants import GENERATED_IMAGES_DIR
from src.optional_deps import patch_realesrgan_torchvision_compat
from routes.gallery_helpers import ( from routes.gallery_helpers import (
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size, GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
@@ -108,6 +109,32 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None):
return fallback return fallback
async def _fetch_result_image_b64(url: str) -> Optional[str]:
"""Fetch an image URL returned in an upstream response body, base64-encoded
(or None on a non-200).
The URL comes from the diffusion/OpenAI server's response, not from our own
config, so a malicious or compromised endpoint could otherwise steer this
fetch at an internal or cloud-metadata address. Validate it the same way the
client-supplied endpoint is validated before the first request.
"""
import base64
import httpx
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
url,
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
async with httpx.AsyncClient(timeout=60) as c2:
ir = await c2.get(url)
if ir.status_code == 200:
return base64.b64encode(ir.content).decode()
return None
def setup_gallery_routes() -> APIRouter: def setup_gallery_routes() -> APIRouter:
router = APIRouter(tags=["gallery"]) router = APIRouter(tags=["gallery"])
@@ -904,14 +931,22 @@ def setup_gallery_routes() -> APIRouter:
raise HTTPException(404, "Image not found") raise HTTPException(404, "Image not found")
img_filename = img.filename img_filename = img.filename
# Remove the file from disk # 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) img_path = _gallery_image_path(img_filename)
if img_path.exists(): if img_path.exists():
img_path.unlink() img_path.unlink()
except Exception as e:
# Soft-delete the record logger.warning(f"Could not remove gallery image file for {img_filename}: {e}")
img.is_active = False
db.commit()
# Strip stale chat-history references so the image bubble # Strip stale chat-history references so the image bubble
# (and its prompt caption) doesn't come back after a server # (and its prompt caption) doesn't come back after a server
@@ -1142,10 +1177,7 @@ def setup_gallery_routes() -> APIRouter:
if item.get("b64_json"): if item.get("b64_json"):
raw_b64 = item["b64_json"] raw_b64 = item["b64_json"]
elif item.get("url"): elif item.get("url"):
async with httpx.AsyncClient(timeout=60) as c2: raw_b64 = await _fetch_result_image_b64(item["url"])
img_r = await c2.get(item["url"])
if img_r.status_code == 200:
raw_b64 = base64.b64encode(img_r.content).decode()
if not raw_b64: if not raw_b64:
raise HTTPException(502, "OpenAI returned no image") raise HTTPException(502, "OpenAI returned no image")
@@ -1206,7 +1238,7 @@ def setup_gallery_routes() -> APIRouter:
original and regenerates `strength` fraction. With strength ~0.4 original and regenerates `strength` fraction. With strength ~0.4
you get edge blending + lighting unification while keeping the you get edge blending + lighting unification while keeping the
composition recognisable.""" composition recognisable."""
import httpx, base64 as _b64 import httpx
user = require_privilege(request, "can_generate_images") user = require_privilege(request, "can_generate_images")
body = await request.json() body = await request.json()
@@ -1382,10 +1414,9 @@ def setup_gallery_routes() -> APIRouter:
if item.get("b64_json"): if item.get("b64_json"):
return {"image": item["b64_json"]} return {"image": item["b64_json"]}
if item.get("url"): if item.get("url"):
async with httpx.AsyncClient(timeout=60) as c2: img_b64 = await _fetch_result_image_b64(item["url"])
ir = await c2.get(item["url"]) if img_b64:
if ir.status_code == 200: return {"image": img_b64}
return {"image": _b64.b64encode(ir.content).decode()}
last_err = f"{path}: server returned no image" last_err = f"{path}: server returned no image"
except httpx.ConnectError as e: except httpx.ConnectError as e:
raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}") raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}")
@@ -1445,6 +1476,7 @@ def setup_gallery_routes() -> APIRouter:
img_bytes = base64.b64decode(image_b64) img_bytes = base64.b64decode(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB") src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
try: try:
patch_realesrgan_torchvision_compat()
from realesrgan import RealESRGANer from realesrgan import RealESRGANer
except ImportError: except ImportError:
return {"error": "realesrgan not installed. Install it from Cookbook → Dependencies (search 'realesrgan')."} return {"error": "realesrgan not installed. Install it from Cookbook → Dependencies (search 'realesrgan')."}
@@ -1494,6 +1526,7 @@ def setup_gallery_routes() -> APIRouter:
img_bytes = base64.b64decode(image_b64) img_bytes = base64.b64decode(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB") src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
try: try:
patch_realesrgan_torchvision_compat()
from basicsr.archs.rrdbnet_arch import RRDBNet from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer from realesrgan import RealESRGANer
except ImportError: except ImportError:
+18 -6
View File
@@ -108,6 +108,12 @@ def _load_disabled_map():
db.close() db.close()
def _mcp_oauth_redirect_uri() -> str:
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
from src.mcp_oauth import REDIRECT_URI
return REDIRECT_URI
def setup_mcp_routes(mcp_manager: McpManager): def setup_mcp_routes(mcp_manager: McpManager):
"""Setup MCP routes with the provided manager.""" """Setup MCP routes with the provided manager."""
@@ -445,9 +451,9 @@ def setup_mcp_routes(mcp_manager: McpManager):
client_id = keys["client_id"] client_id = keys["client_id"]
scopes = oauth_cfg.get("scopes", []) scopes = oauth_cfg.get("scopes", [])
# For Desktop App creds, redirect to localhost — the user will # For Desktop App creds, default to localhost — the user will
# paste the resulting URL back if they're on a different device. # paste the resulting URL back if they're on a different device.
redirect_uri = "http://localhost:7000/api/mcp/oauth/callback" redirect_uri = _mcp_oauth_redirect_uri()
params = { params = {
"client_id": client_id, "client_id": client_id,
@@ -469,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
return RedirectResponse(auth_url) return RedirectResponse(auth_url)
else: else:
# Remote device — show paste-back page # Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host)) return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
finally: finally:
db.close() db.close()
@@ -536,7 +542,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
client_id = keys["client_id"] client_id = keys["client_id"]
client_secret = keys["client_secret"] client_secret = keys["client_secret"]
redirect_uri = "http://localhost:7000/api/mcp/oauth/callback" redirect_uri = _mcp_oauth_redirect_uri()
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
@@ -603,13 +609,19 @@ def setup_mcp_routes(mcp_manager: McpManager):
return router return router
def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str: def _oauth_authorize_page(
auth_url: str,
server_id: str,
host: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access.""" """Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `host` comes from the request # Escape values interpolated into the page: `host` comes from the request
# Host header and `server_id` from the OAuth state — neither is trusted. # Host header and `server_id` from the OAuth state — neither is trusted.
auth_url = html.escape(auth_url, quote=True) auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True) server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True) host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html><head> <html><head>
<meta charset="UTF-8"><title>Authorize Odysseus</title> <meta charset="UTF-8"><title>Authorize Odysseus</title>
@@ -654,7 +666,7 @@ def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str:
<div class="divider"></div> <div class="divider"></div>
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}"> <form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p> <p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." required> <input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button> <br><button type="submit">Connect</button>
</form> </form>
</div></body></html>""" </div></body></html>"""
+23 -9
View File
@@ -29,6 +29,7 @@ from src.llm_core import llm_call_async
from services.memory.memory_extractor import audit_memories from services.memory.memory_extractor import audit_memories
from src.auth_helpers import get_current_user, require_user from src.auth_helpers import get_current_user, require_user
from src.endpoint_resolver import resolve_endpoint from src.endpoint_resolver import resolve_endpoint
from src.task_endpoint import resolve_task_endpoint
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -240,14 +241,18 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
} }
messages = [system_msg] + sess.get_context_messages() messages = [system_msg] + sess.get_context_messages()
t_url, t_model, t_headers = resolve_task_endpoint(
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
)
try: try:
suggestion_text = await llm_call_async( suggestion_text = await llm_call_async(
sess.endpoint_url, t_url,
sess.model, t_model,
messages, messages,
temperature=0.2, temperature=0.2,
max_tokens=500, max_tokens=500,
headers=sess.headers, headers=t_headers,
) )
try: try:
suggestions = json.loads(suggestion_text) suggestions = json.loads(suggestion_text)
@@ -278,7 +283,15 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
endpoint_url = model = None endpoint_url = model = None
headers = {} headers = {}
# Try default model from settings first # Try utility model from settings first — memory audit is a background
# task and should prefer the lighter utility model over the main chat model.
from src.task_endpoint import resolve_task_endpoint
user = _owner(request)
t_url, t_model, t_headers = resolve_task_endpoint(owner=user)
if t_url and t_model:
endpoint_url, model, headers = t_url, t_model, t_headers
else:
# Fall back to default model if no task/utility model configured
settings = _load_settings() settings = _load_settings()
ep_id = settings.get("default_endpoint_id", "") ep_id = settings.get("default_endpoint_id", "")
default_model = settings.get("default_model", "") default_model = settings.get("default_model", "")
@@ -360,13 +373,14 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
try: try:
sess = session_manager.get_session(session) sess = session_manager.get_session(session)
_assert_session_owner(sess, _owner(request)) _assert_session_owner(sess, _owner(request))
endpoint_url = sess.endpoint_url endpoint_url, model, headers = resolve_task_endpoint(
model = sess.model sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
headers = sess.headers )
except KeyError: except KeyError:
raise HTTPException(404, "Session not found — needed for LLM config") logger.warning("Session %s not found, falling back to utility endpoint", session)
else:
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request)) endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
else:
endpoint_url, model, headers = resolve_task_endpoint(owner=_owner(request))
if not endpoint_url or not model: if not endpoint_url or not model:
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.") raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
+52 -4
View File
@@ -248,6 +248,9 @@ _PROVIDER_CURATED = {
"zai-coding": [ "zai-coding": [
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air", "glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
], ],
"kimi-code": [
"kimi-for-coding",
],
"deepseek": [ "deepseek": [
"deepseek-chat", "deepseek-reasoner", "deepseek-chat", "deepseek-reasoner",
], ],
@@ -315,6 +318,8 @@ def _match_provider_curated(base_url: str, provider: str) -> str:
parsed = urlparse(base_url) parsed = urlparse(base_url)
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""): if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
return "zai-coding" 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: for domain, key in _HOST_TO_CURATED:
if _host_match(base_url, domain): if _host_match(base_url, domain):
return key 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. """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.""" For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
from src.endpoint_resolver import resolve_url from src.endpoint_resolver import resolve_url
from src.llm_core import httpx_get_kimi_aware
base = resolve_url(_normalize_base(base_url)) base = resolve_url(_normalize_base(base_url))
provider = _safe_detect_provider(base) provider = _safe_detect_provider(base)
if provider == "chatgpt-subscription": 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) url = _safe_build_models_url(base)
headers = _safe_build_headers(api_key, base) headers = _safe_build_headers(api_key, base)
try: 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() r.raise_for_status()
data = r.json() data = r.json()
# OpenAI format: {"data": [{"id": "model-name"}]} # 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, []): for _e in _PROVIDER_CURATED.get(_ck, []):
if _e not in set(models) and not any(m.startswith(_e) for m in models): if _e not in set(models) and not any(m.startswith(_e) for m in models):
models.append(_e) 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)] return [m for m in models if _is_chat_model(m)]
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if api_key: if api_key:
@@ -870,15 +881,52 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str: def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
"""Return a provider-aware error message for failed endpoint probes.""" """Return a provider-aware error message for failed endpoint probes.
Surfaces the URL we actually probed and, when the endpoint looks like
LM Studio (port 1234 or hostname match), adds a hint about loading a
model and confirming the Developer Server is running. The user previously
saw a generic "No models found for that provider/key" with no way to
tell whether the URL was wrong, the server was down, or the server was
reachable but had no model loaded (issue #25).
"""
ping = ping or {} ping = ping or {}
error = ping.get("error") error = ping.get("error")
from src.endpoint_resolver import build_models_url
try:
probed = build_models_url(base_url) or base_url
except Exception:
probed = base_url
parsed = urlparse(base_url) parsed = urlparse(base_url)
host = (parsed.hostname or "").lower() host = (parsed.hostname or "").lower()
is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower() is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower()
is_lmstudio = (
parsed.port == 1234
or "lmstudio" in host
or "lm-studio" in host
or "lm_studio" in host
)
if is_lmstudio:
parts = [
"LM Studio is reachable, but no models were reported.",
f"Probed {probed}.",
]
if error:
parts.append(f"Last probe error: {error}.")
parts.append(
"Open LM Studio, load at least one model, and confirm the "
"Developer Server is running on port 1234."
)
parts.append(
"Base URL should be http://localhost:1234/v1 (native) or "
"http://host.docker.internal:1234/v1 (Docker)."
)
return " ".join(parts)
if is_ollama: if is_ollama:
parts = ["No Ollama models found for that endpoint."] parts = ["No Ollama models found for that endpoint."]
parts.append(f"Probed {probed}.")
if error: if error:
parts.append(f"Last probe error: {error}.") parts.append(f"Last probe error: {error}.")
parts.append("Check that Ollama is running and that the base URL is correct.") parts.append("Check that Ollama is running and that the base URL is correct.")
@@ -888,9 +936,9 @@ def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) ->
return " ".join(parts) return " ".join(parts)
if error: if error:
return f"No models found for that provider/key. Last probe error: {error}." return f"No models found for that provider/key. Probed {probed}. Last probe error: {error}."
return "No models found for that provider/key." return f"No models found for that provider/key. Probed {probed}."
def _normalize_model_ids(value): def _normalize_model_ids(value):
+5 -2
View File
@@ -160,8 +160,11 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
JSON response confirming removal JSON response confirming removal
""" """
try: try:
if not directory: # Confine to PERSONAL_DIR — parity with add_directory_to_rag (which
raise HTTPException(400, "Directory path is required") # resolves the path the same way). Without this, an arbitrary or
# `..`-escaping path is passed straight to
# personal_docs_manager.remove_directory / rag.remove_directory.
directory = _resolve_allowed_personal_dir(directory)
logger.info(f"Removing directory from RAG: {directory}") logger.info(f"Removing directory from RAG: {directory}")
+16 -2
View File
@@ -1,6 +1,7 @@
"""Shell routes — user-facing command execution endpoint.""" """Shell routes — user-facing command execution endpoint."""
import asyncio import asyncio
import importlib
import json import json
import logging import logging
import os import os
@@ -14,6 +15,7 @@ from collections import namedtuple
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool from core.platform_compat import IS_APPLE_SILICON, which_tool
from src.optional_deps import prepare_optional_dependency_import
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there # on Windows, so importing them unconditionally crashed app startup there
@@ -149,6 +151,11 @@ def _pip_dist_name(pkg: dict) -> str:
return (pkg.get("name") or "").replace("_", "-") return (pkg.get("name") or "").replace("_", "-")
def _import_optional_dependency_for_status(name: str):
prepare_optional_dependency_import(name)
return importlib.import_module(name)
def _package_installed_from_probe(name: str, probe: dict) -> bool: def _package_installed_from_probe(name: str, probe: dict) -> bool:
"""Return whether an optional dependency is usable by Cookbook. """Return whether an optional dependency is usable by Cookbook.
@@ -970,7 +977,6 @@ def setup_shell_routes() -> APIRouter:
""" """
_require_admin(request) _require_admin(request)
_reject_cross_site(request) _reject_cross_site(request)
import importlib
import importlib.metadata as importlib_metadata import importlib.metadata as importlib_metadata
import shlex import shlex
import json as _json import json as _json
@@ -1057,6 +1063,13 @@ def setup_shell_routes() -> APIRouter:
"category": "Image", "category": "Image",
"target": "remote", "target": "remote",
}, },
{
"name": "transformers",
"pip": "transformers",
"desc": "Hugging Face model components used by SD/Flux pipelines and image tools",
"category": "Image",
"target": "remote",
},
{ {
"name": "rembg", "name": "rembg",
"pip": "rembg[gpu]", "pip": "rembg[gpu]",
@@ -1202,7 +1215,7 @@ def setup_shell_routes() -> APIRouter:
pkg["status_note"] = _package_status_note("vllm", probe) pkg["status_note"] = _package_status_note("vllm", probe)
else: else:
try: try:
importlib.import_module(pkg["name"]) _import_optional_dependency_for_status(pkg["name"])
importlib_metadata.version(_pip_dist_name(pkg)) importlib_metadata.version(_pip_dist_name(pkg))
pkg["installed"] = True pkg["installed"] = True
except ImportError: except ImportError:
@@ -1251,6 +1264,7 @@ def setup_shell_routes() -> APIRouter:
"sglang[all]", "sglang[all]",
"diffusers", "diffusers",
"diffusers[torch]", "diffusers[torch]",
"transformers",
"TTS", "TTS",
"bark", "bark",
"faster-whisper", "faster-whisper",
+4
View File
@@ -198,6 +198,8 @@ def setup_webhook_routes(
"opencode-go": "https://opencode.ai/zen/go/v1", "opencode-go": "https://opencode.ai/zen/go/v1",
"fireworks": "https://api.fireworks.ai/inference/v1", "fireworks": "https://api.fireworks.ai/inference/v1",
"venice": "https://api.venice.ai/api/v1", "venice": "https://api.venice.ai/api/v1",
"kimi-code": "https://api.kimi.com/coding/v1",
"kimicode": "https://api.kimi.com/coding/v1",
} }
# Model prefix → provider mapping for auto-detection # Model prefix → provider mapping for auto-detection
@@ -210,6 +212,8 @@ def setup_webhook_routes(
"mistral": "mistral", "mistral": "mistral",
"llama": "groq", "llama": "groq",
"mixtral": "groq", "mixtral": "groq",
"kimi-for-coding": "kimi-code",
"kimi": "kimi-code",
} }
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]: 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())
+64 -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, "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, "mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322, "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 # Pre-sort keys by length descending for correct substring matching
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True) _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 # Apple Silicon unified-memory bandwidth (GB/s). For chip families with both
# M5) — the named chips above take the accurate bandwidth path instead. # 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} FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
USE_CASE_WEIGHTS = { USE_CASE_WEIGHTS = {
@@ -60,10 +70,51 @@ 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: if not isinstance(gpu_name, str) or not gpu_name:
return None return None
gn = gpu_name.lower() 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
if isinstance(system, dict):
bw = _lookup_apple_bandwidth(system)
if bw is not None:
return bw
gn = gpu_name.lower()
for key in _BW_KEYS_SORTED: for key in _BW_KEYS_SORTED:
if key in gn: if key in gn:
return GPU_BANDWIDTH[key] return GPU_BANDWIDTH[key]
@@ -84,7 +135,7 @@ def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
""" """
pb = _active_params_b(model) pb = _active_params_b(model)
is_moe = model.get("is_moe", False) is_moe = model.get("is_moe", False)
bw = _lookup_bandwidth(system.get("gpu_name")) bw = _lookup_bandwidth(system)
backend = system.get("backend", "cpu_x86") backend = system.get("backend", "cpu_x86")
if bw and run_mode in ("gpu", "cpu_offload"): if bw and run_mode in ("gpu", "cpu_offload"):
+127 -1
View File
@@ -1,3 +1,4 @@
import json
import os import os
import platform import platform
import re import re
@@ -335,6 +336,37 @@ def _detect_apple_silicon():
if total_gb <= 0: if total_gb <= 0:
return None 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 # 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 # default working-set limit scales with RAM: small machines have to keep
# more back for the OS + app. These fractions track Apple's # more back for the OS + app. These fractions track Apple's
@@ -357,7 +389,7 @@ def _detect_apple_silicon():
pass pass
gpu = {"index": 0, "name": brand, "vram_gb": vram_gb} gpu = {"index": 0, "name": brand, "vram_gb": vram_gb}
return { info = {
"gpu_name": brand, "gpu_name": brand,
"gpu_vram_gb": vram_gb, "gpu_vram_gb": vram_gb,
"gpu_count": 1, "gpu_count": 1,
@@ -369,6 +401,9 @@ def _detect_apple_silicon():
# separate pool — downstream fit logic uses this to avoid double-budgeting. # separate pool — downstream fit logic uses this to avoid double-budgeting.
"unified_memory": True, "unified_memory": True,
} }
if gpu_cores is not None:
info["gpu_cores"] = gpu_cores
return info
def _read_file(path): 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): def detect_system(host="", ssh_port="", platform="", fresh=False):
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely """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 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: if _remote_platform == "windows" and _remote_host:
result = _detect_windows() result = _detect_windows()
if result: if result:
result = _attach_probe_context(result, host=host)
_remote_host = None _remote_host = None
_remote_platform = None _remote_platform = None
_cache_by_host[cache_key] = (now, result) _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": if not _remote_host and os.name == "nt":
result = _detect_windows() result = _detect_windows()
if result: if result:
result = _attach_probe_context(result, host=host)
_cache_by_host[cache_key] = (now, result) _cache_by_host[cache_key] = (now, result)
return result return result
# PowerShell probe failed entirely — fall through to the generic path # 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_name": gpu_info["gpu_name"],
"gpu_vram_gb": gpu_info["gpu_vram_gb"], "gpu_vram_gb": gpu_info["gpu_vram_gb"],
"gpu_count": gpu_info["gpu_count"], "gpu_count": gpu_info["gpu_count"],
"gpu_cores": gpu_info.get("gpu_cores"),
"gpus": gpu_info.get("gpus", []), "gpus": gpu_info.get("gpus", []),
"gpu_groups": gpu_info.get("gpu_groups", []), "gpu_groups": gpu_info.get("gpu_groups", []),
"homogeneous": gpu_info.get("homogeneous", True), "homogeneous": gpu_info.get("homogeneous", True),
@@ -714,6 +839,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"gpu_error": _last_gpu_error, "gpu_error": _last_gpu_error,
} }
result = _attach_probe_context(result, host=host)
_remote_host = None _remote_host = None
_remote_platform = None _remote_platform = None
_cache_by_host[cache_key] = (now, result) _cache_by_host[cache_key] = (now, result)
+8 -2
View File
@@ -188,12 +188,18 @@ def compute_serve_profiles(system, model, serve_weights_gb=None, serve_quant=Non
# Shrink context if even the chosen KV won't fit alongside weights. # Shrink context if even the chosen KV won't fit alongside weights.
# Start from the smaller of the profile's target and the model's limit. # Start from the smaller of the profile's target and the model's limit.
cur_ctx = min(ctx, model_ctx_max) cur_ctx = min(ctx, model_ctx_max)
while cur_ctx >= 8192: # Floor the context-shrink loop at 8192, but never above the model's own
# trained limit. A model with a sub-8192 context (e.g. a 2048-token
# SmolLM) starts below 8192, so a hard-coded 8192 guard skipped the loop
# entirely and produced NO profile — the serve UI then fell back to
# manual flags even though the model fits the GPU trivially.
ctx_floor = min(8192, model_ctx_max)
while cur_ctx >= ctx_floor:
kv = _kv_gb(model, cur_ctx, kv_type) kv = _kv_gb(model, cur_ctx, kv_type)
n_cpu_moe, fits = _cpu_moe_for_budget(model, quant, kv, budget, fixed_gb=serve_weights_gb) n_cpu_moe, fits = _cpu_moe_for_budget(model, quant, kv, budget, fixed_gb=serve_weights_gb)
est = _weights_gb(model, quant, serve_weights_gb) + kv + 0.6 est = _weights_gb(model, quant, serve_weights_gb) + kv + 0.6
# If a non-MoE model can't fit even fully offloaded, try less context. # If a non-MoE model can't fit even fully offloaded, try less context.
if model.get("is_moe") or fits or cur_ctx <= 8192: if model.get("is_moe") or fits or cur_ctx <= ctx_floor:
profiles.append({ profiles.append({
"key": key, "key": key,
"label": label, "label": label,
+40 -24
View File
@@ -66,42 +66,58 @@ def _has_duplicate_title(skills, title: str) -> bool:
def _extract_json_object(text: str) -> Optional[dict]: def _extract_json_object(text: str) -> Optional[dict]:
"""Best-effort extraction of a JSON object from an LLM response. """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 The response may be wrapped in code fences or surrounded by prose. Uses
models emit a stray brace in the prose before the real object json.JSONDecoder().raw_decode() to locate the boundaries of complete JSON
(e.g. "uses {placeholder} then {...}"). Slicing first-'{' .. last-'}' then objects starting at each '{' position. Nested objects are filtered out to
grabs an unparseable span and the skill is silently lost. Try the whole keep only top-level candidates. If multiple non-overlapping valid JSON
string first, then each '{' start position in turn, returning the first objects are found, it is treated as ambiguous and returns None. Otherwise,
candidate that parses to a JSON object (dict). Returns None if none do. returns the single valid candidate dictionary.
""" """
if not text: if not text:
return None return None
s = text.strip() s = text.strip()
if s.startswith("```"): if s.startswith("```"):
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip() s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
end = s.rfind("}")
if end == -1:
return None
def _as_dict(candidate): decoder = json.JSONDecoder()
try: candidates = []
obj = json.loads(candidate)
except (json.JSONDecodeError, ValueError):
return None
return obj if isinstance(obj, dict) else 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("{") start = s.find("{")
while 0 <= start < end: while start != -1:
obj = _as_dict(s[start : end + 1]) try:
if obj is not None: obj, idx = decoder.raw_decode(s[start:])
return obj end_pos = start + idx
if isinstance(obj, dict):
candidates.append((start, end_pos, obj))
except (json.JSONDecodeError, ValueError):
pass
start = s.find("{", start + 1) 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 return 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
return top_level[0][2]
async def maybe_extract_skill( async def maybe_extract_skill(
session, session,
+6 -4
View File
@@ -603,7 +603,6 @@ class SkillsManager:
escalation) those are work-in-progress and pollute the escalation) those are work-in-progress and pollute the
prompt with half-finished procedures. prompt with half-finished procedures.
""" """
active_toolsets = active_toolsets or []
out = [] out = []
for s in self.load(owner=owner): for s in self.load(owner=owner):
status = s.get("status") status = s.get("status")
@@ -617,13 +616,16 @@ class SkillsManager:
# Platform gating # Platform gating
if platform and s.get("platforms") and platform not in s["platforms"]: if platform and s.get("platforms") and platform not in s["platforms"]:
continue 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 [] 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 continue
# fallback_for_toolsets: hide when any of those toolsets is active # fallback_for_toolsets: hide when any of those toolsets is active
fb = s.get("fallback_for_toolsets") or [] 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 continue
out.append({ out.append({
"name": s["name"], "name": s["name"],
+43 -12
View File
@@ -64,20 +64,40 @@ def is_youtube_url(url: str) -> bool:
return "youtube.com" in url or "youtu.be" in url return "youtube.com" in url or "youtu.be" in url
# youtube.com-shaped hosts. music.youtube.com serves the same /watch and
# /shorts paths, so links shared from YouTube Music must resolve too.
_YT_HOSTS = ("www.youtube.com", "youtube.com", "m.youtube.com", "music.youtube.com")
# Path prefixes whose first following segment is the video id. Covers the
# /embed/ player, Shorts (/shorts/), live streams (/live/), and the legacy
# /v/ embed — all of which `is_youtube_url` already treats as YouTube, so
# they must be extractable or the link is silently dropped (neither web-fetched
# nor transcript-fetched) by the chat pipeline.
_YT_PATH_PREFIXES = ("/embed/", "/shorts/", "/live/", "/v/")
def extract_youtube_id(url: str) -> Optional[str]: def extract_youtube_id(url: str) -> Optional[str]:
"""Extract YouTube video ID from various URL formats.""" """Extract a YouTube video ID from the common URL shapes:
watch?v=, youtu.be/<id>, /embed/<id>, /shorts/<id>, /live/<id>, /v/<id>,
across youtube.com / m.youtube.com / music.youtube.com / youtu.be."""
if not isinstance(url, str): if not isinstance(url, str):
return None return None
parsed = urllib.parse.urlparse(url) parsed = urllib.parse.urlparse(url)
if parsed.hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"): host = (parsed.hostname or "").lower()
if host in _YT_HOSTS:
if parsed.path == "/watch": if parsed.path == "/watch":
params = urllib.parse.parse_qs(parsed.query) params = urllib.parse.parse_qs(parsed.query)
if "v" in params: if params.get("v"):
return params["v"][0] return params["v"][0]
elif parsed.path.startswith("/embed/"): else:
return parsed.path.split("/")[-1] for prefix in _YT_PATH_PREFIXES:
elif parsed.hostname == "youtu.be": if parsed.path.startswith(prefix):
return parsed.path[1:] vid = parsed.path[len(prefix):].split("/")[0]
if vid:
return vid
elif host == "youtu.be":
vid = parsed.path.lstrip("/").split("/")[0]
if vid:
return vid
return None return None
@@ -170,6 +190,8 @@ def format_transcript_for_context(
if segments: if segments:
ctx += "Timestamped Transcript:\n" ctx += "Timestamped Transcript:\n"
for seg in segments: for seg in segments:
if not isinstance(seg, dict):
continue
ctx += f"[{seg['timestamp']}] {seg['text']}\n" ctx += f"[{seg['timestamp']}] {seg['text']}\n"
# Check length — fall back to plain text if too long # Check length — fall back to plain text if too long
if len(ctx) > 12000: if len(ctx) > 12000:
@@ -202,15 +224,24 @@ async def fetch_youtube_comments(
f"https://www.youtube.com/watch?v={video_id}", f"https://www.youtube.com/watch?v={video_id}",
] ]
proc = await asyncio.wait_for( proc = await asyncio.create_subprocess_exec(
asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
),
timeout=timeout,
) )
stdout, stderr = await proc.communicate() # Bound the wait on the process actually finishing, not on spawning it.
# create_subprocess_exec returns as soon as the child starts, so wrapping
# it in wait_for never enforces the timeout — proc.communicate() is the
# blocking step. Kill and reap the child if it overruns so it does not
# linger after we return.
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
if proc.returncode != 0: if proc.returncode != 0:
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []} return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
+3
View File
@@ -91,6 +91,9 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"), ("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"),
# Deep research jobs, not quick conceptual mentions of research. # Deep research jobs, not quick conceptual mentions of research.
("web", "explicit web search request", rf"{_PLEASE}(?:do|run|use|perform|make)\s+(?:a\s+)?(?:web\s+search|search\s+the\s+web)\b.+"),
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"), ("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"), ("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
+130 -18
View File
@@ -262,6 +262,11 @@ _DOMAIN_RULES = {
- Use `manage_settings` for preferences and tool enable/disable. - Use `manage_settings` for preferences and tool enable/disable.
- Use named tools over `app_api` when a named wrapper exists. - Use named tools over `app_api` when a named wrapper exists.
- `app_api` is only for safe UI/API actions without a named tool; do not use it for shell, package installs, engine rebuilds, or sensitive auth/admin paths.""", - `app_api` is only for safe UI/API actions without a named tool; do not use it for shell, package installs, engine rebuilds, or sensitive auth/admin paths.""",
"contacts": """\
## Contacts rules
- Use `resolve_contact` to look up a contact's email or phone number by name. Searches the CardDAV address book and sent email history.
- Use `manage_contact` to list, add, update, or delete contacts in the address book.
- Do NOT use `manage_memory` for contact lookups contact details live in the address book, not memory.""",
} }
_DOMAIN_TOOL_MAP = { _DOMAIN_TOOL_MAP = {
@@ -274,6 +279,7 @@ _DOMAIN_TOOL_MAP = {
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"}, "sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"}, "files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"}, "settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
"contacts": {"resolve_contact", "manage_contact"},
} }
def _domain_rules_for_tools(tool_names: set) -> list[str]: def _domain_rules_for_tools(tool_names: set) -> list[str]:
@@ -600,7 +606,7 @@ _API_HOSTS = frozenset([
"api.deepseek.com", "deepseek.com", "api.deepseek.com", "deepseek.com",
"api.together.xyz", "api.fireworks.ai", "api.together.xyz", "api.fireworks.ai",
"api.perplexity.ai", "api.x.ai", "api.perplexity.ai", "api.x.ai",
"ollama.com", "api.venice.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
"api.githubcopilot.com", "api.githubcopilot.com",
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.). # Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
# Without these, `_is_api_model` falls back to keyword sniffing on the # Without these, `_is_api_model` falls back to keyword sniffing on the
@@ -787,6 +793,12 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
domains.add("documents") domains.add("documents")
if has(r"\b(search|web|google|look up|latest|news|current|weather|forecast|stock price|price of|website|url|https?://|www\.)\b"): if has(r"\b(search|web|google|look up|latest|news|current|weather|forecast|stock price|price of|website|url|https?://|www\.)\b"):
domains.add("web") domains.add("web")
if has(
r"\b(wyszukaj|wyszukać|wyszukac)\b.*\b(internet|internecie|online|web)\b",
r"\b(sprawd[zź]|znajd[zź])\b.*\b(internet|internecie|online|web)\b",
r"\b(aktualn\w*|bieżąc\w*|biezac\w*|dzisiaj|teraz)\b.*\b(pogod\w*|temperatur\w*)\b",
):
domains.add("web")
if has(r"\b(research|deep dive|investigate|look into)\b"): if has(r"\b(research|deep dive|investigate|look into)\b"):
domains.add("web") domains.add("web")
if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"): if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"):
@@ -797,6 +809,8 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
domains.add("files") domains.add("files")
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"): if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
domains.add("settings") domains.add("settings")
if has(r"\b(contact|contacts|phone|phone number|address book|vcard)\b"):
domains.add("contacts")
low_signal = not continuation and not domains low_signal = not continuation and not domains
return { return {
@@ -1801,18 +1815,21 @@ async def stream_agent_loop(
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)") logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)")
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")): if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
from src.tool_index import ALWAYS_AVAILABLE from src.tool_index import ALWAYS_AVAILABLE
_relevant_tools = set(ALWAYS_AVAILABLE)
if workspace: if workspace:
# An active workspace IS the file-work signal: a vague "look at the # An active workspace IS the file-work signal: a vague "look at the
# project" means explore this folder. Surface only the READ-ONLY file # project" means explore this folder. Surface only the READ-ONLY file
# tools (intersection with the plan-mode read-only allowlist) so the # tools (intersection with the plan-mode read-only allowlist) so the
# agent can investigate; write/shell tools stay out until the request # agent can investigate; write/shell tools stay out until the request
# actually calls for them (RAG retrieval adds those on a real ask). # actually calls for them (RAG retrieval adds those on a real ask).
_relevant_tools = set(ALWAYS_AVAILABLE)
from src.tool_security import PLAN_MODE_READONLY_TOOLS from src.tool_security import PLAN_MODE_READONLY_TOOLS
_relevant_tools |= (_DOMAIN_TOOL_MAP["files"] & PLAN_MODE_READONLY_TOOLS) _relevant_tools |= (_DOMAIN_TOOL_MAP["files"] & PLAN_MODE_READONLY_TOOLS)
logger.info("[tool-rag] Low-signal but workspace active; including read-only file tools") logger.info("[tool-rag] Low-signal but workspace active; including read-only file tools")
else: else:
logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only") # Don't short-circuit: fall through to RAG retrieval below.
# Non-English queries are flagged low_signal by the English-only
# intent classifier, but fastembed retrieval works across languages.
logger.info("[tool-rag] Low-signal query; will run RAG retrieval")
if not guide_only and not _relevant_tools: if not guide_only and not _relevant_tools:
try: try:
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
@@ -1887,6 +1904,44 @@ async def stream_agent_loop(
if _relevant_tools is not None and active_document is not None: if _relevant_tools is not None and active_document is not None:
_relevant_tools.update({"edit_document", "update_document", "suggest_document"}) _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: if _relevant_tools is not None:
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50]) logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
@@ -1937,6 +1992,10 @@ async def stream_agent_loop(
# and can override this list for users who know their setup. # and can override this list for users who know their setup.
_model_no_tools = any(kw in _model_lc for kw in ( _model_no_tools = any(kw in _model_lc for kw in (
"deepseek-r1", "deepseek-r1",
# Open-weight GPT-OSS models are commonly served through llama.cpp /
# llama-cpp-python. Their names contain "gpt-o", but they do not use
# OpenAI's native tool-call channel unless the endpoint opts in.
"gpt-oss",
)) ))
# Native Ollama endpoints (/api/chat) handle tool schemas differently from # Native Ollama endpoints (/api/chat) handle tool schemas differently from
# the OpenAI-compat path. Models like gemma4, qwen3.5, ministral respond to # the OpenAI-compat path. Models like gemma4, qwen3.5, ministral respond to
@@ -1998,30 +2057,34 @@ async def stream_agent_loop(
_t3 = time.time() _t3 = time.time()
try: try:
from src.context_compactor import trim_for_context from src.context_compactor import trim_for_context
from src.context_budget import compute_input_token_budget, DEFAULT_HARD_MAX from src.context_budget import compute_input_token_budget, DEFAULT_HARD_MAX, DEFAULT_BUDGET, budget_is_explicit as _budget_is_explicit
from src.settings import is_setting_overridden from src.model_context import budget_context_for_model
soft_budget = int(get_setting("agent_input_token_budget", 6000) or 0) soft_budget = int(get_setting("agent_input_token_budget", DEFAULT_BUDGET) or 0)
if soft_budget > 0: if soft_budget > 0:
before_trim_tokens = estimate_tokens(messages) before_trim_tokens = estimate_tokens(messages)
reserve_tokens = min(max(max_tokens or 1024, 512), 2048) reserve_tokens = min(max(max_tokens or 1024, 512), 2048)
# Honour the configurable ceiling for the auto-derived budget path. # Ceiling for the auto-derived budget (no effect on an explicit budget;
# No-op when the user has an explicit `agent_input_token_budget` # see #1230). Falls back to DEFAULT_HARD_MAX on missing/malformed values
# (that branch ignores hard_max). Falls back to DEFAULT_HARD_MAX # so misconfig can't zero the budget.
# on missing/malformed values so misconfig can't zero the budget.
try: try:
hard_max = int(get_setting("agent_input_token_hard_max", DEFAULT_HARD_MAX) or DEFAULT_HARD_MAX) hard_max = int(get_setting("agent_input_token_hard_max", DEFAULT_HARD_MAX) or DEFAULT_HARD_MAX)
except (TypeError, ValueError): except (TypeError, ValueError):
hard_max = DEFAULT_HARD_MAX hard_max = DEFAULT_HARD_MAX
if hard_max <= 0: if hard_max <= 0:
hard_max = DEFAULT_HARD_MAX hard_max = DEFAULT_HARD_MAX
# Scale the default budget to the model's context window so long-context # Default value = auto sentinel (scale to the window); any other value =
# models aren't silently capped at 6000; an explicit user setting is # explicit cap. Value-based, not presence-based, because the save path
# still honoured (clamped to the window). (#1170) # materializes defaults so a persisted default must still read as auto (#4121).
budget_is_explicit = _budget_is_explicit(soft_budget)
# Scale only off a window we actually discovered, bound to the value it
# proves (else 0) — not the passed-in context_length, which can be stale
# or unset for some callers (#4122 review).
ctx_for_budget = budget_context_for_model(endpoint_url, model, fallback=context_length)
effective_budget = compute_input_token_budget( effective_budget = compute_input_token_budget(
soft_budget, soft_budget,
context_length, ctx_for_budget,
is_setting_overridden("agent_input_token_budget"), budget_is_explicit,
hard_max=hard_max, hard_max=hard_max,
) )
trimmed_messages = trim_for_context( trimmed_messages = trim_for_context(
@@ -2096,11 +2159,12 @@ async def stream_agent_loop(
# tool, so we don't nudge on harmless transitional text like "let me # tool, so we don't nudge on harmless transitional text like "let me
# know what you think". # know what you think".
_INTENT_RE = re.compile( _INTENT_RE = re.compile(
r"(?:^|\n)\s*(?:let me|i'?ll|i will|going to|let's)\s+" r"(?:^|\n)\s*(?:let me|i'?ll|i will|i need to|we need to|need to|"
r"i should|we should|i must|we must|going to|let's)\s+"
r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|" r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|"
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|" r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|" r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
r"register|adopt|list|search|find|query|hit|ping|test)" r"register|adopt|list|search|find|query|hit|ping|test|use|perform|do)"
r"\b[^.\n]{0,140}", r"\b[^.\n]{0,140}",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -2141,9 +2205,17 @@ async def stream_agent_loop(
elif _is_api_model: elif _is_api_model:
# Filter schemas by RAG-selected tools (if available) # Filter schemas by RAG-selected tools (if available)
if _relevant_tools: 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 = [ base_schemas = [
s for s in FUNCTION_TOOL_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 = [ _mcp_filtered = [
s for s in mcp_schemas s for s in mcp_schemas
@@ -2679,6 +2751,46 @@ async def stream_agent_loop(
) )
desc, result = await _tool_task 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. # Extract structured web sources from web_search tool output.
# web_search returns {"output": ..., "exit_code": 0}; check "output" # web_search returns {"output": ..., "exit_code": 0}; check "output"
# first so the <!-- SOURCES:…--> marker is found and stripped even # first so the <!-- SOURCES:…--> marker is found and stripped even
+2 -3
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] memories = [m for m in memories if m.get("category", "").lower() == category_filter]
if not memories: if not memories:
return {"results": "No memories found" + (f" in category '{category_filter}'" if category_filter else "") + "."} return {"results": "No memories found" + (f" in category '{category_filter}'" if category_filter else "") + "."}
result_lines = [f"Found {len(memories)} memory entries:\n"] result_lines = [f"Found {len(memories)} memory entries:\n"]
for m in memories[:100]: for m in memories:
cat = m.get("category", "fact") cat = m.get("category", "fact")
mid = m.get("id", "?")[:8] mid = m.get("id", "?")[:8]
text = m.get("text", "") text = m.get("text", "")
if len(text) > 150: if len(text) > 150:
text = text[:150] + "..." text = text[:150] + "..."
result_lines.append(f"- [{cat}] `{mid}` — {text}") 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)} return {"results": "\n".join(result_lines)}
elif action == "add": elif action == "add":
+10
View File
@@ -4,6 +4,8 @@ import logging
from typing import Dict from typing import Dict
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from core.platform_compat import safe_chmod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class APIKeyManager: class APIKeyManager:
@@ -15,12 +17,20 @@ class APIKeyManager:
def get_or_create_key(self) -> bytes: def get_or_create_key(self) -> bytes:
"""Get or create encryption key for API keys""" """Get or create encryption key for API keys"""
if os.path.exists(self.key_file): if os.path.exists(self.key_file):
# Older versions wrote .key with the process umask (often 0o644,
# i.e. group/world-readable). Re-restrict on read so existing
# installs heal without needing the key to be regenerated.
safe_chmod(self.key_file, 0o600)
with open(self.key_file, 'rb') as f: with open(self.key_file, 'rb') as f:
return f.read() return f.read()
else: else:
key = Fernet.generate_key() key = Fernet.generate_key()
with open(self.key_file, 'wb') as f: with open(self.key_file, 'wb') as f:
f.write(key) f.write(key)
# This key decrypts every stored provider credential, so restrict it
# to the owner (0o600) — it must not be group/world-readable. No-op
# on Windows (files there are ACL-restricted to the user already).
safe_chmod(self.key_file, 0o600)
return key return key
def encrypt_api_key(self, api_key: str) -> str: def encrypt_api_key(self, api_key: str) -> str:
+2
View File
@@ -55,6 +55,8 @@ async def _drain_agent(sess, messages):
if "delta" in d: if "delta" in d:
delta = d.get("delta") delta = d.get("delta")
if isinstance(delta, str): if isinstance(delta, str):
if d.get("thinking"):
continue
full += delta full += delta
elif d.get("type") == "agent_step": elif d.get("type") == "agent_step":
round_num = d.get("round", round_num) round_num = d.get("round", round_num)
+73 -6
View File
@@ -5,12 +5,13 @@ Auto-registration of built-in MCP servers on startup.
Each server runs as a stdio subprocess managed by McpManager. Each server runs as a stdio subprocess managed by McpManager.
""" """
import asyncio
import json
import logging import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import asyncio
from core.platform_compat import IS_WINDOWS, which_tool from core.platform_compat import IS_WINDOWS, which_tool
@@ -197,12 +198,13 @@ def _npx_package_from_args(args):
async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5): async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
"""Probe whether an npx package is already in the local cache. """Probe whether an npx package is already in the local cache.
Runs `npx --no-install <pkg> --version`. --no-install tells npx to First checks the local `_npx` cache for an installed package. If the
fail instead of downloading, so a cache miss returns fast. We treat package is not found there, falls back to `npx --no-install <pkg>
"exited 0 with non-empty stdout" as proof of a working cached copy. --version` so older npm layouts still work without downloading.
Anything else (non-zero exit, empty stdout, timeout, missing npx,
network error) means we should skip the server.
""" """
if _is_package_in_npx_cache(package_spec):
return True
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
npx_path, "--no-install", package_spec, "--version", npx_path, "--no-install", package_spec, "--version",
@@ -231,3 +233,68 @@ async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
pass pass
return False return False
return proc.returncode == 0 and bool(stdout.strip()) return proc.returncode == 0 and bool(stdout.strip())
def _is_package_in_npx_cache(package_spec):
"""Return True when npm's `_npx` cache already contains package_spec."""
package_name = _npx_package_name(package_spec)
if not package_name:
return False
for cache_root in _npm_cache_roots():
npx_root = os.path.join(cache_root, "_npx")
if _npx_cache_contains_package(npx_root, package_name):
return True
return False
def _npx_package_name(package_spec):
"""Strip a version/range suffix from an npm package spec."""
if not package_spec:
return ""
if package_spec.startswith("@"):
parts = package_spec.split("@", 2)
if len(parts) >= 3:
return f"@{parts[1]}"
return package_spec
return package_spec.split("@", 1)[0]
def _npm_cache_roots():
roots = []
configured = os.environ.get("npm_config_cache")
if configured:
roots.append(os.path.expanduser(configured))
roots.append(os.path.join(os.path.expanduser("~"), ".npm"))
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
roots.append(os.path.join(local_app_data, "npm-cache"))
return list(dict.fromkeys(roots))
def _npx_cache_contains_package(npx_root, package_name):
if not os.path.isdir(npx_root):
return False
package_path = os.path.join("node_modules", *package_name.split("/"), "package.json")
try:
entries = list(os.scandir(npx_root))
except OSError:
return False
for entry in entries:
try:
is_dir = entry.is_dir()
except OSError:
continue
cached_name = _cached_package_name(os.path.join(entry.path, package_path))
if is_dir and cached_name == package_name:
return True
return False
def _cached_package_name(package_json_path):
try:
with open(package_json_path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
return ""
return str(data.get("name", "")).strip()
+178 -1
View File
@@ -128,6 +128,17 @@ def validate_caldav_url(raw_url: str) -> str:
return urlunparse(parsed._replace(fragment="")).rstrip("/") 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: def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
"""Deterministic local id for a remote CalDAV calendar, scoped to owner """Deterministic local id for a remote CalDAV calendar, scoped to owner
and account so two users or one user with two accounts pointing at 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", color="#5b8abf",
source="caldav", source="caldav",
account_id=account_id or None, account_id=account_id or None,
caldav_base_url=remote_url,
) )
db.add(local_cal) db.add(local_cal)
db.commit() db.commit()
else: else:
# Refresh display name and stamp account_id if missing. # Refresh display name and stamp CalDAV metadata if missing.
changed = False changed = False
if local_cal.name != display_name: if local_cal.name != display_name:
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: if account_id and not local_cal.account_id:
local_cal.account_id = account_id local_cal.account_id = account_id
changed = True changed = True
if local_cal.caldav_base_url != remote_url:
local_cal.caldav_base_url = remote_url
changed = True
if changed: if changed:
db.commit() db.commit()
result["calendars"] += 1 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) existing = _find_existing_event(db, pending, uid_val, local_cal.id)
if existing: if existing:
if existing.caldav_sync_pending in {"create", "update"}:
result["events"] += 1
continue
existing.calendar_id = local_cal.id existing.calendar_id = local_cal.id
existing.summary = summary existing.summary = summary
existing.description = description 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.is_utc = row_is_utc
existing.rrule = rrule existing.rrule = rrule
existing.origin = "caldav" 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: else:
new_ev = CalendarEvent( new_ev = CalendarEvent(
uid=uid_val, 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, is_utc=row_is_utc,
rrule=rrule, rrule=rrule,
origin="caldav", origin="caldav",
remote_href=str(getattr(obj, "url", "") or "") or None,
remote_etag=_event_etag(obj) or None,
) )
db.add(new_ev) db.add(new_ev)
pending[uid_val] = 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.origin == "caldav",
CalendarEvent.dtstart >= start, CalendarEvent.dtstart >= start,
CalendarEvent.dtstart <= end, 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), ~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
).all() ).all()
for ev in stale: for ev in stale:
@@ -458,6 +483,92 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
return result 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: def _load_caldav_accounts(owner: str) -> list:
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy """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. 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", []): for err in result.get("errors", []):
totals["errors"].append(f"{label}: {err}") totals["errors"].append(f"{label}: {err}")
return totals 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 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, def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
owner: str = "", account_id: str = "") -> dict: owner: str = "", account_id: str = "") -> dict:
"""Create/update (or delete) ``ev`` on the matching remote calendar. """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) remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
if remote is None: if remote is None:
return {"ok": False, "error": "remote calendar not found"} return {"ok": False, "error": "remote calendar not found"}
remote_url = str(getattr(remote, "url", "") or "")
try: try:
existing = remote.event_by_uid(uid) 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 delete:
if existing is None: 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() 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) ical = build_event_ical(ev)
if existing is not None: if existing is not None:
existing.data = ical existing.data = ical
existing.save() existing.save()
return {"ok": True, "updated": True} return {
remote.save_event(ical) "ok": True,
return {"ok": True, "created": 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): 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) 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, async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
ev: dict, *, delete: bool = False) -> dict: ev: dict, *, delete: bool = False) -> dict:
"""Best-effort push of a local change to the remote CalDAV server. """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( result = await asyncio.to_thread(
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id _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"): if not result.get("ok"):
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result) logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
return result return result
except Exception as e: except Exception as e:
logger.exception("CalDAV write-back raised") 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
+27 -7
View File
@@ -31,16 +31,22 @@ def compute_input_token_budget(
Args: Args:
configured: the value read from settings (may be the default). configured: the value read from settings (may be the default).
context_length: the model's discovered context window (0/unknown if none). context_length: the model's discovered context window. Pass 0 when the
explicit: True if the user explicitly set ``agent_input_token_budget``. window is unknown / only a bare fallback auto-scaling then stays
conservative instead of trusting an unproven window (review on #4122).
explicit: True if the user set a NON-default budget. The default value is
the "auto" sentinel (scale to the window); any other value is an
explicit cap. (A deliberately-chosen default can't be distinguished
from a materialized default by value, so the default reads as auto.)
Rules: Rules:
- Explicit user budget is honoured exactly, only clamped to the model's - Explicit user budget is honoured exactly, only clamped to the model's
window when that window is known (never send more than the model holds). window when that window is known (the user's deliberate choice wins;
- Otherwise (default), scale to ``headroom`` of the context window, capped ``hard_max`` is an auto-budget ceiling only see #1230).
at ``hard_max`` so long-context models use their capacity. - Otherwise (auto), scale to ``headroom`` of the context window, capped at
- When the window is unknown, fall back to the configured/default value ``hard_max`` so long-context models use their capacity.
(preserving the previous behaviour). - When the window is unknown (context_length <= 0), use the conservative
``default`` budget and do NOT scale off the fallback.
""" """
configured = int(configured or 0) configured = int(configured or 0)
context_length = int(context_length or 0) context_length = int(context_length or 0)
@@ -53,3 +59,17 @@ def compute_input_token_budget(
return max(1, min(scaled, hard_max)) return max(1, min(scaled, hard_max))
return configured if configured > 0 else default return configured if configured > 0 else default
def budget_is_explicit(configured: int, *, default: int = DEFAULT_BUDGET) -> bool:
"""Whether a configured agent_input_token_budget is a deliberate explicit cap.
The default value is the "auto" sentinel (scale to the model's window), so only
a NON-default positive value counts as explicit. This keys off the VALUE, not
settings *presence* the settings-save path materializes every default into
settings.json, so a persisted default must still read as auto (the regression
#4121 / #1230 are about). Centralised here so the materialized-default contract
is unit-testable and can't silently regress to a presence check.
"""
configured = int(configured or 0)
return configured > 0 and configured != default
+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) protected_tokens = estimate_tokens(protected_msgs)
budget -= protected_tokens budget -= protected_tokens
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo) # Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo).
essential_system = system_msgs[:1] if system_msgs else [] # Exception: a research-spinoff primer (the seeded report that grounds a
extra_system = system_msgs[1:] # "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) # Try dropping extra system messages one by one (from the end)
trimmed = essential_system + convo_msgs trimmed = essential_system + convo_msgs
+22 -3
View File
@@ -136,7 +136,8 @@ async def _tick() -> None:
return return
try: try:
state = json.loads(state_path.read_text(encoding="utf-8")) state = json.loads(state_path.read_text(encoding="utf-8"))
except Exception: except Exception as e:
logger.warning("cookbook_serve_lifecycle: state file unreadable (%s), skipping tick", e)
return return
tasks = state.get("tasks") or [] tasks = state.get("tasks") or []
now_ms = int(time.time() * 1000) now_ms = int(time.time() * 1000)
@@ -178,8 +179,26 @@ async def _tick() -> None:
if stopped_any: if stopped_any:
try: try:
from core.atomic_io import atomic_write_json from core.atomic_io import atomic_write_json
state["tasks"] = tasks # Re-read the state file so concurrent UI writes (task adds,
atomic_write_json(state_path, state) # status flips, config edits) are not silently overwritten.
# Apply only our stop mutations to the fresh snapshot.
try:
fresh = json.loads(state_path.read_text(encoding="utf-8"))
fresh_tasks = fresh.get("tasks") or []
except Exception:
fresh = state
fresh_tasks = tasks
stopped_sids = {sid for sid, _, _ in to_stop}
for ft in fresh_tasks:
if not isinstance(ft, dict):
continue
ft_sid = ft.get("sessionId") or ft.get("id")
if ft_sid in stopped_sids:
ft["status"] = "stopped"
ft["_scheduledStopAtMs"] = None
ft["_lastStatusFlipAt"] = now_ms
fresh["tasks"] = fresh_tasks
atomic_write_json(state_path, fresh)
except Exception as e: except Exception as e:
logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}") logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}")
+26 -13
View File
@@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from core.database import SessionLocal, ModelEndpoint 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__) logger = logging.getLogger(__name__)
@@ -183,7 +183,16 @@ def build_chat_url(base: str) -> str:
def build_models_url(base: str) -> Optional[str]: def build_models_url(base: str) -> Optional[str]:
"""Return the provider-specific model-list endpoint URL for a base.""" """Return the provider-specific model-list endpoint URL for a base.
For OpenAI-compatible servers (LM Studio, llama.cpp, vLLM,
text-generation-webui, etc.) the model list is exposed at ``/v1/models``.
When the user-supplied base has no path e.g. ``http://localhost:1234``
we still need to land on ``/v1/models`` (issue #25); insert the ``/v1``
segment only when the path is empty, leaving any explicit non-empty path
untouched (so custom prefixes like ``/openai`` or ``/api/openai/v1`` keep
their semantics).
"""
base = normalize_base(resolve_url(base)) base = normalize_base(resolve_url(base))
provider = _detect_provider(base) provider = _detect_provider(base)
if provider == "anthropic": if provider == "anthropic":
@@ -192,6 +201,12 @@ def build_models_url(base: str) -> Optional[str]:
return _ollama_api_root(base) + "/tags" return _ollama_api_root(base) + "/tags"
if provider == "chatgpt-subscription": if provider == "chatgpt-subscription":
return None 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:
base = base + "/v1"
return base + "/models" return base + "/models"
@@ -215,6 +230,8 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
if provider == "openrouter": if provider == "openrouter":
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus") headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
headers.setdefault("X-OpenRouter-Title", "Odysseus") headers.setdefault("X-OpenRouter-Title", "Odysseus")
if _is_kimi_code_url(base):
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
return headers return headers
@@ -250,23 +267,19 @@ def resolve_endpoint(
ep_id = _stg(f"{setting_prefix}_endpoint_id") ep_id = _stg(f"{setting_prefix}_endpoint_id")
model = _stg(f"{setting_prefix}_model") model = _stg(f"{setting_prefix}_model")
# If the specific endpoint is not configured, but the caller provided a # Fall back to utility model for task/research/auto-naming if not specifically configured.
if not ep_id and setting_prefix not in ("utility", "default"):
ep_id = _stg("utility_endpoint_id")
model = _stg("utility_model")
# If the endpoint is STILL not configured, but the caller provided a
# valid fallback (e.g. the active session model), use that immediately. # valid fallback (e.g. the active session model), use that immediately.
# This prevents background tasks from jumping to the global default_model # This prevents background tasks from jumping to the global default_model
# when the user is mid-conversation with a different model. # when the user is mid-conversation with a different model.
if not ep_id and fallback_url and fallback_model: if not ep_id and fallback_url and fallback_model:
return fallback_url, fallback_model, fallback_headers return fallback_url, fallback_model, fallback_headers
# Unset Utility means "same as Default Chat Model". # Unset Utility (or anything else that didn't have a fallback) means "same as Default Chat Model".
if setting_prefix == "utility" and not ep_id:
ep_id = _stg("default_endpoint_id")
model = _stg("default_model")
# Fall back to utility model for task/research/auto-naming if not specifically configured.
# If Utility itself is unset, the block above makes that resolve to Default Chat.
if not ep_id and setting_prefix != "utility":
ep_id = _stg("utility_endpoint_id")
model = _stg("utility_model")
if not ep_id: if not ep_id:
ep_id = _stg("default_endpoint_id") ep_id = _stg("default_endpoint_id")
model = _stg("default_model") model = _stg("default_model")
+11
View File
@@ -6,6 +6,7 @@ import re
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
import httpx import httpx
from fastapi import HTTPException
from core.atomic_io import atomic_write_json from core.atomic_io import atomic_write_json
from core.platform_compat import safe_chmod from core.platform_compat import safe_chmod
@@ -258,6 +259,11 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
integration.setdefault("name", "") integration.setdefault("name", "")
integration.setdefault("base_url", "") integration.setdefault("base_url", "")
if not isinstance(integration.get("name"), str) or not integration["name"].strip():
raise HTTPException(400, "Integration name is required")
if not isinstance(integration.get("base_url"), str) or not integration["base_url"].strip():
raise HTTPException(400, "Integration base URL is required")
integrations = load_integrations() integrations = load_integrations()
integrations.append(integration) integrations.append(integration)
save_integrations(integrations) save_integrations(integrations)
@@ -266,6 +272,11 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update fields on an existing integration. Returns updated integration or None.""" """Update fields on an existing integration. Returns updated integration or None."""
if "name" in data and (not isinstance(data["name"], str) or not data["name"].strip()):
raise HTTPException(400, "Integration name is required")
if "base_url" in data and (not isinstance(data["base_url"], str) or not data["base_url"].strip()):
raise HTTPException(400, "Integration base URL is required")
integrations = load_integrations() integrations = load_integrations()
for item in integrations: for item in integrations:
if item.get("id") == integration_id: if item.get("id") == integration_id:
+204 -11
View File
@@ -7,6 +7,7 @@ import logging
import hashlib import hashlib
import threading import threading
import re import re
import os
from fastapi import HTTPException from fastapi import HTTPException
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
from src.model_context import get_context_length, DEFAULT_CONTEXT from src.model_context import get_context_length, DEFAULT_CONTEXT
@@ -22,6 +23,24 @@ class LLMConfig:
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRY_DELAY = 0.5 RETRY_DELAY = 0.5
STREAM_TIMEOUT = 300 STREAM_TIMEOUT = 300
# TCP+TLS connect budget for a SINGLE attempt. The old hard-coded 3.0s
# assumed LAN/Tailscale peers ('SYN in <100ms'); it is too tight for public
# cloud endpoints (offshore APIs take ~0.5-1.5s cold, with jitter), so a
# brief blip on the first connect of an idle chat surfaced as a 503 on the
# streaming path (which, unlike llm_call, does not retry the connect). A
# genuinely dead upstream stays bounded by the dead-host cooldown. Override
# with env LLM_CONNECT_TIMEOUT (seconds).
CONNECT_TIMEOUT = float(os.getenv('LLM_CONNECT_TIMEOUT', '10') or '10')
def _call_timeout(read_timeout) -> httpx.Timeout:
"""Per-request timeout for non-streaming LLM calls (connect from config)."""
return httpx.Timeout(connect=LLMConfig.CONNECT_TIMEOUT, read=float(read_timeout), write=10.0, pool=5.0)
def _stream_timeout(read_timeout) -> httpx.Timeout:
"""Per-request timeout for streaming LLM calls (connect from config)."""
return httpx.Timeout(connect=LLMConfig.CONNECT_TIMEOUT, read=float(read_timeout), write=30.0, pool=5.0)
# Cache for LLM responses # Cache for LLM responses
@@ -423,6 +442,146 @@ def _host_match(url: str, *domains: str) -> bool:
return any(host == d or host.endswith("." + d) for d in domains) 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: def _detect_provider(url: str) -> str:
"""Detect the API provider from a configured endpoint URL. """Detect the API provider from a configured endpoint URL.
@@ -446,6 +605,8 @@ def _detect_provider(url: str) -> str:
return "groq" return "groq"
if _host_match(url, "nvidia.com"): if _host_match(url, "nvidia.com"):
return "nvidia" 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 from src.chatgpt_subscription import is_chatgpt_subscription_base
if is_chatgpt_subscription_base(url): if is_chatgpt_subscription_base(url):
return "chatgpt-subscription" return "chatgpt-subscription"
@@ -542,6 +703,12 @@ def _provider_label(url: str) -> str:
if _host_match(url, "googleapis.com"): return "Google" if _host_match(url, "googleapis.com"): return "Google"
if _host_match(url, "together.xyz", "together.ai"): return "Together" if _host_match(url, "together.xyz", "together.ai"): return "Together"
if _host_match(url, "fireworks.ai"): return "Fireworks" 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" if _is_ollama_native_url(url): return "Ollama"
try: try:
host = (urlparse(url).hostname or "").lower() host = (urlparse(url).hostname or "").lower()
@@ -682,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 # 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 # the API use its required default. (gpt-4.5 is intentionally excluded — it is
# not a reasoning model and accepts temperature normally.) # 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: def _restricts_temperature(model: str) -> bool:
"""Check if a model rejects any non-default temperature.""" """Check if a model rejects any non-default temperature."""
@@ -691,6 +858,28 @@ def _restricts_temperature(model: str) -> bool:
m = model.lower() m = model.lower()
return any(m.startswith(p) or f"/{p}" in m for p in _FIXED_TEMPERATURE_MODELS) 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 # 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 — # 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 # even 0.0 — returns HTTP 400. Earlier Claude models (Opus 4.6 and below, every
@@ -1138,7 +1327,7 @@ def list_model_ids(
from src.endpoint_resolver import build_models_url from src.endpoint_resolver import build_models_url
models_url = build_models_url(base_chat_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() r.raise_for_status()
data = r.json() data = r.json()
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")] model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
@@ -1239,14 +1428,14 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
"messages": messages_copy, "messages": messages_copy,
"temperature": temperature, "temperature": temperature,
} }
if _restricts_temperature(model): if _omit_temperature(provider, model):
payload.pop("temperature", None) payload.pop("temperature", None)
if max_tokens and max_tokens > 0: if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = max_tokens payload[tok_key] = max_tokens
try: try:
note_model_activity(target_url, model) 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: except Exception as e:
raise HTTPException(502, f"POST {target_url} failed: {e}") raise HTTPException(502, f"POST {target_url} failed: {e}")
if not r.is_success: if not r.is_success:
@@ -1433,7 +1622,7 @@ async def llm_call_async(
"messages": messages_copy, "messages": messages_copy,
"temperature": temperature, "temperature": temperature,
} }
if _restricts_temperature(model): if _omit_temperature(provider, model):
payload.pop("temperature", None) payload.pop("temperature", None)
if max_tokens and max_tokens > 0: if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
@@ -1446,7 +1635,7 @@ async def llm_call_async(
if _is_host_dead(target_url): if _is_host_dead(target_url):
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)") raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
call_timeout = httpx.Timeout(connect=3.0, read=float(timeout), write=10.0, pool=5.0) call_timeout = _call_timeout(timeout)
attempt = 0 attempt = 0
while attempt < max_retries: while attempt < max_retries:
attempt += 1 attempt += 1
@@ -1454,7 +1643,7 @@ async def llm_call_async(
try: try:
note_model_activity(target_url, model) note_model_activity(target_url, model)
client = _get_http_client() 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 duration = time.time() - start
if not r.is_success: if not r.is_success:
friendly = _format_upstream_error(r.status_code, r.text, target_url) friendly = _format_upstream_error(r.status_code, r.text, target_url)
@@ -1550,7 +1739,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
"temperature": temperature, "temperature": temperature,
"stream": True, "stream": True,
} }
if _restricts_temperature(model): if _omit_temperature(provider, model):
payload.pop("temperature", None) payload.pop("temperature", None)
if provider not in {"openrouter", "groq"}: if provider not in {"openrouter", "groq"}:
payload["stream_options"] = {"include_usage": True} payload["stream_options"] = {"include_usage": True}
@@ -1570,9 +1759,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
from src.copilot import apply_request_headers from src.copilot import apply_request_headers
apply_request_headers(h, messages_copy) apply_request_headers(h, messages_copy)
# Short connect timeout: a reachable peer answers SYN in <100ms even on # Connect budget from LLMConfig.CONNECT_TIMEOUT (env LLM_CONNECT_TIMEOUT).
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI. # The dead-host cooldown still bounds a genuinely unreachable upstream, so a
stream_timeout = httpx.Timeout(connect=3.0, read=float(timeout), write=30.0, pool=5.0) # wider connect budget only affects first contact and stops a brief cold
# connect blip (offshore/public endpoints) surfacing as a 503 on this stream
# path, which -- unlike llm_call -- does not retry the connect.
stream_timeout = _stream_timeout(timeout)
if _is_host_dead(target_url): if _is_host_dead(target_url):
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n' yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
@@ -1848,6 +2040,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
events.append(_stream_delta_event(part)) events.append(_stream_delta_event(part))
return events return events
h = apply_kimi_code_headers(h, target_url)
try: try:
client = _get_http_client() client = _get_http_client()
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r: async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
+56 -22
View File
@@ -222,16 +222,12 @@ KNOWN_CONTEXT_WINDOWS = {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cache # Cache
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_context_cache: Dict[Tuple[str, str], int] = {} _context_cache: Dict[Tuple[str, str], Tuple[int, bool]] = {}
def get_context_length(endpoint_url: str, model: str) -> int: def _get_context_length_cached(endpoint_url: str, model: str) -> Tuple[int, bool]:
"""Get the context window size for a model. """Return (context_length, known). ``known`` is False only when the value is a
bare DEFAULT_CONTEXT fallback (no endpoint report and not in the known table)."""
Queries /v1/models on the endpoint and looks for context_length
or context_window fields. Caches result per (endpoint, model).
Falls back to DEFAULT_CONTEXT if unavailable.
"""
configured_kind = _configured_endpoint_kind(endpoint_url) configured_kind = _configured_endpoint_kind(endpoint_url)
is_local = is_local_endpoint(endpoint_url) is_local = is_local_endpoint(endpoint_url)
# Key on (endpoint_url, model): the same model id can be served by two # Key on (endpoint_url, model): the same model id can be served by two
@@ -242,14 +238,50 @@ def get_context_length(endpoint_url: str, model: str) -> int:
if not is_local and cache_key in _context_cache: if not is_local and cache_key in _context_cache:
return _context_cache[cache_key] return _context_cache[cache_key]
ctx = _query_context_length(endpoint_url, model) ctx, known = _query_context_length(endpoint_url, model)
# Only cache non-default values to allow retry on next request. # Only cache non-default values to allow retry on next request.
# Local endpoints can restart with a different --max-model-len while keeping # Local endpoints can restart with a different --max-model-len while keeping
# the same model id, so always re-query them instead of serving stale cache. # the same model id, so always re-query them instead of serving stale cache.
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")): if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
_context_cache[cache_key] = ctx _context_cache[cache_key] = (ctx, known)
logger.info(f"Context length for {model}: {ctx}") logger.info(f"Context length for {model}: {ctx}")
return ctx return ctx, known
def get_context_length(endpoint_url: str, model: str) -> int:
"""Get the context window size for a model.
Queries /v1/models on the endpoint and looks for context_length
or context_window fields. Caches result per (endpoint, model).
Falls back to DEFAULT_CONTEXT if unavailable.
"""
return _get_context_length_cached(endpoint_url, model)[0]
def get_context_length_known(endpoint_url: str, model: str) -> Tuple[int, bool]:
"""Like ``get_context_length`` but also returns whether the window was actually
discovered (endpoint-reported or in the known-models table) rather than the bare
DEFAULT_CONTEXT fallback. Callers that *scale* a budget off the window must not
trust an unknown value a fallback 128K isn't proof the model holds 128K
(review on #4122)."""
return _get_context_length_cached(endpoint_url, model)
def budget_context_for_model(endpoint_url: str, model: str, *, fallback: int = 0) -> int:
"""Context window to scale the agent input budget against.
Returns the *freshly discovered* window when it was actually proven
(endpoint-reported / known table), else 0 so auto-scaling stays conservative.
Crucially this binds the ``known`` flag to the value it proves callers must
not pair this flag with a context length from a *different* lookup (a stale
local re-query, or a caller that didn't pass one), which would budget off an
unproven number (review on #4122). On probe error, returns ``fallback`` (the
caller's best-known value) to preserve prior behaviour."""
try:
ctx, known = get_context_length_known(endpoint_url, model)
return ctx if known else 0
except Exception:
return fallback
def _lookup_known(model: str) -> Optional[int]: def _lookup_known(model: str) -> Optional[int]:
@@ -271,8 +303,9 @@ def _lookup_known(model: str) -> Optional[int]:
return best_ctx return best_ctx
def _query_context_length(endpoint_url: str, model: str) -> int: def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
"""Query the model API for context length.""" """Query the model API for context length. Returns (context_length, known) where
``known`` is False only for the bare DEFAULT_CONTEXT fallback."""
known = _lookup_known(model) known = _lookup_known(model)
api_ctx = None api_ctx = None
configured_kind = _configured_endpoint_kind(endpoint_url) configured_kind = _configured_endpoint_kind(endpoint_url)
@@ -283,8 +316,8 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
if configured_kind in ("api", "proxy"): if configured_kind in ("api", "proxy"):
if known: if known:
logger.info(f"Using known context window for {model}: {known}") logger.info(f"Using known context window for {model}: {known}")
return known return known, True
return DEFAULT_CONTEXT return DEFAULT_CONTEXT, False
# Try llama.cpp /slots endpoint first — reports actual serving context # Try llama.cpp /slots endpoint first — reports actual serving context
if is_local_endpoint(endpoint_url): if is_local_endpoint(endpoint_url):
@@ -297,7 +330,7 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
n_ctx = slots[0].get("n_ctx") n_ctx = slots[0].get("n_ctx")
if n_ctx and isinstance(n_ctx, int) and n_ctx > 0: if n_ctx and isinstance(n_ctx, int) and n_ctx > 0:
logger.info(f"llama.cpp /slots reports n_ctx={n_ctx} for {model}") logger.info(f"llama.cpp /slots reports n_ctx={n_ctx} for {model}")
return n_ctx return n_ctx, True
except Exception: except Exception:
pass pass
@@ -309,7 +342,8 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
if is_copilot_base(endpoint_url): if is_copilot_base(endpoint_url):
if known: if known:
logger.info(f"Using known context window for {model}: {known}") logger.info(f"Using known context window for {model}: {known}")
return known or DEFAULT_CONTEXT return known, True
return DEFAULT_CONTEXT, False
from src.endpoint_resolver import build_models_url from src.endpoint_resolver import build_models_url
@@ -354,18 +388,18 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
_is_local = is_local_endpoint(endpoint_url) _is_local = is_local_endpoint(endpoint_url)
if _is_local and api_ctx < known: if _is_local and api_ctx < known:
logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value") logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value")
return api_ctx return api_ctx, True
result = max(api_ctx, known) result = max(api_ctx, known)
if api_ctx < known: if api_ctx < known:
logger.info(f"API reported {api_ctx} for {model}, using known {known} instead") logger.info(f"API reported {api_ctx} for {model}, using known {known} instead")
return result return result, True
if api_ctx: if api_ctx:
return api_ctx return api_ctx, True
if known: if known:
logger.info(f"Using known context window for {model}: {known}") logger.info(f"Using known context window for {model}: {known}")
return known return known, True
return DEFAULT_CONTEXT return DEFAULT_CONTEXT, False
def estimate_tokens(messages: List[Dict]) -> int: def estimate_tokens(messages: List[Dict]) -> int:
+32
View File
@@ -0,0 +1,32 @@
"""Compatibility helpers for optional third-party dependencies."""
from __future__ import annotations
import sys
import types
def patch_realesrgan_torchvision_compat() -> None:
"""Restore the torchvision import path expected by BasicSR/Real-ESRGAN."""
module_name = "torchvision.transforms.functional_tensor"
if module_name in sys.modules:
return
try:
from torchvision.transforms import functional
except Exception:
return
rgb_to_grayscale = getattr(functional, "rgb_to_grayscale", None)
if rgb_to_grayscale is None:
return
shim = types.ModuleType(module_name)
shim.rgb_to_grayscale = rgb_to_grayscale
shim.__getattr__ = lambda name: getattr(functional, name)
sys.modules[module_name] = shim
def prepare_optional_dependency_import(name: str) -> None:
"""Apply known import-time compatibility shims before probing a package."""
if name == "realesrgan":
patch_realesrgan_torchvision_compat()
+18 -8
View File
@@ -101,14 +101,22 @@ DEFAULT_SETTINGS = {
"research_run_timeout_seconds": 1800, "research_run_timeout_seconds": 1800,
"agent_max_tool_calls": 0, "agent_max_tool_calls": 0,
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200) "agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
# "auto" sentinel: it means "scale the budget to the model's context window"
# (#1230) — so long-context models aren't capped at 6000. Set ANY OTHER value
# to enforce an explicit cap (clamped to the window only — hard_max does not
# apply to explicit budgets, #1230); set 0 to disable soft-trimming. The
# default is treated as auto because the settings-save path materializes
# defaults, so a persisted 6000 can't be told apart from a deliberate 6000 —
# to pin a budget near the default, use a nearby value (e.g. 5999).
"agent_input_token_budget": 6000, "agent_input_token_budget": 6000,
# Ceiling on the *auto-derived* input budget that #1230 introduced. Has # Ceiling on the *auto-derived* input budget; a configurable setting since #1273
# no effect when `agent_input_token_budget` is explicitly set (the user's # (the merged #1230 left it a module constant). No effect on an explicit budget
# value is honoured regardless). Default matches # — a deliberate value is honoured (#1230). Default matches
# `src.context_budget.DEFAULT_HARD_MAX`; lower this for cost-paranoid # `src.context_budget.DEFAULT_HARD_MAX`; lower this for
# setups, raise it on premium APIs with very large windows that you # cost-paranoid setups, raise it on premium APIs with very large windows you
# want to actually use (e.g. 900_000 to fill a 1M-context model). See # want to actually use (e.g. 900_000 to fill a 1M-context model). See
# `compute_input_token_budget` in src/context_budget.py. # `compute_input_token_budget`.
"agent_input_token_hard_max": 200_000, "agent_input_token_hard_max": 200_000,
"agent_stream_timeout_seconds": 300, "agent_stream_timeout_seconds": 300,
# Extra directory roots that read_file / write_file may access, in # Extra directory roots that read_file / write_file may access, in
@@ -223,8 +231,10 @@ def is_setting_overridden(key: str) -> bool:
``load_settings`` merges DEFAULT_SETTINGS with the saved file, so a value ``load_settings`` merges DEFAULT_SETTINGS with the saved file, so a value
equal to its default is indistinguishable from "never set" via get_setting. equal to its default is indistinguishable from "never set" via get_setting.
Callers that need to treat an explicit user choice differently from the Callers that must distinguish an explicit user choice from a default read
default (e.g. adaptive budgets) use this to read the raw saved file. the raw saved file via this. (Note: a materialized default is also "present",
so value-sensitive callers should compare against the default see
``context_budget.budget_is_explicit``.)
""" """
try: try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f: with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
+2
View File
@@ -1649,6 +1649,8 @@ class TaskScheduler:
data = json.loads(event_str[6:]) data = json.loads(event_str[6:])
# Capture text from all event types, not just delta # Capture text from all event types, not just delta
if "delta" in data: if "delta" in data:
if data.get("thinking"):
continue
full_text += data["delta"] full_text += data["delta"]
elif data.get("type") == "tool_output": elif data.get("type") == "tool_output":
# Tool results — capture summary so we have SOMETHING even # Tool results — capture summary so we have SOMETHING even
+3 -1
View File
@@ -42,7 +42,7 @@ _SOTA_HOSTS = frozenset({
"api.together.xyz", "api.fireworks.ai", "api.together.xyz", "api.fireworks.ai",
"api.perplexity.ai", "api.x.ai", "api.perplexity.ai", "api.x.ai",
"generativelanguage.googleapis.com", "api.groq.com", "generativelanguage.googleapis.com", "api.groq.com",
"openrouter.ai", "ollama.com", "api.venice.ai", "openrouter.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
}) })
@@ -594,6 +594,8 @@ async def run_teacher_inline(
"exit_code": payload.get("exit_code"), "exit_code": payload.get("exit_code"),
}) })
if "delta" in payload and isinstance(payload["delta"], str): if "delta" in payload and isinstance(payload["delta"], str):
if payload.get("thinking"):
continue
captured_text_parts.append(payload["delta"]) captured_text_parts.append(payload["delta"])
yield 'data: ' + json.dumps(payload) + '\n\n' yield 'data: ' + json.dumps(payload) + '\n\n'
continue continue
+25 -1
View File
@@ -1445,7 +1445,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).""" """Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note 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 import uuid as _uuid
try: try:
@@ -1643,6 +1651,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
except ValueError as e: except ValueError as e:
return {"error": f"Invalid date format: {e}", "exit_code": 1} return {"error": f"Invalid date format: {e}", "exit_code": 1}
if end_dt <= start_dt:
end_dt = start_dt + timedelta(days=1)
q = _event_query().filter( q = _event_query().filter(
CalendarEvent.dtstart < end_dt, CalendarEvent.dtstart < end_dt,
CalendarEvent.dtend > start_dt, CalendarEvent.dtend > start_dt,
@@ -1822,6 +1833,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
rrule=args.get("rrule", "") or "", rrule=args.get("rrule", "") or "",
event_type=event_type, event_type=event_type,
importance=importance, importance=importance,
caldav_sync_pending="create" if cal.source == "caldav" else None,
) )
db.add(ev) db.add(ev)
reminder_note_id = None reminder_note_id = None
@@ -1836,6 +1848,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
dtstart_is_utc and not all_day, dtstart_is_utc and not all_day,
) )
db.commit() db.commit()
if cal.source == "caldav":
await _push_caldav_event_after_commit(owner, uid, "create")
tag_blurb = f" [{event_type}]" if event_type else "" tag_blurb = f" [{event_type}]" if event_type else ""
if minutes_before is None: if minutes_before is None:
reminder_blurb = "" reminder_blurb = ""
@@ -1893,7 +1907,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
ev.event_type = _tag or None ev.event_type = _tag or None
if args.get("importance") is not None: if args.get("importance") is not None:
ev.importance = args["importance"] ev.importance = args["importance"]
is_caldav = ev.calendar and ev.calendar.source == "caldav"
if is_caldav:
ev.caldav_sync_pending = "update"
db.commit() db.commit()
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "update")
return {"response": f"Updated event {uid}", "exit_code": 0} return {"response": f"Updated event {uid}", "exit_code": 0}
elif action == "delete_event": elif action == "delete_event":
@@ -1907,8 +1926,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
ev = _event_query().filter(CalendarEvent.uid == base_uid).first() ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
if not ev: if not ev:
return {"error": f"Event {uid} not found", "exit_code": 1} 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.delete(ev)
db.commit() db.commit()
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "delete")
return {"response": f"Deleted event {uid}", "exit_code": 0} return {"response": f"Deleted event {uid}", "exit_code": 0}
else: else:
+4
View File
@@ -384,6 +384,10 @@ class ToolIndex:
"delegate to", "have model"}): "delegate to", "have model"}):
{"chat_with_model", "ask_teacher", "list_models"}, {"chat_with_model", "ask_teacher", "list_models"},
# Deep research intent (incl. common typo "reserach") # Deep research intent (incl. common typo "reserach")
frozenset({"web search", "search the web", "search online", "look up",
"google", "latest", "current", "news", "weather",
"forecast", "stock price", "price of"}):
{"web_search", "web_fetch"},
frozenset({"research", "reserach", "reasearch", "look into", "investigate", frozenset({"research", "reserach", "reasearch", "look into", "investigate",
"deep dive", "deep research", "find out about", "study up on", "deep dive", "deep research", "find out about", "study up on",
"report on", "do research", "look up everything"}): "report on", "do research", "look up everything"}):
+86
View File
@@ -188,6 +188,12 @@ _MISFENCED_WEB_TOOL_NAMES = {
"fetch_url": "web_fetch", "fetch_url": "web_fetch",
} }
_RAW_WEB_JSON_TOOL_RE = re.compile(
r"\b(?:web_search|websearch|google_search|google_search_retrieval|google_search_grounding)\b",
re.IGNORECASE,
)
_RAW_WEB_JSON_ALLOWED_KEYS = {"query", "queries", "time_filter", "freshness", "max_pages"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Parsing functions # Parsing functions
@@ -279,6 +285,73 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
return None return None
return ToolBlock("web_fetch", url) return ToolBlock("web_fetch", url)
def _coerce_raw_web_query(value) -> Optional[str]:
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, list):
for item in value:
if isinstance(item, str) and item.strip():
return item.strip()
return None
def _raw_web_json_to_tool_block(payload) -> Optional[ToolBlock]:
if not isinstance(payload, dict):
return None
if set(payload) - _RAW_WEB_JSON_ALLOWED_KEYS:
return None
query = _coerce_raw_web_query(payload.get("query"))
if not query:
query = _coerce_raw_web_query(payload.get("queries"))
if not query:
return None
content = {"query": query}
for key in ("time_filter", "freshness"):
value = payload.get(key)
if isinstance(value, str) and value.strip().lower() in ("day", "week", "month", "year"):
content[key] = value.strip().lower()
max_pages = payload.get("max_pages")
if isinstance(max_pages, int) and 1 <= max_pages <= 10:
content["max_pages"] = max_pages
if len(content) == 1:
return ToolBlock("web_search", query)
return ToolBlock("web_search", json.dumps(content))
def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int, int]]]:
"""Recover local text-model web_search calls emitted as prose + bare JSON.
Some non-native tool models leak the intended call as:
Need to do web_search for ...
{"query": "...", "time_filter": "week"}
Keep this narrower than fenced/tool markup: it only runs when a known web
tool name appears shortly before a JSON object shaped like web_search args.
"""
if not isinstance(text, str):
return None
decoder = json.JSONDecoder()
for mention in _RAW_WEB_JSON_TOOL_RE.finditer(text):
search_start = mention.end()
search_end = min(len(text), search_start + 1200)
for brace in re.finditer(r"\{", text[search_start:search_end]):
start = search_start + brace.start()
try:
parsed, end = decoder.raw_decode(text[start:])
except json.JSONDecodeError:
continue
block = _raw_web_json_to_tool_block(parsed)
if block:
return block, (start, start + end)
return None
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]: def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
"""Parse a [TOOL_CALL] block into a ToolBlock. """Parse a [TOOL_CALL] block into a ToolBlock.
@@ -436,6 +509,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
3. XML-style <tool_call>/<invoke> blocks 3. XML-style <tool_call>/<invoke> blocks
4. <tool_code> blocks (MiniMax-M2.5 style) 4. <tool_code> blocks (MiniMax-M2.5 style)
5. DeepSeek DSML markup (normalized to <invoke> first) 5. DeepSeek DSML markup (normalized to <invoke> first)
6. Non-native local model fallback: prose mentioning web_search followed by
bare JSON args, e.g. {"query":"...", "time_filter":"week"}
`skip_fenced`: when True, Pattern 1 (fenced ```bash/```python/```json code `skip_fenced`: when True, Pattern 1 (fenced ```bash/```python/```json code
blocks) is not matched at all. Native function-calling models (GPT/Claude/ blocks) is not matched at all. Native function-calling models (GPT/Claude/
@@ -509,6 +584,12 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if block: if block:
blocks.append(block) blocks.append(block)
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
if not blocks and not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(text)
if raw_web_json:
blocks.append(raw_web_json[0])
return blocks return blocks
@@ -532,6 +613,11 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
cleaned = _TOOL_CALL_RE.sub('', cleaned) cleaned = _TOOL_CALL_RE.sub('', cleaned)
cleaned = _XML_TOOL_CALL_RE.sub('', cleaned) cleaned = _XML_TOOL_CALL_RE.sub('', cleaned)
cleaned = _TOOL_CODE_RE.sub('', cleaned) cleaned = _TOOL_CODE_RE.sub('', cleaned)
if not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(cleaned)
if raw_web_json:
_, (start, end) = raw_web_json
cleaned = cleaned[:start] + cleaned[end:]
# Strip bare <invoke> blocks not wrapped in <tool_call> # Strip bare <invoke> blocks not wrapped in <tool_call>
cleaned = re.sub(r'<invoke\s+name=["\'].*?</invoke>', '', cleaned, flags=re.DOTALL | re.IGNORECASE) cleaned = re.sub(r'<invoke\s+name=["\'].*?</invoke>', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned) cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
+6 -3
View File
@@ -177,13 +177,16 @@ def owner_is_admin_or_single_user(owner: Optional[str]) -> bool:
defense-in-depth for callers that bypass it (e.g. trusted loopback). defense-in-depth for callers that bypass it (e.g. trusted loopback).
""" """
try: try:
from src.auth_helpers import _auth_disabled
if _auth_disabled():
return True
from core.auth import AuthManager from core.auth import AuthManager
auth = AuthManager() auth = AuthManager()
if not auth.is_configured: if not auth.is_configured:
from src.auth_helpers import _auth_disabled return False
return _auth_disabled()
return bool(owner and auth.is_admin(owner)) return bool(owner and auth.is_admin(owner))
except Exception as exc: except Exception as exc:
logger.warning("Unable to evaluate owner admin status: %s", exc) logger.warning("Unable to evaluate owner admin status: %s", exc)
+18 -273
View File
@@ -1,278 +1,23 @@
""" """Compatibility wrapper for the canonical services.youtube.youtube_handler module.
YouTube handling transcript extraction, comment fetching (yt-dlp),
and context formatting for LLM injection. Used by chat_handler.py. Odysseus historically carried two independent copies of the YouTube handler
one here under ``src`` and one under ``services.youtube``. They drifted: the
comment-fetch timeout fix landed only in the ``src`` copy, while ``app.py``
calls ``services.youtube.init_youtube()`` at startup. Because the chat flow
imported ``extract_transcript_async`` from ``src.youtube_handler`` (a different
module object), the ``YOUTUBE_AVAILABLE`` / ``YouTubeTranscriptApi`` globals set
by ``init_youtube`` never reached it and transcript extraction always reported
"YouTube transcript API not available".
Keep the old ``src.youtube_handler`` import path working, but make it resolve to
the single source of truth so module state and behavior can't diverge again.
""" """
import asyncio import importlib
import json
import logging
import shutil
import sys import sys
import urllib.parse
from pathlib import Path
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__) # Import the canonical module directly (services.youtube.youtube_handler)
# without triggering the heavy services/__init__.py top-level imports.
_youtube_handler = importlib.import_module("services.youtube.youtube_handler")
# --------------------------------------------------------------------------- sys.modules[__name__] = _youtube_handler
# Constants
# ---------------------------------------------------------------------------
YOUTUBE_INSTRUCTION_PROMPT = """When the user shares a YouTube video, respond with a structured breakdown:
1. **Summary** Concise overview of the video's content and main thesis (2-4 sentences)
2. **Key Points** Bullet list of the most important topics, arguments, or moments
3. **Notable Timestamps** If timestamps are available from the transcript, highlight 3-5 interesting moments with their approximate timestamps (e.g. "03:45 — discusses X")
4. **Audience Reception** If comments are available, summarize what viewers think: general sentiment, top reactions, any debate or controversy
Keep it conversational and concise. Do NOT web search for this video use only the transcript and comments provided."""
# ---------------------------------------------------------------------------
# Init / helpers
# ---------------------------------------------------------------------------
# Will be set at startup by init_youtube()
YouTubeTranscriptApi = None
YOUTUBE_AVAILABLE = False
def _find_ytdlp() -> str:
"""Find the yt-dlp binary: venv bin first, then system PATH."""
venv_bin = Path(sys.executable).parent / "yt-dlp"
if venv_bin.exists():
return str(venv_bin)
found = shutil.which("yt-dlp")
return found or "yt-dlp"
def init_youtube():
"""Import and cache the YouTube transcript API."""
global YouTubeTranscriptApi, YOUTUBE_AVAILABLE
try:
from youtube_transcript_api import YouTubeTranscriptApi as _Api
YouTubeTranscriptApi = _Api
YOUTUBE_AVAILABLE = True
logger.info("YouTube transcript API available")
except ImportError as e:
logger.warning(f"youtube-transcript-api not installed: {e}")
YOUTUBE_AVAILABLE = False
def is_youtube_url(url: str) -> bool:
if not isinstance(url, str):
return False
return "youtube.com" in url or "youtu.be" in url
def extract_youtube_id(url: str) -> Optional[str]:
"""Extract YouTube video ID from various URL formats."""
parsed = urllib.parse.urlparse(url)
if parsed.hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
if parsed.path == "/watch":
params = urllib.parse.parse_qs(parsed.query)
if "v" in params:
return params["v"][0]
elif parsed.path.startswith("/embed/"):
return parsed.path.split("/")[-1]
elif parsed.hostname == "youtu.be":
return parsed.path[1:]
return None
async def extract_transcript_async(
url: str, video_id: str, max_retries: int = 3
) -> Dict[str, Any]:
"""
Async YouTube transcript extraction with retries.
Args:
url: Full YouTube URL
video_id: Extracted video ID
max_retries: Number of attempts
Returns:
Dict with success/error/transcript keys
"""
if not YOUTUBE_AVAILABLE or YouTubeTranscriptApi is None:
return {"success": False, "error": "YouTube transcript API not available", "transcript": None}
for attempt in range(max_retries):
try:
api = YouTubeTranscriptApi()
transcript = api.fetch(video_id)
transcript_list = list(transcript)
formatted = []
for snippet in transcript_list:
text = snippet.text.strip()
if not text:
continue
start = snippet.start
formatted.append({
"text": text,
"start": start,
"duration": snippet.duration,
"timestamp": f"{int(start // 60):02d}:{int(start % 60):02d}",
})
full_text = " ".join(e["text"] for e in formatted)
max_len = 8000
if len(full_text) > max_len:
full_text = full_text[:max_len] + "... [transcript truncated]"
return {
"success": True,
"transcript": full_text,
"video_id": video_id,
"language": "en",
"is_generated": False,
"segments": formatted,
}
except Exception as e:
logger.warning(f"Transcript attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1:
await asyncio.sleep(1 * (attempt + 1))
return {"success": False, "error": f"Failed after {max_retries} attempts", "transcript": None}
def format_transcript_for_context(
transcript_data: Dict[str, Any], url: str,
title: str = "", channel: str = ""
) -> str:
"""Format transcript data for inclusion in LLM context."""
if not transcript_data.get("success"):
header = ""
if title:
header = f" \"{title}\""
if channel:
header += f" by {channel}"
return f"\n[YouTube Video{header}: Transcript unavailable ({transcript_data.get('error', 'Unknown error')}). Use the comments below if available, do NOT web search for this video.]"
transcript = transcript_data.get("transcript", "")
video_id = transcript_data.get("video_id", "")
language = transcript_data.get("language", "unknown")
is_generated = transcript_data.get("is_generated", False)
segments = transcript_data.get("segments", [])
ctx = "\n[YOUTUBE VIDEO TRANSCRIPT]\n"
if title:
ctx += f"Title: {title}\n"
if channel:
ctx += f"Channel: {channel}\n"
ctx += f"Video ID: {video_id}\n"
ctx += f"Language: {language}\n"
ctx += f"Source: {'Auto-generated' if is_generated else 'Manual'}\n"
ctx += f"URL: {url}\n\n"
# Include timestamped segments for the LLM to reference
if segments:
ctx += "Timestamped Transcript:\n"
for seg in segments:
if not isinstance(seg, dict):
continue
ctx += f"[{seg['timestamp']}] {seg['text']}\n"
# Check length — fall back to plain text if too long
if len(ctx) > 12000:
ctx = ctx[:ctx.index("Timestamped Transcript:\n")]
ctx += "Transcript:\n"
ctx += transcript
else:
ctx += "Transcript:\n"
ctx += transcript
ctx += "\n[END TRANSCRIPT]\n"
return ctx
async def fetch_youtube_comments(
video_id: str, max_comments: int = 25, timeout: int = 30
) -> Dict[str, Any]:
"""Fetch top comments for a YouTube video using yt-dlp.
Returns dict with 'success', 'comments' list, 'error'.
"""
try:
cmd = [
_find_ytdlp(),
"--skip-download",
"--write-comments",
"--extractor-args", f"youtube:max_comments={max_comments},all,100,0",
"--dump-json",
"--js-runtimes", "node",
"--remote-components", "ejs:github",
f"https://www.youtube.com/watch?v={video_id}",
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Bound the wait on the process actually finishing, not on spawning it.
# create_subprocess_exec returns as soon as the child starts, so wrapping
# it in wait_for never enforces the timeout — proc.communicate() is the
# blocking step. Kill and reap the child if it overruns so it does not
# linger after we return.
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
if proc.returncode != 0:
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
data = json.loads(stdout.decode())
title = data.get("title", "")
channel = data.get("channel", "") or data.get("uploader", "")
raw_comments = data.get("comments", [])
comments = []
for c in raw_comments[:max_comments]:
text = (c.get("text") or "").strip()
if not text:
continue
comments.append({
"author": c.get("author", "Unknown"),
"text": text,
"likes": c.get("like_count", 0),
})
# Sort by likes descending — most popular comments first
comments.sort(key=lambda x: x.get("likes", 0), reverse=True)
return {"success": True, "comments": comments, "count": len(comments),
"title": title, "channel": channel}
except asyncio.TimeoutError:
logger.warning(f"Comment fetch timed out for {video_id}")
return {"success": False, "error": "Comment fetch timed out", "comments": []}
except FileNotFoundError:
logger.warning("yt-dlp not installed — cannot fetch comments")
return {"success": False, "error": "yt-dlp not installed", "comments": []}
except Exception as e:
logger.warning(f"Failed to fetch comments for {video_id}: {e}")
return {"success": False, "error": str(e), "comments": []}
def format_comments_for_context(comments_data: Dict[str, Any], url: str) -> str:
"""Format YouTube comments for inclusion in LLM context."""
if not comments_data.get("success") or not comments_data.get("comments"):
return ""
comments = comments_data["comments"]
ctx = f"\n[YOUTUBE VIDEO COMMENTS — Top {len(comments)} by popularity]\n"
ctx += f"URL: {url}\n\n"
for i, c in enumerate(comments, 1):
likes = c.get("likes", 0)
likes_str = f" [{likes} likes]" if likes else ""
ctx += f"{i}. @{c['author']}{likes_str}: {c['text']}\n\n"
if len(ctx) > 4000:
ctx = ctx[:4000] + "\n[Comments truncated]\n"
ctx += "[END COMMENTS]\n"
return ctx
+3 -2
View File
@@ -130,11 +130,12 @@ fi
# 3. Python environment + dependencies (kept inside the repo, in venv/). # 3. Python environment + dependencies (kept inside the repo, in venv/).
# Named `venv` to match the manual steps and build-macos-app.sh, so the # Named `venv` to match the manual steps and build-macos-app.sh, so the
# clickable .app reuses this same environment. # 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…" echo "▶ Creating Python environment…"
"$PY" -m venv venv "$PY" -m venv venv
fi fi
VENV_PY="./venv/bin/python3"
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)" REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
REQ_HASH_FILE="venv/.requirements_hash" REQ_HASH_FILE="venv/.requirements_hash"
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
+6 -2
View File
@@ -3135,7 +3135,9 @@ function initializeEventListeners() {
setTimeout(() => uiModule.autoResize(textarea), 1); setTimeout(() => uiModule.autoResize(textarea), 1);
}); });
textarea.addEventListener('keydown', (e) => { textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { const isMobile = window.innerWidth <= 768
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
// If ghost autocomplete is active, accept the suggestion instead of submitting // If ghost autocomplete is active, accept the suggestion instead of submitting
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) { if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
e.preventDefault(); e.preventDefault();
@@ -3708,7 +3710,9 @@ function startOdysseusApp() {
// Enter to send (shift+enter for newline), or new chat when empty // Enter to send (shift+enter for newline), or new chat when empty
if (messageInput) { if (messageInput) {
messageInput.addEventListener('keydown', (e) => { messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { const isMobile = window.innerWidth <= 768
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
e.preventDefault(); e.preventDefault();
// Flush the debounced icon update so dataset.mode reflects the current // Flush the debounced icon update so dataset.mode reflects the current
// text state. Without this, a fast type-and-Enter would still see the // text state. Without this, a fast type-and-Enter would still see the
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

+56 -1
View File
@@ -12,7 +12,7 @@
in email bodies — was wrapping random digits in <a href="tel:..."> with in email bodies — was wrapping random digits in <a href="tel:..."> with
browser-default styling that didn't match the Odysseus theme. --> browser-default styling that didn't match the Odysseus theme. -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <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}}"> <script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now(); window._odysseusLoadTime = Date.now();
(function(){ (function(){
@@ -2232,6 +2232,61 @@
<!-- ═══ SYSTEM TAB ═══ --> <!-- ═══ SYSTEM TAB ═══ -->
<div data-settings-panel="system" class="hidden"> <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"> <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> <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> <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>
+235 -2
View File
@@ -55,6 +55,7 @@ async function loadUsers() {
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;align-items:center;"> <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> <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 ? '' : `<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>'} ${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 // Toggle panel visibility + rotate chevron + load models
let _modelsLoaded = false; let _modelsLoaded = false;
header.addEventListener('click', (e) => { 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'); privPanel.classList.toggle('hidden');
const chevron = header.querySelector('.admin-user-chevron'); const chevron = header.querySelector('.admin-user-chevron');
if (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); list.appendChild(row);
}); });
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; } } catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
@@ -2488,12 +2525,206 @@ function initDangerZone() {
}); });
} }
/*
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 INIT & REFRESH
*/ */
function initAll() { function initAll() {
modalEl = el('settings-modal'); 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) { for (const fn of inits) {
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); } try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
} }
@@ -2507,6 +2738,7 @@ function refreshAll() {
loadBuiltinTools(); loadBuiltinTools();
loadMcpServers(); loadMcpServers();
loadTokens(); loadTokens();
loadLogs(false);
} }
/* /*
@@ -2523,6 +2755,7 @@ export function open(tab) {
} }
export function close() { export function close() {
stopLogsPolling();
settingsModule.close(); settingsModule.close();
} }
+153 -44
View File
@@ -9,7 +9,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js'; import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js'; import { bindMenuDismiss } from './escMenuStack.js';
import { import {
WEEKDAYS, MONTHS, MON_SHORT, WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE, CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
_trashIcon, _moreIcon, _bellIcon, _trashIcon, _moreIcon, _bellIcon,
_isCalBgImage, _calBgImageUrl, _calBgCss, _isCalBgImage, _calBgImageUrl, _calBgCss,
@@ -64,6 +64,8 @@ let _hiddenTypes = new Set(); // event_type values to hide
let _onlyImportant = false; let _onlyImportant = false;
let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1'; let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1';
// Week-start preference: 'mon' (default, Mon=first col) or 'sun' (Sun=first col).
let _weekStartSun = localStorage.getItem('cal-week-start') === 'sun';
let _selectedDay = null; let _selectedDay = null;
let _view = 'month'; let _view = 'month';
let _searchQuery = ''; let _searchQuery = '';
@@ -360,14 +362,14 @@ function _today() { return _ds(new Date()); }
function _monthRange(d) { function _monthRange(d) {
const y = d.getFullYear(), m = d.getMonth(); const y = d.getFullYear(), m = d.getMonth();
const first = new Date(y, m, 1); const first = new Date(y, m, 1);
const dow = (first.getDay() + 6) % 7; const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const gs = new Date(y, m, 1 - dow); const gs = new Date(y, m, 1 - dow);
const ge = new Date(gs); ge.setDate(gs.getDate() + 42); const ge = new Date(gs); ge.setDate(gs.getDate() + 42);
return [_ds(gs), _ds(ge)]; return [_ds(gs), _ds(ge)];
} }
function _weekRange(d) { function _weekRange(d) {
const dow = (d.getDay() + 6) % 7; const dow = _weekStartSun ? d.getDay() : (d.getDay() + 6) % 7;
const s = new Date(d); s.setDate(d.getDate() - dow); const s = new Date(d); s.setDate(d.getDate() - dow);
const e = new Date(s); e.setDate(s.getDate() + 7); const e = new Date(s); e.setDate(s.getDate() + 7);
return [_ds(s), _ds(e)]; return [_ds(s), _ds(e)];
@@ -928,11 +930,11 @@ async function _renderMonth() {
_slideDir = 0; _slideDir = 0;
let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`; let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`;
h += '<div class="cal-week-headers">'; h += '<div class="cal-week-headers">';
for (const wd of WEEKDAYS) h += `<div class="cal-weekday">${wd}</div>`; for (const wd of (_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)) h += `<div class="cal-weekday">${wd}</div>`;
h += '</div>'; h += '</div>';
const first = new Date(y, m, 1); const first = new Date(y, m, 1);
const dow = (first.getDay() + 6) % 7; const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const gs = new Date(y, m, 1 - dow); const gs = new Date(y, m, 1 - dow);
const multiDay = _events.filter(e => { const multiDay = _events.filter(e => {
@@ -1141,13 +1143,13 @@ function _wkEventTopHeight(ev, dayStr) {
// Date math if the string isn't shaped as expected. // Date math if the string isn't shaped as expected.
const _toMin = (iso, fallbackDate) => { const _toMin = (iso, fallbackDate) => {
if (!iso) return null; if (!iso) return null;
const m = iso.match(/T(\d{2}):(\d{2})/); const mins = _timeToMin(iso);
if (m) { if (mins !== null && iso.includes('T')) {
// If the event spans into a previous/next day, clamp to today's bounds. // If the event spans into a previous/next day, clamp to today's bounds.
const evDate = iso.slice(0, 10); const evDate = _localDateOf(iso);
if (evDate < fallbackDate) return 0; // event started before today if (evDate < fallbackDate) return 0; // event started before today
if (evDate > fallbackDate) return 24 * 60; // event ends after today if (evDate > fallbackDate) return 24 * 60; // event ends after today
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); return mins;
} }
// All-day or date-only — treat as start of day. // All-day or date-only — treat as start of day.
return 0; return 0;
@@ -1204,8 +1206,8 @@ async function _renderWeek() {
const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day); const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day);
const isSun = d.getDay() === 0; const isSun = d.getDay() === 0;
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`; colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun && !_weekStartSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${WEEKDAYS[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`; colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${(_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
// All-day strip // All-day strip
colsHtml += `<div class="cal-wk-allday">`; colsHtml += `<div class="cal-wk-allday">`;
for (const ev of allDayEvents) { for (const ev of allDayEvents) {
@@ -1286,12 +1288,17 @@ async function _renderWeek() {
if (!ev) return; if (!ev) return;
const cols = Array.from(body.querySelectorAll('.cal-wk-grid')); const cols = Array.from(body.querySelectorAll('.cal-wk-grid'));
if (!cols.length) return; if (!cols.length) return;
// Original timing // Local/display timing
const m1 = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/); const startMin0 = _timeToMin(ev.dtstart) ?? 0;
const m2 = (ev.dtend || '').match(/T(\d{2}):(\d{2})/); const endMin0 = _timeToMin(ev.dtend) ?? startMin0 + 60;
const startMin0 = m1 ? parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10) : 0;
const endMin0 = m2 ? parseInt(m2[1], 10) * 60 + parseInt(m2[2], 10) : startMin0 + 60; let durationMin = endMin0 - startMin0;
const durationMin = Math.max(15, endMin0 - startMin0); const startDs = _localDateOf(ev.dtstart);
const endDs = ev.dtend ? _localDateOf(ev.dtend) : startDs;
if (endDs > startDs && endMin0 <= startMin0) {
durationMin += 24 * 60;
}
durationMin = Math.max(15, durationMin);
// Where did the cursor grab the block? (offset from block-top in px) // Where did the cursor grab the block? (offset from block-top in px)
const blockRect = block.getBoundingClientRect(); const blockRect = block.getBoundingClientRect();
@@ -1365,7 +1372,7 @@ async function _renderWeek() {
// a plain click (no movement) must still open the event. // a plain click (no movement) must still open the event.
if (moved) block.dataset.justResized = '1'; if (moved) block.dataset.justResized = '1';
// Decide whether anything actually moved. // Decide whether anything actually moved.
const oldDs = (ev.dtstart || '').slice(0, 10); const oldDs = _localDateOf(ev.dtstart);
if (!nextDs) return; if (!nextDs) return;
if (nextDs === oldDs && nextStartMin === startMin0) return; if (nextDs === oldDs && nextStartMin === startMin0) return;
// Snapshot the original times so we can offer an Undo. // Snapshot the original times so we can offer an Undo.
@@ -1374,11 +1381,10 @@ async function _renderWeek() {
const newEndMin = nextStartMin + durationMin; const newEndMin = nextStartMin + durationMin;
const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0'); const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0');
const mm = String(nextStartMin % 60).padStart(2, '0'); const mm = String(nextStartMin % 60).padStart(2, '0');
const hh2 = String(Math.floor(newEndMin / 60)).padStart(2, '0'); const newDtstartDate = new Date(`${nextDs}T${hh}:${mm}:00`);
const mm2 = String((newEndMin) % 60).padStart(2, '0'); const _tz = _tzOffsetForDate(newDtstartDate);
const _tz = _tzOffset();
const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`; const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`;
const newDtend = `${nextDs}T${hh2}:${mm2}:00${_tz}`; const newDtend = _addMinutesToLocalIso(newDtstart, durationMin);
try { try {
await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend }); await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend });
_render(); _render();
@@ -1410,10 +1416,7 @@ async function _renderWeek() {
const uid = block.dataset.uid; const uid = block.dataset.uid;
const ev = _events.find(x => x.uid === uid); const ev = _events.find(x => x.uid === uid);
if (!ev || !grid || !ds) return; if (!ev || !grid || !ds) return;
const startMin = (() => { const startMin = _timeToMin(ev.dtstart) ?? 0;
const m = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0;
})();
const initialTop = parseFloat(block.style.top || '0'); const initialTop = parseFloat(block.style.top || '0');
const gridRect = grid.getBoundingClientRect(); const gridRect = grid.getBoundingClientRect();
let newEndMin = startMin; let newEndMin = startMin;
@@ -1438,9 +1441,8 @@ async function _renderWeek() {
if (resized) block.dataset.justResized = '1'; if (resized) block.dataset.justResized = '1';
if (newEndMin === startMin) return; if (newEndMin === startMin) return;
const prevDtend = ev.dtend; const prevDtend = ev.dtend;
const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0'); const durationMin = newEndMin - startMin;
const mm = String(newEndMin % 60).padStart(2, '0'); const newDtend = _addMinutesToLocalIso(ev.dtstart, durationMin);
const newDtend = `${ds}T${hh}:${mm}:00${_tzOffset()}`;
try { try {
await _updateEvent(uid, { dtend: newDtend }); await _updateEvent(uid, { dtend: newDtend });
_render(); _render();
@@ -1724,9 +1726,9 @@ async function _renderYear() {
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`; h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`;
h += '<div class="cal-year-grid">'; h += '<div class="cal-year-grid">';
for (const wd of ['M', 'T', 'W', 'T', 'F', 'S', 'S']) h += `<div class="cal-year-wd">${wd}</div>`; for (const wd of (_weekStartSun ? ['S','M','T','W','T','F','S'] : ['M','T','W','T','F','S','S'])) h += `<div class="cal-year-wd">${wd}</div>`;
const first = new Date(y, m, 1); const first = new Date(y, m, 1);
const dow = (first.getDay() + 6) % 7; const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const daysInMonth = new Date(y, m + 1, 0).getDate(); const daysInMonth = new Date(y, m + 1, 0).getDate();
for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>'; for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>';
for (let d = 1; d <= daysInMonth; d++) { for (let d = 1; d <= daysInMonth; d++) {
@@ -1966,10 +1968,10 @@ function _wireAll(body) {
const ad = document.getElementById('cal-f-allday'); const ad = document.getElementById('cal-f-allday');
if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); } if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); }
} else { } else {
const t1 = (ev.dtstart || '').match(/T(\d{2}:\d{2})/); const t1 = _fmtTime(ev.dtstart);
const t2 = (ev.dtend || '').match(/T(\d{2}:\d{2})/); const t2 = _fmtTime(ev.dtend);
if (t1) set('cal-f-start', t1[1]); if (t1) set('cal-f-start', t1);
if (t2) set('cal-f-end', t2[1]); if (t2) set('cal-f-end', t2);
document.getElementById('cal-f-start')?.dispatchEvent(new Event('input')); document.getElementById('cal-f-start')?.dispatchEvent(new Event('input'));
} }
// Make sure the details panel is open so the user can verify time. // Make sure the details panel is open so the user can verify time.
@@ -2474,6 +2476,13 @@ async function _showCalSettings() {
</div> </div>
<div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div> <div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div>
</div> </div>
<div style="border-top:1px solid var(--border);padding-top:12px;">
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Week starts on</div>
<div style="display:flex;gap:6px;">
<button id="cal-wstart-mon" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${!_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Monday</button>
<button id="cal-wstart-sun" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Sunday</button>
</div>
</div>
<div style="border-top:1px solid var(--border);padding-top:12px;"> <div style="border-top:1px solid var(--border);padding-top:12px;">
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div> <div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
@@ -2494,6 +2503,28 @@ async function _showCalSettings() {
overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup); overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup);
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
// Week-start toggle: save to localStorage, update module state, re-render.
const _monBtn = overlay.querySelector('#cal-wstart-mon');
const _sunBtn = overlay.querySelector('#cal-wstart-sun');
const _activeStyle = 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))';
const _inactiveStyle = 'var(--panel)';
const _applyWeekStartActive = () => {
if (_monBtn) _monBtn.style.background = _weekStartSun ? _inactiveStyle : _activeStyle;
if (_sunBtn) _sunBtn.style.background = _weekStartSun ? _activeStyle : _inactiveStyle;
};
_monBtn?.addEventListener('click', () => {
_weekStartSun = false;
localStorage.setItem('cal-week-start', 'mon');
_applyWeekStartActive();
if (_open) _render();
});
_sunBtn?.addEventListener('click', () => {
_weekStartSun = true;
localStorage.setItem('cal-week-start', 'sun');
_applyWeekStartActive();
if (_open) _render();
});
// Create a new (local) calendar. Defaults the name + next palette color, then // Create a new (local) calendar. Defaults the name + next palette color, then
// reopens the panel so the user can rename it inline and pick a color. // reopens the panel so the user can rename it inline and pick a color.
overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => { overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => {
@@ -2918,35 +2949,68 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
const startEl = document.getElementById('cal-f-start'); const startEl = document.getElementById('cal-f-start');
const endEl = document.getElementById('cal-f-end'); const endEl = document.getElementById('cal-f-end');
if (!startEl || !endEl) return; if (!startEl || !endEl) return;
const _toMin = (v) => { const _toMin = (v) => {
if (!v || !/^\d{2}:\d{2}$/.test(v)) return null; if (!v || !/^\d{2}:\d{2}$/.test(v)) return null;
const [h, m] = v.split(':').map(n => parseInt(n, 10)); const [h, m] = v.split(':').map(n => parseInt(n, 10));
return h * 60 + m; return h * 60 + m;
}; };
const _toHHMM = (mins) => { const _toHHMM = (mins) => {
let m = ((mins % 1440) + 1440) % 1440; let m = ((mins % 1440) + 1440) % 1440;
const hh = String(Math.floor(m / 60)).padStart(2, '0'); const hh = String(Math.floor(m / 60)).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0'); const mm = String(m % 60).padStart(2, '0');
return `${hh}:${mm}`; return `${hh}:${mm}`;
}; };
const _autoAdvanceEndDate = () => {
const isAD = document.getElementById('cal-f-allday')?.checked;
if (isAD) return;
const dv = document.getElementById('cal-f-date')?.value;
const dvEndEl = document.getElementById('cal-f-date-end');
if (!dv || !dvEndEl || dvEndEl.value !== dv) return;
const sVal = startEl.value;
const eVal = endEl.value;
if (sVal && eVal && eVal <= sVal) {
const d = new Date(`${dv}T00:00:00`);
d.setDate(d.getDate() + 1);
dvEndEl.value = _ds(d);
}
};
let prevStartMin = _toMin(startEl.value); let prevStartMin = _toMin(startEl.value);
endEl.addEventListener('input', () => { endEl.dataset.userEdited = '1'; });
endEl.addEventListener('input', () => {
endEl.dataset.userEdited = '1';
});
endEl.addEventListener('change', _autoAdvanceEndDate);
startEl.addEventListener('change', () => { startEl.addEventListener('change', () => {
const newStartMin = _toMin(startEl.value); const newStartMin = _toMin(startEl.value);
const endMin = _toMin(endEl.value); const endMin = _toMin(endEl.value);
if (newStartMin == null) { prevStartMin = newStartMin; return; }
// Compute the duration before the change. Use the user's existing if (newStartMin == null) {
// start→end gap, fallback to 1 hour.
let durationMin = 60;
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
durationMin = endMin - prevStartMin;
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
// User already set a custom end before changing start — leave it.
prevStartMin = newStartMin; prevStartMin = newStartMin;
return; return;
} }
let durationMin = 60;
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
durationMin = endMin - prevStartMin;
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
prevStartMin = newStartMin;
return;
}
endEl.value = _toHHMM(newStartMin + durationMin); endEl.value = _toHHMM(newStartMin + durationMin);
prevStartMin = newStartMin; prevStartMin = newStartMin;
_autoAdvanceEndDate();
}); });
})(); })();
// Custom reminder picker // Custom reminder picker
@@ -3007,6 +3071,20 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
// proper UTC instants (is_utc=True). Without this, naive "10:00" gets // proper UTC instants (is_utc=True). Without this, naive "10:00" gets
// re-interpreted as local elsewhere — the timezone-misfire bug. // re-interpreted as local elsewhere — the timezone-misfire bug.
const _tz = _tzOffset(); const _tz = _tzOffset();
if (!isAD) {
const startVal = document.getElementById('cal-f-start').value;
const endVal = document.getElementById('cal-f-end').value;
const startDt = new Date(`${dv}T${startVal}:00`);
const endDt = new Date(`${dvEnd}T${endVal}:00`);
if (endDt <= startDt) {
uiModule.showToast('End time must be after start time');
return;
}
}
const payload = { const payload = {
summary, summary,
dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`, dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`,
@@ -3215,6 +3293,37 @@ function _fmtTime(s) {
} }
return s.slice(11, 16); return s.slice(11, 16);
} }
function _timeToMin(iso) {
const hm = _fmtTime(iso);
if (!hm) return null;
const m = hm.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const h = parseInt(m[1], 10);
const min = parseInt(m[2], 10);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return h * 60 + min;
}
function _tzOffsetForDate(d) {
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const abs = Math.abs(off);
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
const mm = String(abs % 60).padStart(2, '0');
return `${sign}${hh}:${mm}`;
}
function _addMinutesToLocalIso(baseIso, addMinutes) {
const d = new Date(new Date(baseIso).getTime() + addMinutes * 60000);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${y}-${mo}-${da}T${h}:${m}:00${_tzOffsetForDate(d)}`;
}
function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// Linkify a location string: URLs become clickable, plain addresses get a Maps link. // Linkify a location string: URLs become clickable, plain addresses get a Maps link.
+2
View File
@@ -4,6 +4,8 @@
// No DOM, no fetch, no global mutable state — safe to import anywhere. // No DOM, no fetch, no global mutable state — safe to import anywhere.
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export const WEEKDAYS_SUN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']; 'July', 'August', 'September', 'October', 'November', 'December'];
+26 -7
View File
@@ -1564,9 +1564,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
.replace(/<channel\|>/gi, ''); .replace(/<channel\|>/gi, '');
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, ''); thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText); _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
// Keep thinking box scrolled to bottom // Keep thinking box scrolled to bottom, but let user scroll up
var thinkBox = _liveThinkInner.closest('.thinking-content'); var thinkBox = _liveThinkInner.closest('.thinking-content');
if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight; if (thinkBox) {
var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
}
} }
uiModule.scrollHistory(); uiModule.scrollHistory();
continue; continue;
@@ -3865,7 +3868,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Also submit on Enter (without shift) // Also submit on Enter (without shift)
editor.addEventListener('keydown', (e) => { editor.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { const isMobile = window.innerWidth <= 768
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
e.preventDefault(); e.preventDefault();
saveBtn.click(); saveBtn.click();
} }
@@ -3873,9 +3878,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} }
/** /**
* Resend a user message truncates history to that point and resubmits. * Resend a user message. Normal resend appends a fresh copy at the end of
* the current thread; regenerate flows can opt into replacing from here.
*/ */
export async function resendUserMessage(userMsgElement) { export async function resendUserMessage(userMsgElement, opts = {}) {
const replaceFromHere = Boolean(opts && opts.replaceFromHere);
const box = document.getElementById('chat-history'); const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg')); const allMsgs = Array.from(box.querySelectorAll('.msg'));
const msgIndex = allMsgs.indexOf(userMsgElement); const msgIndex = allMsgs.indexOf(userMsgElement);
@@ -3921,9 +3928,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const sessionId = sessionModule.getCurrentSessionId(); const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return; if (!sessionId) return;
// Truncate backend to keep everything before this user message
const keepCount = msgIndex;
try { try {
if (replaceFromHere) {
// Regenerate flows intentionally trim history to this point before
// resubmitting. The plain "Resend message" action must not do this.
const keepCount = msgIndex;
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -3940,6 +3949,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
sibling = next; sibling = next;
} }
_hideUserBubble = true; _hideUserBubble = true;
}
_pendingRegenAttachments = _ids; _pendingRegenAttachments = _ids;
// Resubmit // Resubmit
@@ -4473,6 +4483,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
* Delete an AI message and its preceding user message from the conversation. * Delete an AI message and its preceding user message from the conversation.
*/ */
export async function deleteMessage(msgElement) { export async function deleteMessage(msgElement) {
if (uiModule && uiModule.styledConfirm) {
const ok = await uiModule.styledConfirm('Delete this message?', {
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
});
if (!ok) return;
}
const box = document.getElementById('chat-history'); const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg')); const allMsgs = Array.from(box.querySelectorAll('.msg'));
const clickedIndex = allMsgs.indexOf(msgElement); const clickedIndex = allMsgs.indexOf(msgElement);
+1 -1
View File
@@ -362,7 +362,7 @@ function _openVisionEditor(att, userMsgEl) {
await _saveVisionText(); await _saveVisionText();
_closeVisionEditor(); _closeVisionEditor();
if (userMsgEl && window.chatModule?.resendUserMessage) { if (userMsgEl && window.chatModule?.resendUserMessage) {
window.chatModule.resendUserMessage(userMsgEl); window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true });
} else if (uiModule?.showToast) { } else if (uiModule?.showToast) {
uiModule.showToast('Saved'); uiModule.showToast('Saved');
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export const EVAL_PROMPTS = {
chat: [ chat: [
// ── ★ Featured — prompts that have actually broken frontier models ── // ── ★ 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: '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', 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.' }, { 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.' },
+9
View File
@@ -320,6 +320,15 @@ export const ERROR_PATTERNS = [
}}, }},
], ],
}, },
{
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native dependencies are missing on this server.',
fixes: [
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
{ {
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i, pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
message: 'SGLang is not installed or not in PATH.', message: 'SGLang is not installed or not in PATH.',
+75
View File
@@ -750,6 +750,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) { export function _hwfitRenderHw(el, sys) {
if (!el || !sys) return; if (!el || !sys) return;
// Cache system info globally so other modules can read VRAM without refetching // Cache system info globally so other modules can read VRAM without refetching
@@ -838,6 +912,7 @@ export function _hwfitRenderHw(el, sys) {
+ chip('cores', cores) + chip('cores', cores)
+ chip('backend', esc(sys.backend || '')) + chip('backend', esc(sys.backend || ''))
+ manualChip; + manualChip;
_renderHwVisibilityWarning(sys);
// Body click → toggle "off" (dimmed, still visible). Membership of // Body click → toggle "off" (dimmed, still visible). Membership of
// _dismissedHwChips is what the ranker reads, so both add+remove // _dismissedHwChips is what the ranker reads, so both add+remove
// here also flips the model list. The manual chip is excluded — // here also flips the model list. The manual chip is excluded —
+3 -2
View File
@@ -597,7 +597,8 @@ export function _buildServeCmd(f, modelName, backend) {
} else if (backend === 'diffusers') { } else if (backend === 'diffusers') {
const gpuStr = f.gpus?.trim(); const gpuStr = f.gpus?.trim();
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `; if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
cmd += `python3 scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`; const diffusersPy = _isWindows() ? 'python' : _py3Bin;
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`; if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`; if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`; if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
@@ -718,7 +719,7 @@ async function _fetchDependencies() {
const data = await resp.json(); const data = await resp.json();
const pkgs = data.packages || []; const pkgs = data.packages || [];
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; } if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
const _winUnsupported = new Set(['diffusers', 'hf_transfer', 'vllm', 'rembg', 'gfpgan']); const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`; if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
+14 -3
View File
@@ -793,9 +793,10 @@ function _winSessionCmd(task, tmuxArgs) {
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`; return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
} }
if (tmuxArgs.includes('kill-session')) { if (tmuxArgs.includes('kill-session')) {
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
const ps = host const ps = host
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue` ? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`; : `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`; return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
} }
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) { if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
@@ -3532,12 +3533,22 @@ async function _pollBackgroundStatus() {
// dead-session check inspects). Recover "done" from the retained output's // dead-session check inspects). Recover "done" from the retained output's
// exit-0 sentinel so a clean install isn't downgraded to crashed. // exit-0 sentinel so a clean install isn't downgraded to crashed.
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output); const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
// A finished model download whose tmux pane is gone is also reported
// "stopped" (the dead-session check can miss the landed snapshot).
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
// only after the runner exits 0 — so a completed download isn't
// downgraded to crashed. This background poll runs blind (no live
// stream to debounce against), so unlike the reconnect loop it keys
// off the conclusive exit sentinel only, never the `/snapshots/` path,
// which can be printed mid-stream for multi-file downloads.
const downloadDone = task.type === 'download'
&& String(task.output || '').includes('DOWNLOAD_OK');
const nextStatus = live.status === 'completed' const nextStatus = live.status === 'completed'
? 'done' ? 'done'
: (live.status === 'error' : (live.status === 'error'
? 'error' ? 'error'
: (live.status === 'stopped' : (live.status === 'stopped'
? (depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')) ? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
: null)); : null));
if (nextStatus && task.status !== nextStatus) { if (nextStatus && task.status !== nextStatus) {
updates.status = nextStatus; updates.status = nextStatus;
+2 -2
View File
@@ -530,7 +530,7 @@ function _rerenderCachedModels() {
: (_lastUsed || (_isLegacyFlat ? _allSs : {})); : (_lastUsed || (_isLegacyFlat ? _allSs : {}));
const detectedBackend = _detectBackend(m).backend; const detectedBackend = _detectBackend(m).backend;
const _allowedBackends = new Set(_isWindows() const _allowedBackends = new Set(_isWindows()
? ['llamacpp'] ? ['llamacpp', 'diffusers']
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers'])); : (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend)) const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
? ss.backend ? ss.backend
@@ -590,7 +590,7 @@ function _rerenderCachedModels() {
// Row 1: Backend + Server + Env // Row 1: Backend + Server + Env
panelHtml += `<div class="hwfit-serve-row">`; panelHtml += `<div class="hwfit-serve-row">`;
const _backendChoices = _isWindows() const _backendChoices = _isWindows()
? [['llamacpp','llama.cpp']] ? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]
: _isMetal() : _isMetal()
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal. // Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
? [['llamacpp','llama.cpp'],['ollama','Ollama']] ? [['llamacpp','llama.cpp'],['ollama','Ollama']]
+1 -1
View File
@@ -994,7 +994,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
stripe.style.bottom = '0'; stripe.style.bottom = '0';
stripe.style.width = '10px'; stripe.style.width = '10px';
stripe.style.cursor = 'col-resize'; stripe.style.cursor = 'col-resize';
stripe.style.zIndex = '9999'; stripe.style.zIndex = '261';
stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)'; stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
stripe.style.pointerEvents = 'auto'; stripe.style.pointerEvents = 'auto';
stripe.style.touchAction = 'none'; stripe.style.touchAction = 'none';
+3
View File
@@ -1099,6 +1099,9 @@ export function openPanel() {
if (_open) return; if (_open) return;
_open = true; _open = true;
_editingId = null; _editingId = null;
// Reset the search filter — the rebuilt pane's search input renders empty, so a
// stale _searchQuery would silently hide non-matching notes after a reopen.
_searchQuery = '';
_clearViewedReminderGlows(); _clearViewedReminderGlows();
_firedDotDismissedAt = Date.now(); _firedDotDismissedAt = Date.now();
try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {} try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {}
+1 -1
View File
@@ -354,7 +354,7 @@ function _buildPanelHTML() {
<span>Multi-step web research with an LLM-in-the-loop agent</span> <span>Multi-step web research with an LLM-in-the-loop agent</span>
</p> </p>
<div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div> <div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div>
<textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and why each one cost him" rows="4"></textarea> <textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and what each one cost him." rows="4"></textarea>
<div class="research-category-row" id="research-category-row"> <div class="research-category-row" id="research-category-row">
<button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button> <button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
<button class="research-cat" data-cat="product">Product</button> <button class="research-cat" data-cat="product">Product</button>
+5 -1
View File
@@ -3644,7 +3644,11 @@ async function initUnifiedIntegrations() {
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-api-save').addEventListener('click', async () => { el('uf-api-save').addEventListener('click', async () => {
const presetKey = preset.value || undefined; const presetKey = preset.value || undefined;
const body = { name: name.value, base_url: url.value, auth_type: auth.value, auth_header: header.value, preset: presetKey }; const nameValue = name.value.trim();
const urlValue = url.value.trim();
if (!nameValue) { el('uf-api-msg').textContent = 'Name required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
if (!urlValue) { el('uf-api-msg').textContent = 'Base URL required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
const body = { name: nameValue, base_url: urlValue, auth_type: auth.value, auth_header: header.value, preset: presetKey };
if (key.value) body.api_key = key.value; if (key.value) body.api_key = key.value;
try { try {
const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations'; const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
+6
View File
@@ -514,6 +514,8 @@ function _buildBuiltinCards() {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (e.target.closest('button, input, textarea')) return; if (e.target.closest('button, input, textarea')) return;
// Editing in progress → don't collapse on an outside-the-textarea click.
if (card.querySelector('.skill-md-editor')) return;
_expandBuiltinCard(card, b.name); _expandBuiltinCard(card, b.name);
}); });
return card; return card;
@@ -786,6 +788,10 @@ function renderSkillsList() {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (card._suppressNextClick) { card._suppressNextClick = false; return; } if (card._suppressNextClick) { card._suppressNextClick = false; return; }
if (e.target.closest('button, input, textarea')) return; if (e.target.closest('button, input, textarea')) return;
// While editing, a click on the card body (outside the textarea) must
// NOT collapse the card — that silently discards unsaved edits. Only
// Save/Cancel exit edit mode.
if (card.querySelector('.skill-md-editor')) return;
if (_selectMode) { if (_selectMode) {
const cb = card.querySelector('.skill-select-cb'); const cb = card.querySelector('.skill-select-cb');
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
+5 -2
View File
@@ -339,10 +339,13 @@ function _submitComposedMessage(text) {
const msgInput = document.getElementById('message'); const msgInput = document.getElementById('message');
const form = document.getElementById('chat-form'); const form = document.getElementById('chat-form');
if (!msgInput || !form) return false; if (!msgInput || !form) return false;
// The slash handler and app-level form debounce must both release before
// sending the pinned prompt, otherwise the follow-up submit is dropped.
setTimeout(() => {
msgInput.value = text; msgInput.value = text;
msgInput.dispatchEvent(new Event('input', { bubbles: true })); msgInput.dispatchEvent(new Event('input', { bubbles: true }));
if (typeof form.requestSubmit === 'function') form.requestSubmit(); form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); }, 350);
return true; return true;
} }

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