diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..13a2da69f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# Code owners. +# +# Every file is owned by the maintainer, so that when branch protection has +# "Require review from Code Owners" turned on, no pull request can be merged +# without the maintainer's review. This is the human gate that backs up the +# automated security checks. See docs/security-ci.md for how to turn it on. + +* @pewdiepie-archdaemon diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..e1e0bf13e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +# Dependabot keeps dependencies and pinned action versions current. +# +# Why this matters for security: every workflow in this repo pins its GitHub +# Actions to an exact commit (a SHA), which is safe but freezes them in time. +# Dependabot opens a small, reviewable pull request whenever a newer version +# exists -- for Python packages, npm packages, the Docker base image, and the +# pinned Actions themselves -- so staying patched does not require manual work. +# Updates are grouped so a week's bumps arrive as one PR per ecosystem, not a +# flood of separate ones. + +version: 2 +updates: + # Python dependencies (requirements.txt + requirements-optional.txt). + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + python: + patterns: ["*"] + + # Frontend / tooling npm packages (package.json). + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + + # The pinned action SHAs used across .github/workflows. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + actions: + patterns: ["*"] + + # The Docker base image in the Dockerfile. + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..a53835a05 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,61 @@ +# CodeQL code scanning +# +# Purpose: GitHub's own static analysis engine reads the application source +# (Python backend + the JavaScript frontend) and looks for real +# vulnerabilities -- SQL/command injection, path traversal, auth mistakes, +# unsafe deserialization. Findings appear in the repo's Security tab. This is +# the deepest check in the suite and the most valuable for a high-profile +# target. +# +# It runs on every push to main and on a weekly schedule (to catch newly +# disclosed query patterns against unchanged code). It deliberately does NOT +# run on pull requests: most PRs here come from forks, whose read-only token +# cannot publish results, which would produce confusing failures. To scan pull +# requests too, a maintainer can instead enable CodeQL "default setup" in +# Settings -> Security -> Code scanning (one toggle, no file needed) -- see +# docs/security-ci.md. + +name: CodeQL + +on: + push: + branches: [main] + schedule: + # Weekly, Monday 06:00 UTC. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: {} + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # publish results to the Security tab + strategy: + fail-fast: false + matrix: + # Both are interpreted, so CodeQL needs no build step (build-mode none). + language: [python, javascript-typescript] + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + languages: ${{ matrix.language }} + build-mode: none + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml new file mode 100644 index 000000000..71c4121a4 --- /dev/null +++ b/.github/workflows/container-scan.yml @@ -0,0 +1,52 @@ +# Container security: Dockerfile lint +# +# Purpose: the Docker image is how most people run Odysseus, so it is part of +# the attack surface. hadolint lints the Dockerfile for mistakes and insecure +# patterns (running as root longer than needed, unpinned base image, bad apt +# usage). Blocking. +# +# The image vulnerability scan (Trivy, advisory) lives in its own file, +# container-trivy.yml. Keeping it separate lets that advisory scan be +# path-filtered and held to a read-only token on pull requests without +# weakening this blocking gate, which must always report so a required check +# never hangs. +# +# Note: a separate open PR (#120) proposes a local `scripts/scan_image.py`. +# This job is complementary -- it is a CI gate, not a script a contributor has +# to remember to run. + +name: Container scan + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: container-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + hadolint: + name: hadolint (Dockerfile lint) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Lint Dockerfile + uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 + with: + dockerfile: Dockerfile + # DL3008: pinning apt package versions is impractical on a -slim base + # image. Debian purges old package versions from its repos, so a + # pinned version breaks future rebuilds. The base image itself is + # what should be pinned (tracked by Dependabot's docker ecosystem). + ignore: DL3008 diff --git a/.github/workflows/container-trivy.yml b/.github/workflows/container-trivy.yml new file mode 100644 index 000000000..025fefc16 --- /dev/null +++ b/.github/workflows/container-trivy.yml @@ -0,0 +1,125 @@ +# Container image vulnerability scan (advisory) +# +# Trivy builds the application image and scans it for known-vulnerable OS and +# Python packages. Advisory only -- it reports findings to the repo's Security +# tab without blocking a merge, because the image inevitably contains +# already-known CVEs in upstream packages that are not this project's bug. +# +# Split from the Dockerfile lint (container-scan.yml) for two reasons: +# +# - Least privilege. The image build runs Dockerfile instructions, which on a +# pull request are attacker-influenceable. That path (the `scan` job) is +# held to a read-only token and never publishes results. Only `publish`, +# which runs on push to main (curated, fast-forwarded from reviewed dev), +# gets security-events:write to upload SARIF. +# - Cost. Docs-only changes do not rebuild the image (paths-ignore below), +# matching docker-publish.yml. hadolint stays on the broad trigger in +# container-scan.yml so the blocking gate always reports. + +name: Container scan (Trivy) + +on: + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + workflow_dispatch: + +permissions: {} + +concurrency: + group: container-trivy-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Pull requests and manual runs: build and scan under a read-only token. + # The build executes PR-supplied Dockerfile instructions, so this job must + # not hold any write scope, and it does not upload to the Security tab. + scan: + name: Trivy (image scan, advisory) + if: github.event_name != 'push' + runs-on: ubuntu-latest + # Advisory: a CVE in an upstream package must not block a PR. + continue-on-error: true + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Build without pushing so a broken Dockerfile is caught here, and the + # exact image we ship is what gets scanned. + - name: Build image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: false + load: true + tags: odysseus:ci + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: odysseus:ci + format: table + ignore-unfixed: true + env: + # Pin the vuln DB source to GHCR to avoid rate-limited Docker Hub + # mirrors that flake on shared runners. + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 + + # Push to main only: build, scan, and publish SARIF to the Security tab. + # This is the only path that runs trusted code, so it is the only one granted + # security-events:write. + publish: + name: Trivy (image scan + SARIF upload) + if: github.event_name == 'push' + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + security-events: write # upload SARIF to the Security tab + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Build image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: false + load: true + tags: odysseus:ci + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: odysseus:ci + format: sarif + output: trivy-results.sarif + ignore-unfixed: true + env: + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 + + - name: Upload Trivy results + uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + sarif_file: trivy-results.sarif + category: trivy-image diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..85dc26ec6 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,71 @@ +# Supply-chain review +# +# Purpose: defend against "side-chain" / supply-chain attacks -- a pull request +# that adds (or bumps) a dependency to a version with a known vulnerability or a +# disallowed license. Two layers: +# +# - dependency-review: runs ONLY on pull requests. It compares the +# dependencies before and after the PR and blocks the merge if the change +# pulls in a package with a known security advisory. This is the gate. +# - pip-audit: scans the project's current Python requirements against the +# advisory database. Advisory only (it never blocks a merge), because it can +# flag a pre-existing issue in an already-shipped dependency. + +name: Dependency review + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Default-deny token; jobs grant only read access. +permissions: {} + +concurrency: + group: dependency-review-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: dependency-review (PR gate) + # Only meaningful on a pull request -- it needs a base..head diff to review. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + # Fail the PR on any newly introduced moderate-or-worse advisory. + fail-on-severity: moderate + + pip-audit: + name: pip-audit (advisory) + runs-on: ubuntu-latest + # Advisory: report known-vulnerable Python deps without blocking the merge. + continue-on-error: true + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Run pip-audit on requirements + run: | + set -euo pipefail + pip install pip-audit==2.10.0 + pip-audit -r requirements.txt -r requirements-optional.txt --strict diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 000000000..55825bedf --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,60 @@ +# Secret scanning +# +# Purpose: stop credentials (API keys, tokens, passwords, private keys) from +# ever living in the Git history. Odysseus deliberately keeps real secrets in +# files that are gitignored (.env, data/), but a slip in a future commit -- or a +# malicious pull request that sneaks one in -- would otherwise go unnoticed. +# This job reads the repository and the full commit history and fails if it +# finds anything that looks like a secret. +# +# It runs the official gitleaks BINARY directly (pinned to an exact version and +# verified against the project's published SHA-256 checksum) rather than the +# gitleaks GitHub Action, because the Action asks for a paid license on +# organization-owned repos. The binary is free and behaves identically. + +name: Secret scan + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Start with zero permissions; the single job opts back in to read-only. +permissions: {} + +concurrency: + group: secret-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Full history so a secret committed in an earlier commit (and later + # deleted) is still caught -- deletion does not remove it from Git. + fetch-depth: 0 + persist-credentials: false + + # Pinned version + checksum so a tampered release binary cannot run here. + # Bump VERSION/SHA256 together; the checksum comes from the matching + # gitleaks__checksums.txt on the GitHub release. + - name: Run gitleaks (pinned, checksum-verified) + env: + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + run: | + set -euo pipefail + TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL -o "${TARBALL}" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}" + echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c - + tar -xzf "${TARBALL}" gitleaks + # Scan the whole history. Findings print to the log and fail the job. + ./gitleaks git --no-banner --redact --verbose . diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 000000000..efe487319 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,80 @@ +# Workflow security (CI that audits the CI) +# +# Purpose: the GitHub Actions workflows themselves are an attack surface. A +# poorly written workflow can leak the repository token, run attacker-supplied +# code from a pull request, or pull in a tampered third-party action. These two +# tools check every workflow file in this repo for those mistakes: +# +# - actionlint: catches workflow syntax errors and shell-script bugs inside +# `run:` steps before they reach main. +# - zizmor: a security linter for Actions. Flags template-injection holes, +# unpinned actions, credential persistence, and over-broad token +# permissions -- exactly the patterns the rest of this CI is built to avoid. +# +# Add this early: it then audits every workflow added after it. + +name: Workflow security + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# Default-deny token; each job grants only read access to the code. +permissions: {} + +concurrency: + group: workflow-security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Pinned version + checksum so a tampered binary cannot run here. + - name: Run actionlint (pinned, checksum-verified) + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + TARBALL="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSL -o "${TARBALL}" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TARBALL}" + echo "${ACTIONLINT_SHA256} ${TARBALL}" | sha256sum -c - + tar -xzf "${TARBALL}" actionlint + ./actionlint -color + + zizmor: + name: zizmor (Actions SAST) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + # Pinned zizmor release. --offline keeps the audit hermetic (no network + # calls about the actions it inspects); --min-severity=low surfaces + # everything so nothing slips through under the gate. + - name: Run zizmor + run: | + set -euo pipefail + pip install zizmor==1.25.2 + zizmor --offline --min-severity=low .github/workflows/ diff --git a/.gitignore b/.gitignore index c48f6cd61..846e6cf74 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ docs/windows-port/ compound.config.json *.error.log _scratch/ +/odysseus/ diff --git a/LICENSE b/LICENSE index 7087e2d59..0c97efd25 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,235 @@ -MIT License +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 -Copyright (c) 2025 Odysseus Contributors +Copyright (C) 2007 Free Software Foundation, Inc. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + Preamble -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/README.md b/README.md index 4fae1d76b..bbc831c37 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls > the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart > library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or > tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue — -> not a Docker passthrough failure. Re-install the serve engine via +> not a Docker passthrough failure. Reinstall the serve engine via > **Cookbook → Dependencies** to get a CUDA-enabled build. > > The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside @@ -329,7 +329,7 @@ To expose Odysseus on a local network or Tailscale with HTTPS: | Package | Feature unlocked | |---------|-----------------| | `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. | -| `duckduckgo-search` | DuckDuckGo as a search provider option. | +| `ddgs` | DuckDuckGo as a search provider option. | | `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) | | `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). | @@ -451,7 +451,7 @@ All user data lives in `data/` (gitignored): `app.db` (sessions, messages, docum ## License -MIT -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). +AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). ``` | diff --git a/app.py b/app.py index 97906bd46..6958ac347 100644 --- a/app.py +++ b/app.py @@ -47,6 +47,7 @@ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.gzip import GZipMiddleware # Core imports from core.constants import ( @@ -55,7 +56,7 @@ from core.constants import ( ) from core.database import SessionLocal, ApiToken from core.middleware import SecurityHeadersMiddleware, is_cors_preflight -from core.auth import AuthManager +from core.auth import AuthManager, normalize_known_username from core.exceptions import ( SessionNotFoundError, InvalidFileUploadError, LLMServiceError, WebSearchError, @@ -104,6 +105,16 @@ app.add_middleware( ], ) +# ========= RESPONSE COMPRESSION (gzip) ========= +# The frontend's text assets (style.css, index.html, the JS bundles) shipped +# uncompressed on every cold load. gzip cuts CSS/JS/HTML by ~75-85% on the wire +# with no behavioural change. Starlette's GZipMiddleware excludes +# `text/event-stream` by default, so the SSE streams (chat, shell, research, +# model-probe — all served with media_type="text/event-stream") are never +# compressed or buffered; only complete bodies over minimum_size are. The +# security-header middleware composes cleanly on top. +app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6) + # ========= SECURITY HEADERS MIDDLEWARE ========= app.add_middleware(SecurityHeadersMiddleware) @@ -217,8 +228,16 @@ if AUTH_ENABLED: try: rows = db.query(ApiToken).filter(ApiToken.is_active == True).all() for r in rows: + owner_key = normalize_known_username(auth_manager.users, getattr(r, "owner", None)) + if not owner_key: + logger.warning( + "Ignoring active API token '%s' for unknown auth user '%s'", + getattr(r, "id", ""), + getattr(r, "owner", None), + ) + continue scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()] - new_map[r.token_prefix].append((r.id, r.token_hash, getattr(r, "owner", None), scopes)) + new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes)) finally: db.close() _token_cache.clear() @@ -472,14 +491,20 @@ components = initialize_managers(BASE_DIR, rag_manager) session_manager = components["session_manager"] from src.assistant_log import set_session_manager as _set_asst_sm _set_asst_sm(session_manager) +# Set the global session manager singleton (used by core.models.Session.add_message) +from core.models import set_session_manager_instance +set_session_manager_instance(session_manager) +app.state.session_manager = session_manager memory_manager = components["memory_manager"] memory_vector = components.get("memory_vector") upload_handler = components["upload_handler"] +app.state.upload_handler = upload_handler personal_docs_mgr = components["personal_docs_manager"] api_key_manager = components["api_key_manager"] preset_manager = components["preset_manager"] chat_processor = components["chat_processor"] research_handler = components["research_handler"] +app.state.research_handler = research_handler chat_handler = components["chat_handler"] model_discovery = components["model_discovery"] skills_manager = components["skills_manager"] @@ -529,9 +554,6 @@ upload_cleanup_task = None from routes.emoji_routes import setup_emoji_routes app.include_router(setup_emoji_routes()) -from routes.workspace_routes import setup_workspace_routes -app.include_router(setup_workspace_routes()) - # Sessions from routes.session_routes import setup_session_routes session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE} @@ -576,7 +598,7 @@ app.include_router(setup_preset_routes(preset_manager)) # Diagnostics from routes.diagnostics_routes import setup_diagnostics_routes -app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler)) +app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector)) # Cleanup from routes.cleanup_routes import setup_cleanup_routes @@ -654,6 +676,9 @@ app.include_router(setup_shell_routes()) from routes.cookbook_routes import setup_cookbook_routes app.include_router(setup_cookbook_routes()) +from routes.workspace_routes import setup_workspace_routes +app.include_router(setup_workspace_routes()) + # Hardware model fitting (cookbook "What Fits?" tab) from routes.hwfit_routes import setup_hwfit_routes app.include_router(setup_hwfit_routes()) @@ -926,16 +951,21 @@ async def _startup_event(): async def _warmup_endpoints(): try: import httpx - endpoints = model_discovery.get_endpoints() if model_discovery else [] - for ep in endpoints[:5]: - url = ep.get("url", "").replace("/chat/completions", "/models") - if url: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - await client.get(url) - logger.info(f"Warmup ping OK: {url}") - except Exception as e: - logger.debug(f"Warmup ping failed for endpoint: {e}") + # model_discovery has no get_endpoints(); that call raised + # AttributeError every run and silently disabled warmup/keepalive. + # Resolve the /models probe URLs via the real discovery API, off the + # event loop since discovery does a blocking port scan. + urls = ( + await asyncio.to_thread(model_discovery.warmup_ping_urls) + if model_discovery else [] + ) + for url in urls: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.get(url) + logger.info(f"Warmup ping OK: {url}") + except Exception as e: + logger.debug(f"Warmup ping failed for endpoint: {e}") except Exception as e: logger.debug(f"Warmup ping skipped: {e}") diff --git a/core/auth.py b/core/auth.py index 5db2fed4c..2f9fd4e51 100644 --- a/core/auth.py +++ b/core/auth.py @@ -67,6 +67,14 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"}) +def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]: + """Return a normalized username only when it exists in the auth user map.""" + key = str(username or "").strip().lower() + if not key or key not in users: + return None + return key + + def _hash_password(password: str) -> str: return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") @@ -96,6 +104,7 @@ class AuthManager: self._load() self._load_sessions() self._migrate_single_user() + self._drop_reserved_loaded_users() self._migrate_legacy_admin_role() def _load(self): @@ -148,7 +157,13 @@ class AuthManager: def _migrate_single_user(self): """Migrate old single-user format to multi-user format.""" if "password_hash" in self._config and "users" not in self._config: - old_user = self._config.get("username", "admin") + old_user = str(self._config.get("username", "admin") or "admin").strip().lower() + if old_user in RESERVED_USERNAMES: + logger.warning( + "Migrating legacy single-user reserved username '%s' to 'admin'", + old_user, + ) + old_user = "admin" old_hash = self._config["password_hash"] self._config = { "users": { @@ -162,6 +177,30 @@ class AuthManager: self._save() logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})") + def _drop_reserved_loaded_users(self): + """Fail closed for legacy/manual auth rows that collide with sentinels.""" + users = self._config.get("users") + if not isinstance(users, dict): + return + normalized = {} + removed = [] + for username, data in users.items(): + key = str(username or "").strip().lower() + if not key: + continue + if key in RESERVED_USERNAMES: + removed.append(key) + continue + normalized[key] = data + if removed or normalized != users: + self._config["users"] = normalized + self._save() + if removed: + logger.warning( + "Removed reserved username(s) from auth config: %s", + ", ".join(sorted(set(removed))), + ) + def _migrate_legacy_admin_role(self): """Normalize setup.py's old role='admin' marker to is_admin=True.""" changed = False @@ -244,6 +283,22 @@ class AuthManager: return False if not self.users.get(requesting_user, {}).get("is_admin"): return False + # Revoke API bearer tokens before removing the auth row. The bearer + # path authenticates from ApiToken rows and does not require the + # owner to still exist, so a successful delete must not leave active + # rows behind. If the token store is unavailable, fail closed and + # keep the user/session state intact so the admin can retry. + try: + from core.database import get_db_session, ApiToken + with get_db_session() as db: + removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete() + if removed_tokens: + logger.info( + f"Revoked {removed_tokens} API token(s) owned by deleted user '{username}'" + ) + except Exception: + logger.warning(f"Failed to revoke API tokens for deleted user '{username}'") + return False del self._config["users"][username] self._save() # Purge all sessions belonging to this user. validate_token doesn't @@ -258,18 +313,6 @@ class AuthManager: revoked += 1 if revoked: self._save_sessions() - # Also revoke API bearer tokens owned by this user. The bearer auth - # path authenticates straight against ApiToken rows and never - # re-checks that the owner still exists, so leaving the rows behind - # would let a deleted user keep full API access indefinitely. - try: - from core.database import get_db_session, ApiToken - with get_db_session() as db: - removed = db.query(ApiToken).filter(ApiToken.owner == username).delete() - if removed: - logger.info(f"Revoked {removed} API token(s) owned by deleted user '{username}'") - except Exception: - logger.warning(f"Failed to revoke API tokens for deleted user '{username}'") logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)") return True diff --git a/core/database.py b/core/database.py index ee365c30c..6eec48d11 100644 --- a/core/database.py +++ b/core/database.py @@ -688,6 +688,7 @@ def _migrate_add_last_message_at_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -713,10 +714,14 @@ def _migrate_add_last_message_at_column(): "ON sessions(archived, last_message_at)" ) conn.commit() - conn.close() logging.getLogger(__name__).info("Migrated: added + backfilled 'last_message_at' on sessions") except Exception as e: logging.getLogger(__name__).warning(f"last_message_at migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_document_archived_column(): """Add `archived` to documents (soft-archive flag). Guarded + idempotent.""" @@ -724,6 +729,7 @@ def _migrate_add_document_archived_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(documents)") @@ -732,9 +738,13 @@ def _migrate_add_document_archived_column(): conn.execute("ALTER TABLE documents ADD COLUMN archived BOOLEAN DEFAULT 0") conn.commit() logging.getLogger(__name__).info("Migrated: added 'archived' to documents") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"documents.archived migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_owner_column(): @@ -743,6 +753,7 @@ def _migrate_add_owner_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -752,9 +763,13 @@ def _migrate_add_owner_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_sessions_owner ON sessions(owner)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'owner' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_model_endpoints(): """Recreate model_endpoints table if schema changed (url->base_url).""" @@ -762,6 +777,7 @@ def _migrate_model_endpoints(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -770,9 +786,13 @@ def _migrate_model_endpoints(): conn.execute("DROP TABLE IF EXISTS model_endpoints") conn.commit() logging.getLogger(__name__).info("Migrated: dropped old model_endpoints table (schema change)") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints migration check failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_hidden_models_column(): """Add hidden_models column to model_endpoints if it doesn't exist.""" @@ -780,6 +800,7 @@ def _migrate_add_hidden_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -788,9 +809,13 @@ def _migrate_add_hidden_models_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN hidden_models TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'hidden_models' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"hidden_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_endpoint_owner_column(): """Add owner column to model_endpoints if it doesn't exist. @@ -805,6 +830,7 @@ def _migrate_add_model_endpoint_owner_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -814,9 +840,13 @@ def _migrate_add_model_endpoint_owner_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_owner ON model_endpoints(owner)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'owner' column + index to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints.owner migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_provider_auth_id_column(): @@ -825,6 +855,7 @@ def _migrate_add_provider_auth_id_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -834,9 +865,13 @@ def _migrate_add_provider_auth_id_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_provider_auth_id ON model_endpoints(provider_auth_id)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'provider_auth_id' column + index to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints.provider_auth_id migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_type_column(): @@ -845,6 +880,7 @@ def _migrate_add_model_type_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -853,9 +889,13 @@ def _migrate_add_model_type_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_type TEXT DEFAULT 'llm'") conn.commit() logging.getLogger(__name__).info("Migrated: added 'model_type' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_type migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_endpoint_refresh_columns(): """Add endpoint classification / refresh policy columns if missing.""" @@ -863,6 +903,7 @@ def _migrate_add_model_endpoint_refresh_columns(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -876,9 +917,13 @@ def _migrate_add_model_endpoint_refresh_columns(): if columns and "model_refresh_timeout" not in columns: conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_refresh_timeout INTEGER") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints refresh-policy migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_task_run_model_column(): """Add model column to task_runs if it doesn't exist (records which model ran).""" @@ -886,6 +931,7 @@ def _migrate_add_task_run_model_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(task_runs)") @@ -894,9 +940,13 @@ def _migrate_add_task_run_model_column(): conn.execute("ALTER TABLE task_runs ADD COLUMN model TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'model' column to task_runs") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"task_runs model migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_supports_tools_column(): """Add supports_tools column to model_endpoints if it doesn't exist.""" @@ -904,6 +954,7 @@ def _migrate_add_supports_tools_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -912,9 +963,13 @@ def _migrate_add_supports_tools_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN supports_tools BOOLEAN") conn.commit() logging.getLogger(__name__).info("Migrated: added 'supports_tools' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"supports_tools migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_cached_models_column(): @@ -923,6 +978,7 @@ def _migrate_add_cached_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -930,9 +986,13 @@ def _migrate_add_cached_models_column(): if columns and "cached_models" not in columns: conn.execute("ALTER TABLE model_endpoints ADD COLUMN cached_models TEXT") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"cached_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_pinned_models_column(): """Add pinned_models column to model_endpoints if it doesn't exist.""" @@ -940,6 +1000,7 @@ def _migrate_add_pinned_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -948,9 +1009,13 @@ def _migrate_add_pinned_models_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN pinned_models TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'pinned_models' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"pinned_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_notes_sort_order(): """Add sort_order, image_url, repeat columns to notes if they don't exist.""" @@ -958,6 +1023,7 @@ def _migrate_add_notes_sort_order(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(notes)") @@ -975,9 +1041,13 @@ def _migrate_add_notes_sort_order(): if columns and "agent_session_id" not in columns: conn.execute("ALTER TABLE notes ADD COLUMN agent_session_id TEXT") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"notes migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_mode_column(): """Add mode column to sessions table if it doesn't exist.""" @@ -985,6 +1055,7 @@ def _migrate_add_mode_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -993,9 +1064,13 @@ def _migrate_add_mode_column(): conn.execute("ALTER TABLE sessions ADD COLUMN mode TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'mode' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for mode failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_folder_column(): """Add folder column to sessions table if it doesn't exist.""" @@ -1003,6 +1078,7 @@ def _migrate_add_folder_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -1011,9 +1087,13 @@ def _migrate_add_folder_column(): conn.execute("ALTER TABLE sessions ADD COLUMN folder TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'folder' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for folder failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_token_columns(): """Add cumulative token tracking columns to sessions table.""" @@ -1021,6 +1101,7 @@ def _migrate_add_token_columns(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -1030,9 +1111,13 @@ def _migrate_add_token_columns(): conn.execute("ALTER TABLE sessions ADD COLUMN total_output_tokens INTEGER DEFAULT 0") conn.commit() logging.getLogger(__name__).info("Migrated: added token tracking columns to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for token columns failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_owner_to_table(table_name: str, index_name: str): """Generic helper: add owner TEXT column + index to a table if missing.""" @@ -1040,6 +1125,7 @@ def _migrate_add_owner_to_table(table_name: str, index_name: str): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute(f"PRAGMA table_info({table_name})") @@ -1049,9 +1135,13 @@ def _migrate_add_owner_to_table(table_name: str, index_name: str): conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name}(owner)") conn.commit() logging.getLogger(__name__).info(f"Migrated: added 'owner' column to {table_name}") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration owner column for {table_name} failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_multiuser_owner_columns(): """Add owner column to memories, gallery_images, user_tools, comparisons.""" @@ -1076,6 +1166,7 @@ def _migrate_add_api_token_scopes_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) columns = [row[1] for row in conn.execute("PRAGMA table_info(api_tokens)").fetchall()] @@ -1084,9 +1175,13 @@ def _migrate_add_api_token_scopes_column(): conn.execute("UPDATE api_tokens SET scopes = 'chat' WHERE scopes IS NULL OR scopes = ''") conn.commit() logging.getLogger(__name__).info("Migrated: added scopes column to api_tokens") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"api_tokens.scopes migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_assign_legacy_owner(): """Assign all null-owner data to the first (admin) user. @@ -1128,6 +1223,7 @@ def _migrate_assign_legacy_owner(): return logger = logging.getLogger(__name__) + conn = None try: conn = sqlite3.connect(db_path) # Every table with an `owner` column. New tables added later will be @@ -1152,9 +1248,13 @@ def _migrate_assign_legacy_owner(): except Exception as e: logger.warning(f"Legacy owner assignment for {table} failed: {e}") conn.commit() - conn.close() except Exception as e: logger.warning(f"Legacy owner migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass # Also migrate memory.json mem_path = MEMORY_FILE @@ -1773,6 +1873,7 @@ def _migrate_add_email_smtp_security(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(email_accounts)") @@ -1788,9 +1889,13 @@ def _migrate_add_email_smtp_security(): ) conn.commit() logging.getLogger(__name__).info("Migrated: added smtp_security column to email_accounts") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"smtp_security migration skipped: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_encrypt_endpoint_keys(): @@ -1891,6 +1996,7 @@ def _migrate_add_calendar_is_utc(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1899,9 +2005,13 @@ def _migrate_add_calendar_is_utc(): conn.execute("ALTER TABLE calendar_events ADD COLUMN is_utc BOOLEAN DEFAULT 0 NOT NULL") conn.commit() logging.getLogger(__name__).info("Migrated: added 'is_utc' column to calendar_events") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"is_utc migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_origin(): @@ -1912,6 +2022,7 @@ def _migrate_add_calendar_origin(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1921,9 +2032,13 @@ def _migrate_add_calendar_origin(): conn.execute("CREATE INDEX IF NOT EXISTS ix_calendar_events_origin ON calendar_events(origin)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'origin' column to calendar_events") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_account_id(): @@ -1933,6 +2048,7 @@ def _migrate_add_calendar_account_id(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendars)") @@ -1942,9 +2058,13 @@ def _migrate_add_calendar_account_id(): conn.execute("CREATE INDEX IF NOT EXISTS ix_calendars_account_id ON calendars(account_id)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'account_id' column to calendars") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendars.account_id migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_metadata(): @@ -1953,6 +2073,7 @@ def _migrate_add_calendar_metadata(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1964,9 +2085,13 @@ def _migrate_add_calendar_metadata(): if columns and "last_pinged" not in columns: conn.execute("ALTER TABLE calendar_events ADD COLUMN last_pinged DATETIME") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendar_events migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def get_db(): """ diff --git a/core/models.py b/core/models.py index 1adae65ed..56f05dc4e 100644 --- a/core/models.py +++ b/core/models.py @@ -11,14 +11,24 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: from .session_manager import SessionManager -# Module-level session manager reference (set at app startup) -_session_manager: Optional["SessionManager"] = None +# Module-level session manager singleton (single source of truth) +_SESSION_MANAGER_INSTANCE: Optional["SessionManager"] = None -def set_session_manager(manager: "SessionManager"): - """Set the global session manager reference.""" - global _session_manager - _session_manager = manager +def set_session_manager_instance(manager: "SessionManager"): + """Set the global SessionManager singleton.""" + global _SESSION_MANAGER_INSTANCE + _SESSION_MANAGER_INSTANCE = manager + + +def get_session_manager_instance() -> Optional["SessionManager"]: + """Get the global SessionManager singleton.""" + return _SESSION_MANAGER_INSTANCE + + +# Keep legacy name for backward compatibility +set_session_manager = set_session_manager_instance +get_session_manager = get_session_manager_instance @dataclass @@ -42,7 +52,17 @@ class ChatMessage: @dataclass class Session: - """A chat session — pure data container.""" + """A chat session — pure data container. + + ``.history`` is the authoritative mutable message list. Callers may + read, append, pop, or reassign it directly — these changes take + effect immediately. ``_history`` remains a compatibility alias that + always resolves to the authoritative ``history`` list. + + Each session gets its own unique history list at construction time + (the dataclass default is never shared between instances). + """ + id: str name: str endpoint_url: str @@ -56,24 +76,35 @@ class Session: message_count: int = 0 def __post_init__(self): - if self.history is None: - self.history = [] if self.headers is None: self.headers = {} + # Ensure each session gets its OWN list (not the shared dataclass default) + if self.history is None: + self.history = [] + + @property + def _history(self) -> List[ChatMessage]: + """Compatibility alias for callers that still reference ``_history``.""" + return self.history + + @_history.setter + def _history(self, messages: List[ChatMessage]): + self.history = messages def add_message(self, message: ChatMessage): """ Add a message to this session. - Delegates to SessionManager for persistence if available, - otherwise just appends to history. + Appends to the authoritative history list and increments + message_count. Delegates to SessionManager for persistence + if available. """ self.history.append(message) self.message_count = len(self.history) # Delegate to session manager for persistence - if _session_manager: - _session_manager._persist_message(self.id, message) + if _SESSION_MANAGER_INSTANCE: + _SESSION_MANAGER_INSTANCE._persist_message(self.id, message) def get_context_messages(self) -> List[Dict[str, Any]]: """Get messages in format for LLM API. @@ -94,3 +125,7 @@ class Session: def get(self, key: str, default=None): """Dict-like access for compatibility.""" return getattr(self, key, default) + + def __getitem__(self, key: str): + """Allow session['field'] syntax.""" + return getattr(self, key) diff --git a/core/platform_compat.py b/core/platform_compat.py index 3eda4a107..efa496ac6 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -191,6 +191,8 @@ def _windows_bash_fallbacks() -> List[str]: base = os.environ.get(env_name) if base: roots.append(ntpath.join(base, "Git")) + if env_name == "LocalAppData": + roots.append(ntpath.join(base, "Programs", "Git")) roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS) paths: List[str] = [] @@ -298,7 +300,7 @@ def is_wsl() -> bool: import sys if sys.platform.startswith("linux") or os.name == "posix": try: - with open("/proc/version", "r") as f: + with open("/proc/version", "r", encoding="utf-8", errors="ignore") as f: if "microsoft" in f.read().lower(): return True except Exception: @@ -366,6 +368,10 @@ def _ssh_exec_argv( strict_host_key_checking: bool | None = None, ) -> list[str]: """Build a consistent ssh argv for remote command execution.""" + remote_value = str(remote or "").strip() + remote_host = remote_value.rsplit("@", 1)[-1] + if not remote_value or remote_value.startswith("-") or not remote_host or remote_host.startswith("-"): + raise ValueError("Invalid SSH remote host") argv = ["ssh"] if connect_timeout is not None: argv.extend(["-o", f"ConnectTimeout={int(connect_timeout)}"]) diff --git a/core/session_manager.py b/core/session_manager.py index ecc23e088..914205a7d 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -17,6 +17,9 @@ from typing import Dict, Optional from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive from .models import Session, ChatMessage +# Re-export singleton accessors from models for convenience +from .models import set_session_manager_instance, get_session_manager_instance + logger = logging.getLogger(__name__) @@ -188,12 +191,17 @@ class SessionManager: """ Add a message to a session and persist to database. + Updates the authoritative history list and persists through this + manager directly so tests and temporary managers do not depend on the + process-wide session-manager singleton. + Args: session_id: Session ID message: ChatMessage to add """ session = self.get_session(session_id) session.history.append(message) + session._history = session.history session.message_count = len(session.history) self._persist_message(session_id, message) @@ -232,7 +240,10 @@ class SessionManager: ) db.add(db_message) - db_session.message_count = len(self.sessions.get(session_id, {}).history) if session_id in self.sessions else 0 + if session_id in self.sessions: + db_session.message_count = len(self.sessions[session_id].history) + else: + db_session.message_count = 0 _now = datetime.now(timezone.utc) db_session.last_accessed = _now # Clean "last conversation" timestamp — only bumped here on a @@ -283,6 +294,7 @@ class SessionManager: # Update in-memory session.history = session.history[:keep_count] + session._history = session.history logger.info(f"Truncated session {session_id} to {keep_count} messages") return True @@ -333,6 +345,7 @@ class SessionManager: db.commit() session.history = list(messages) + session._history = session.history session.message_count = len(messages) logger.info("Replaced session %s history with %d messages", session_id, len(messages)) return True @@ -608,24 +621,52 @@ class SessionManager: def save_sessions(self): """No-op for DB compatibility.""" + def ensure_task_session(self, session_id: str, name: str, endpoint_url: str, model: str, owner: str = None, task: object = None) -> Session: + """Create a task session if it doesn't exist, or return the existing one. + + Unlike create_session, this checks the cache first and does NOT + overwrite an existing in-memory session. The task scheduler must + use this instead of direct dict assignment. + """ + if session_id in self.sessions: + return self.sessions[session_id] + + session = self.create_session(session_id, name, endpoint_url, model, owner=owner) + if task is not None: + task.session_id = session_id + return session + # ------------------------------------------------------------------ # Cleanup # ------------------------------------------------------------------ - def cleanup_empty_sessions(self, auto_archive_days: int = 30) -> dict: - """Clean up empty and old sessions.""" + def cleanup_empty_sessions(self, auto_archive_days: int = 30, min_age_hours: int = 1) -> dict: + """Clean up empty and old sessions. + + Args: + auto_archive_days: Age in days before non-important sessions are archived. + min_age_hours: Minimum age in hours before an empty session can be deleted. + Prevents deleting sessions that were just created. + """ db = SessionLocal() stats = {'deleted_empty': 0, 'archived_old': 0, 'total_checked': 0} try: all_sessions = db.query(DbSession).all() cutoff_date = utcnow_naive() - timedelta(days=auto_archive_days) + min_age = utcnow_naive() - timedelta(hours=min_age_hours) for db_session in all_sessions: stats['total_checked'] += 1 - # Delete empty sessions + # Delete empty sessions only if older than min_age_hours if db_session.message_count == 0: + if db_session.created_at is not None: + created = db_session.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + if created > min_age: + continue # Too young to delete if db_session.id in self.sessions: del self.sessions[db_session.id] db.delete(db_session) diff --git a/docs/index.html b/docs/index.html index 540237840..f740e0bb9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -25,9 +25,16 @@ --radius: 8px; } * { box-sizing: border-box; } - html { scroll-behavior: smooth; scroll-snap-type: y proximity; scroll-padding-top: 60px; } - /* Each section is a full-viewport "page" with its content centered, so only - one shows at a time and the snap is obvious. */ + html { scroll-behavior: smooth; scroll-padding-top: 60px; } + /* REMOVED: "scroll-snap-type: y proximity" + The idea was: >>Each section is a full-viewport "page" with its content centered, + so only one shows at a time and the snap is obvious.<< + + PROBLEM: sections easily grow taller than 100vh IRL + This cause forced jumps mid-read. It's intrusive UX. + The landing-page is not a PowerPoint presentation! + + Preserved: CSS snap-points to avoid destroying code meta-data*/ .hero, section { scroll-snap-align: start; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; diff --git a/docs/security-ci.md b/docs/security-ci.md new file mode 100644 index 000000000..c25838f72 --- /dev/null +++ b/docs/security-ci.md @@ -0,0 +1,102 @@ +# Security CI guide + +This project runs a set of automated security checks on every pull request and +on every push to `main`. This page explains what each one does, whether it can +block a merge, and the few one-time settings you should turn on to get the full +benefit. + +## What runs, and why + +Each check lives in its own file under `.github/workflows/`. They run +automatically; you do not start them. + +| Check | What it protects against | Blocks a merge? | +|---|---|---| +| **Secret scan** (gitleaks) | An API key, token, or password being committed by mistake or on purpose | Yes | +| **Workflow security** (actionlint + zizmor) | A broken or insecure automation file that could leak the repo's access token | Yes | +| **Dependency review** | A pull request that adds a software library with a known security hole | Yes | +| **pip-audit** | Known security holes in the Python libraries already used | No (advisory) | +| **Container scan: hadolint** | Mistakes and insecure patterns in the `Dockerfile` | Yes | +| **Container scan: Trivy** | Known security holes in the Docker image | No (advisory) | +| **CodeQL** | Real bugs in the app's own code: injection, auth mistakes, path traversal | No (advisory) | + +"Blocks a merge" means a red X appears on the pull request and, once you enable +the setting below, the **Merge** button is disabled until it is fixed. + +"Advisory" means it reports problems into the repository's **Security** tab so +you can review them on your own schedule, but it never stops a merge. These are +advisory on purpose: they often flag long-standing issues in other people's +libraries, not something a given pull request introduced. + +## Where results appear + +- **Checks tab of a pull request**: the pass/fail of each check. A green tick is + good; a red X needs attention. +- **Security tab of the repository**: detailed findings from the advisory + scanners (Trivy and CodeQL). This is your dashboard. + +## If a check fails + +- **Secret scan failed**: a real credential may have been committed. Treat it as + leaked: rotate (regenerate) that key or token immediately, then remove it from + the file. Do not just delete the commit; assume it was seen. +- **Dependency review failed**: the pull request adds a library with a known + vulnerability. Ask the contributor to use a patched version, or decline the + change. +- **hadolint / workflow security failed**: the contributor changed the + `Dockerfile` or an automation file in a way the linter rejects. Ask them to + address the message shown in the failed check. + +## One-time settings to turn on + +These two settings unlock the full value. You only do them once. + +### 1. Require the blocking checks before merging + +This makes the **Merge** button refuse to work until the gating checks pass. + +1. Go to the repository on GitHub. +2. Click **Settings** (top right of the repo). +3. In the left sidebar, click **Branches**. +4. Under **Branch protection rules**, click **Add branch ruleset** (or **Add + rule**), and set the branch name pattern to `dev` (this is the branch all + pull requests target; `main` is fast-forwarded at releases). +5. Enable **Require status checks to pass before merging**. +6. In the search box that appears, add these checks by name: + - `Python syntax (compileall)` + - `JS syntax (node --check)` + - `gitleaks` + - `actionlint` + - `zizmor (Actions SAST)` + - `hadolint (Dockerfile lint)` + - `dependency-review (PR gate)` + + The first two come from the correctness CI (`ci.yml`); the rest are this + security suite. Leave pytest, pip-audit, Trivy, and CodeQL unchecked so they + stay advisory. +7. Also enable **Require a pull request before merging** and **Require review + from Code Owners** (this uses the `.github/CODEOWNERS` file so every change + needs your sign-off). +8. Click **Create** / **Save changes**. + +Note: a check name only appears in the list after it has run at least once, so +let the workflows run on one pull request first, then add them here. + +### 2. Turn on the Security tab features + +1. **Settings -> Code security** (or **Code security and analysis**). +2. Turn on **Dependency graph** (usually on by default for public repos) -- this + powers Dependency review and Dependabot. +3. Turn on **Dependabot alerts** and **Dependabot security updates**. +4. Under **Code scanning**, you have two ways to scan the app code with CodeQL: + - The included `codeql.yml` workflow already scans `main` and runs weekly. + - To also scan **pull requests** (recommended, since most contributions come + from forks), click **Set up -> Default** under Code scanning. GitHub then + runs CodeQL on pull requests for you, with no token limitations. + +## Keeping it current + +`.github/dependabot.yml` opens small weekly pull requests to update Python and +npm packages, the Docker base image, and the pinned automation actions +themselves. Review and merge those like any other pull request; they keep the +project patched without manual tracking. diff --git a/launch-windows.ps1 b/launch-windows.ps1 index 88ede8d66..8b53c43e6 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -30,14 +30,26 @@ function Fail($msg) { exit 1 } +function Test-WindowsBashStub($path) { + if (-not $path) { return $false } + $lowered = $path.ToLowerInvariant() + foreach ($stub in @("system32\bash.exe", "sysnative\bash.exe", "windowsapps\bash.exe")) { + if ($lowered.Contains($stub)) { return $true } + } + return $false +} + function Find-GitBash { $cmd = Get-Command bash -ErrorAction SilentlyContinue - if ($cmd) { return $cmd.Source } + if ($cmd -and -not (Test-WindowsBashStub $cmd.Source)) { return $cmd.Source } $roots = @() foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) { $base = [Environment]::GetEnvironmentVariable($name) - if ($base) { $roots += (Join-Path $base "Git") } + if ($base) { + $roots += (Join-Path $base "Git") + if ($name -eq "LocalAppData") { $roots += (Join-Path $base "Programs\Git") } + } } $roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git") diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index d1c2ac07e..b807937cd 100644 --- a/mcp_servers/email_server.py +++ b/mcp_servers/email_server.py @@ -22,6 +22,7 @@ import os import os.path from pathlib import Path from datetime import datetime, timedelta +import uuid from mcp.server import Server from mcp.server.stdio import stdio_server @@ -67,6 +68,59 @@ def _db_path() -> Path: return Path(APP_DB) +def _load_email_writing_style() -> str: + """Return the existing Settings > Email > Writing Style value.""" + try: + settings_path = DATA_DIR / "settings.json" + if not settings_path.exists(): + return "" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + return str(settings.get("email_writing_style") or "").strip() + except Exception: + return "" + + +def _writing_style_guidance() -> str: + style = _load_email_writing_style() + if not style: + return ( + "No saved writing style is configured in Settings > Email > Writing Style. " + "Use a concise, natural tone and do not invent facts." + ) + return ( + "Use this saved writing style from Settings > Email > Writing Style when " + "drafting the body. It overrides generic tone guidance:\n" + f"{style}" + ) + + +def _default_document_owner() -> str | None: + """Best-effort owner for MCP-created documents. + + MCP stdio tools do not receive the browser request's authenticated user, + but the document library is owner-filtered. Stamp drafts to the configured + single/default admin so assistant-created email drafts are visible. + """ + owner = os.environ.get("ODYSSEUS_DOCUMENT_OWNER", "").strip() + if owner: + return owner + try: + auth_path = DATA_DIR / "auth.json" + if not auth_path.exists(): + return None + users = (json.loads(auth_path.read_text(encoding="utf-8")).get("users") or {}) + if not isinstance(users, dict) or not users: + return None + admins = [name for name, data in users.items() if isinstance(data, dict) and data.get("is_admin")] + if len(admins) == 1: + return admins[0] + if len(users) == 1: + return next(iter(users)) + return admins[0] if admins else next(iter(users)) + except Exception: + return None + + def _list_accounts_raw() -> list: """Return list of dicts from the email_accounts table. Empty list if table missing or empty. Never raises.""" @@ -896,6 +950,340 @@ def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, b } +def _build_email_document_content( + to, + subject, + body, + *, + cc=None, + bcc=None, + in_reply_to=None, + references=None, + source_uid=None, + source_folder=None, +): + header_lines = [f"To: {to or ''}"] + if cc: + header_lines.append(f"Cc: {cc}") + if bcc: + header_lines.append(f"Bcc: {bcc}") + header_lines.append(f"Subject: {subject or ''}") + if in_reply_to: + header_lines.append(f"In-Reply-To: {in_reply_to}") + if references: + header_lines.append(f"References: {references}") + if source_uid: + header_lines.append(f"X-Source-UID: {source_uid}") + if source_folder: + header_lines.append(f"X-Source-Folder: {source_folder}") + return "\n".join(header_lines) + "\n---\n" + (body or "") + + +def _merge_email_reply_body(existing_content: str, reply_body: str) -> str: + """Preserve email headers and quoted chain while replacing the editable reply body.""" + if "\n---\n" not in (existing_content or ""): + return reply_body or "" + head, body = existing_content.split("\n---\n", 1) + quote_markers = ( + "---------- Previous message ----------", + "-----Original Message-----", + "----- Original Message -----", + ) + quote_index = -1 + for marker in quote_markers: + idx = body.find(marker) + if idx != -1 and (quote_index == -1 or idx < quote_index): + quote_index = idx + quote = body[quote_index:].strip() if quote_index != -1 else "" + merged_body = (reply_body or "").strip() + if quote: + merged_body = f"{merged_body}\n\n{quote}" if merged_body else quote + return f"{head}\n---\n{merged_body}" + + +def _create_email_draft_document( + *, + to, + subject, + body, + title=None, + cc=None, + bcc=None, + in_reply_to=None, + references=None, + source_uid=None, + source_folder=None, + account=None, + source_message_id=None, +): + """Create an Odysseus email compose document for user review. Does not send.""" + from core.database import SessionLocal, Document, DocumentVersion + try: + from src.event_bus import fire_event + except Exception: + fire_event = None + + cfg = _load_config(account) if account else _load_config(None) + content = _build_email_document_content( + to, + subject, + body, + cc=cc, + bcc=bcc, + in_reply_to=in_reply_to, + references=references, + source_uid=source_uid, + source_folder=source_folder, + ) + doc_id = str(uuid.uuid4()) + ver_id = str(uuid.uuid4()) + doc_title = (title or subject or "Email draft").strip() or "Email draft" + doc_owner = _default_document_owner() + + db = SessionLocal() + try: + if source_uid and source_folder: + existing = ( + db.query(Document) + .filter(Document.is_active == True) + .filter(Document.language == "email") + .filter(Document.owner == doc_owner) + .filter(Document.source_email_uid == str(source_uid)) + .filter(Document.source_email_folder == source_folder) + .order_by(Document.updated_at.desc()) + .first() + ) + if existing and "\n---\n" in (existing.current_content or ""): + existing.current_content = _merge_email_reply_body(existing.current_content, body or "") + existing.version_count = (existing.version_count or 0) + 1 + ver = DocumentVersion( + id=ver_id, + document_id=existing.id, + version_number=existing.version_count, + content=existing.current_content, + summary="Updated by email MCP draft tool", + source="ai", + ) + db.add(ver) + db.commit() + if fire_event: + try: + fire_event("document_updated", doc_owner) + except Exception: + pass + return { + "draft": True, + "updated": True, + "doc_id": existing.id, + "title": existing.title, + "language": existing.language, + "account": cfg.get("account_name"), + "account_id": cfg.get("account_id"), + "to": to, + "subject": subject, + } + + doc = Document( + id=doc_id, + session_id=None, + title=doc_title, + language="email", + current_content=content, + version_count=1, + is_active=True, + owner=doc_owner, + source_email_uid=source_uid, + source_email_folder=source_folder, + source_email_account_id=cfg.get("account_id"), + source_email_message_id=source_message_id, + ) + ver = DocumentVersion( + id=ver_id, + document_id=doc_id, + version_number=1, + content=content, + summary="Created by email MCP draft tool", + source="ai", + ) + db.add(doc) + db.add(ver) + db.commit() + if fire_event: + try: + fire_event("document_created", doc_owner) + except Exception: + pass + return { + "draft": True, + "doc_id": doc_id, + "title": doc_title, + "language": "email", + "account": cfg.get("account_name"), + "account_id": cfg.get("account_id"), + "to": to, + "subject": subject, + } + finally: + db.close() + + +def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None): + """Create a threaded Odysseus reply draft document. Does not send.""" + conn = _imap_connect(account) + conn.select(_q(folder), readonly=True) + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") + conn.logout() + if status != "OK" or not msg_data or not msg_data[0]: + return {"error": f"Failed to fetch email UID {uid}"} + raw = msg_data[0][1] + orig = email.message_from_bytes(raw) + + orig_subject = _decode_header(orig.get("Subject", "")) + reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}" + orig_message_id = orig.get("Message-ID", "") + orig_references = orig.get("References", "") + new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id + + sender = _decode_header(orig.get("From", "")) + _, sender_addr = email.utils.parseaddr(sender) + to_addrs = sender_addr + + cc = None + if reply_all: + cc_addrs = [] + cfg = _load_config(account) + own_addrs = { + (cfg.get("imap_user") or "").strip().lower(), + (cfg.get("from_address") or "").strip().lower(), + } + for header_name in ("To", "Cc"): + for _, addr in email.utils.getaddresses([orig.get(header_name, "")]): + addr_l = (addr or "").strip().lower() + if addr and addr != sender_addr and addr_l not in own_addrs: + cc_addrs.append(addr) + if cc_addrs: + cc = ", ".join(dict.fromkeys(cc_addrs)) + + return _create_email_draft_document( + to=to_addrs, + subject=reply_subject, + body=body, + title=title or reply_subject, + cc=cc, + in_reply_to=orig_message_id, + references=new_references, + source_uid=uid, + source_folder=folder, + account=account, + source_message_id=orig_message_id, + ) + + +async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account=None, title=None): + """Generate a reply with Odysseus' AI-reply prompt/style, then create a compose doc.""" + read_result = _read_email(uid=uid, folder=folder, account=account) + if "error" in read_result: + return read_result + + to_addr = read_result.get("from_address") or email.utils.parseaddr(read_result.get("from") or "")[1] + subject = read_result.get("subject") or "" + reply_subject = subject if subject.lower().startswith("re:") else f"Re: {subject}" + original_body = read_result.get("body") or "" + message_id = read_result.get("message_id") or "" + + if not original_body.strip(): + return {"error": "No email body available for AI reply"} + + try: + from routes.email_helpers import ( + _EMAIL_REPLY_SYS_PROMPT_BASE, + _apply_email_style_mechanics, + _extract_reply, + _load_settings, + ) + from src.endpoint_resolver import ( + resolve_endpoint, + resolve_utility_fallback_candidates, + resolve_chat_fallback_candidates, + ) + from src.llm_core import llm_call_async_with_fallback + except Exception as exc: + return {"error": f"AI reply helpers unavailable: {exc}"} + + settings = _load_settings() + style = settings.get("email_writing_style", "") + system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE + if style: + system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}" + + user_msg = ( + f"Recipient: {to_addr}\nSubject: {reply_subject}\n\n" + f"Original email and any current draft:\n{original_body[:6000]}\n\n" + "Draft a reply. Return only the reply body text." + ) + + candidates = [] + seen = set() + + def _add(url, model, headers): + key = (url or "", model or "") + if not url or not model or key in seen: + return + seen.add(key) + candidates.append((url, model, headers)) + + try: + _add(*resolve_endpoint("utility", owner=None)) + except Exception: + pass + try: + _add(*resolve_endpoint("default", owner=None)) + except Exception: + pass + try: + utility_fallbacks = resolve_utility_fallback_candidates(owner=None) or [] + except TypeError: + utility_fallbacks = resolve_utility_fallback_candidates() or [] + for cand in utility_fallbacks: + _add(*cand) + try: + chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or [] + except TypeError: + chat_fallbacks = resolve_chat_fallback_candidates() or [] + for cand in chat_fallbacks: + _add(*cand) + + if not candidates: + return {"error": "No LLM endpoint configured for AI reply"} + + try: + raw_reply = await llm_call_async_with_fallback( + candidates, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_msg}, + ], + temperature=0.7, + max_tokens=1024, + timeout=60, + ) + except Exception as exc: + return {"error": f"AI reply generation failed: {exc}"} + + reply = _apply_email_style_mechanics(_extract_reply(raw_reply or "")) + if not reply: + return {"error": "AI reply generation returned an empty response"} + + return _draft_reply_to_email( + uid=uid, + body=reply, + folder=folder, + reply_all=reply_all, + account=account, + title=title or reply_subject, + ) + + def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None): """Reply to an existing email by UID. Threads via In-Reply-To/References.""" conn = None @@ -1189,6 +1577,8 @@ async def list_tools() -> list[Tool]: name="send_email", description=( "Send a new email via SMTP. Provide recipient(s), subject, and body. " + "This sends immediately; for normal assistant-written email, prefer " + "draft_email so the user can review and send from Odysseus. " "For replying to an existing thread, use reply_to_email instead. " "Pass `account` to send from a non-default mailbox." ), @@ -1205,10 +1595,35 @@ async def list_tools() -> list[Tool]: "required": ["to", "subject", "body"], }, ), + Tool( + name="draft_email", + description=( + "Create a new Odysseus email compose draft document. This DOES NOT send. " + "Use this as the default way to write an email for the user: it opens " + "a reviewable email document with To/Cc/Bcc/Subject/body, and the user " + "can edit or press Send in Odysseus. " + f"{_writing_style_guidance()}" + ), + inputSchema={ + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient email address(es), comma-separated"}, + "subject": {"type": "string", "description": "Email subject line"}, + "body": {"type": "string", "description": "Draft body"}, + "cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"}, + "bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"}, + "title": {"type": "string", "description": "Optional Odysseus document title"}, + **ACCOUNT_PROP, + }, + "required": ["to", "subject", "body"], + }, + ), Tool( name="reply_to_email", description=( - "Reply to an existing email by UID. Automatically threads the reply with " + "Reply to an existing email by UID. This sends immediately; for normal " + "assistant-written replies, prefer draft_email_reply so the user can " + "review and send from Odysseus. Automatically threads the reply with " "In-Reply-To and References headers, prefixes 'Re:' on the subject, and " "uses the original sender as the recipient. Set reply_all=true to also CC " "the original To/Cc recipients. For follow-up 'reply ...' requests, use " @@ -1226,6 +1641,49 @@ async def list_tools() -> list[Tool]: "required": ["uid", "body"], }, ), + Tool( + name="draft_email_reply", + description=( + "Create an Odysseus email reply draft document for an existing email UID. " + "This DOES NOT send. It threads the draft with In-Reply-To/References, " + "prefills the recipient and subject, and stores source email metadata so " + "the user can review and send from the normal email composer. " + f"{_writing_style_guidance()}" + ), + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "body": {"type": "string", "description": "Draft reply body text"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False}, + "title": {"type": "string", "description": "Optional Odysseus document title"}, + **ACCOUNT_PROP, + }, + "required": ["uid", "body"], + }, + ), + Tool( + name="ai_draft_email_reply", + description=( + "Generate an AI reply using Odysseus' existing AI Reply behavior, " + "including Settings > Email > Writing Style, then create an email " + "compose document for review. This DOES NOT send and does NOT save " + "to the mailbox Drafts folder. Use this when the user asks you to " + "write or draft a reply to an email without dictating the exact body." + ), + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False}, + "title": {"type": "string", "description": "Optional Odysseus document title"}, + **ACCOUNT_PROP, + }, + "required": ["uid"], + }, + ), Tool( name="archive_email", description="Move an email out of the inbox into the Archive folder. Use after handling an email you want to keep but no longer need in the inbox.", @@ -1552,6 +2010,31 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: acct_note = f" (from {result['account']})" if result.get("account") else "" return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")] + elif name == "draft_email": + to = arguments.get("to") + subject = arguments.get("subject") + body = arguments.get("body") + if not to or not subject or body is None: + return [TextContent(type="text", text="Error: to, subject, and body are required")] + result = _create_email_draft_document( + to=to, + subject=subject, + body=body, + title=arguments.get("title"), + cc=arguments.get("cc"), + bcc=arguments.get("bcc"), + account=acct, + ) + acct_note = f" from {result['account']}" if result.get("account") else "" + return [TextContent( + type="text", + text=( + f"Created Odysseus email draft `{result['title']}` " + f"(document ID: {result['doc_id']}){acct_note}. " + "It has not been sent; open the document in Odysseus to review and send." + ), + )] + elif name == "reply_to_email": uid = arguments.get("uid") body = arguments.get("body") @@ -1573,6 +2056,54 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: pass return [TextContent(type="text", text=f"Replied to UID {uid}: '{result['subject']}' → {result['to']}")] + elif name == "draft_email_reply": + uid = arguments.get("uid") + body = arguments.get("body") + if not uid or body is None: + return [TextContent(type="text", text="Error: uid and body are required")] + result = _draft_reply_to_email( + uid=uid, + body=body, + folder=arguments.get("folder", "INBOX"), + reply_all=bool(arguments.get("reply_all", False)), + account=acct, + title=arguments.get("title"), + ) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + acct_note = f" from {result['account']}" if result.get("account") else "" + return [TextContent( + type="text", + text=( + f"Created Odysseus reply draft `{result['title']}` for UID {uid} " + f"(document ID: {result['doc_id']}){acct_note}. " + "It has not been sent; open the document in Odysseus to review and send." + ), + )] + + elif name == "ai_draft_email_reply": + uid = arguments.get("uid") + if not uid: + return [TextContent(type="text", text="Error: uid is required")] + result = await _ai_draft_reply_to_email( + uid=uid, + folder=arguments.get("folder", "INBOX"), + reply_all=bool(arguments.get("reply_all", False)), + account=acct, + title=arguments.get("title"), + ) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + acct_note = f" from {result['account']}" if result.get("account") else "" + return [TextContent( + type="text", + text=( + f"Generated AI reply and created Odysseus compose draft " + f"`{result['title']}` for UID {uid} (document ID: {result['doc_id']}){acct_note}. " + "It has not been sent; open the document in Odysseus to review and send." + ), + )] + elif name == "archive_email": uid = arguments.get("uid") if not uid: diff --git a/pyproject.toml b/pyproject.toml index 58161958f..da00ee259 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,4 +15,8 @@ markers = [ "area_helpers: self-tests for the shared test helpers in tests/helpers/", "area_unit: pure parser / utility tests that do not clearly belong elsewhere", "area_uncategorized: tests not yet matched by the taxonomy (fallback)", + # Fast-lane marker (issue #3443). Opt-in and orthogonal to the area_*/sub_* + # taxonomy. The fast lane runs `not slow`; mark a test slow only with + # duration evidence (see tests/run_focus.py --durations and tests/README.md). + "slow: opt-in marker for known-slow tests; excluded by the fast lane (not slow)", ] diff --git a/requirements-optional.txt b/requirements-optional.txt index eeb57c151..b4b654232 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -15,7 +15,7 @@ faster-whisper # DuckDuckGo as a search provider option. # Install if you want DDG in the search-provider dropdown. # Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE. -duckduckgo-search +ddgs # PDF form-filling feature (fillable AcroForm detection, field extraction, # value/annotation/signature stamping, page rendering for the form overlay). diff --git a/requirements.txt b/requirements.txt index 2c4072980..b71f9897b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,3 +43,7 @@ qrcode[pil] croniter pytest pytest-asyncio +# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every +# TestClient import when only classic httpx is present. Runtime code keeps +# using `httpx` above; this is test-client only. +httpx2 diff --git a/routes/_validators.py b/routes/_validators.py new file mode 100644 index 000000000..aa4cf00cc --- /dev/null +++ b/routes/_validators.py @@ -0,0 +1,31 @@ +import re + +from fastapi import HTTPException + + +_REMOTE_HOST_RE = re.compile( + r"^(?:[A-Za-z0-9][A-Za-z0-9._-]*@)?[A-Za-z0-9][A-Za-z0-9._-]*$" +) +_SSH_PORT_RE = re.compile(r"^\d{1,5}$") + + +def validate_remote_host(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _REMOTE_HOST_RE.match(v): + raise HTTPException( + 400, + "Invalid remote_host — must be host or user@host, no SSH option syntax", + ) + return v + + +def validate_ssh_port(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _SSH_PORT_RE.fullmatch(str(v)): + raise HTTPException(400, "Invalid ssh_port") + port = int(v) + if port < 1 or port > 65535: + raise HTTPException(400, "Invalid ssh_port") + return str(port) diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py index 97c576d15..475c6502d 100644 --- a/routes/api_token_routes.py +++ b/routes/api_token_routes.py @@ -25,6 +25,8 @@ ALLOWED_SCOPES = { "calendar:write", "memory:read", "memory:write", + "cookbook:read", + "cookbook:launch", } TOKEN_PROFILES = { "chat": ["chat"], @@ -65,6 +67,7 @@ def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None ensure_before("calendar:write", "calendar:read") ensure_before("memory:write", "memory:read") ensure_before("email:draft", "email:read") + ensure_before("cookbook:launch", "cookbook:read") return normalized or [DEFAULT_SCOPES] @@ -151,6 +154,7 @@ def setup_api_token_routes() -> APIRouter: @router.patch("/tokens/{token_id}") async def update_token(request: Request, token_id: str): require_admin(request) + current_user = get_current_user(request) try: payload = await request.json() except Exception: @@ -159,6 +163,8 @@ def setup_api_token_routes() -> APIRouter: token = db.query(ApiToken).filter(ApiToken.id == token_id).first() if not token: raise HTTPException(404, "Token not found") + if current_user and token.owner != current_user: + raise HTTPException(403, "Not your token") if isinstance(payload.get("name"), str) and payload["name"].strip(): token.name = payload["name"].strip()[:MAX_NAME_LEN] # Only touch scopes when the caller actually sent them. A partial @@ -186,10 +192,14 @@ def setup_api_token_routes() -> APIRouter: @router.delete("/tokens/{token_id}") def delete_token(request: Request, token_id: str): require_admin(request) + current_user = get_current_user(request) with get_db_session() as db: - deleted = db.query(ApiToken).filter(ApiToken.id == token_id).delete() - if not deleted: + token = db.query(ApiToken).filter(ApiToken.id == token_id).first() + if not token: raise HTTPException(404, "Token not found") + if current_user and token.owner != current_user: + raise HTTPException(403, "Not your token") + db.delete(token) _invalidate_cache(request) return {"status": "deleted"} diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 9379bced8..a9cc8ecb1 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -7,7 +7,13 @@ import asyncio import logging import os +import json +import re +from pathlib import Path + +from core.atomic_io import atomic_write_json, atomic_write_text from core.auth import AuthManager +from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings from src.settings import ( @@ -291,9 +297,30 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: if new_username in auth_manager.users: raise HTTPException(409, "Username already taken") + # Gate on auth first. Every mutation below is contingent on this + # succeeding — doing it last meant a rejected rename (e.g. reserved + # username) left file-backed owner fields already rewritten with no + # way to roll them back. + ok = auth_manager.rename_user(old_username, new_username, user) + if not ok: + raise HTTPException(400, "Cannot rename user") + + def _rollback_auth_rename() -> bool: + # On self-rename the admin session has already moved to the new + # username, so the rollback must authenticate as the new user. + rollback_user = new_username if user == old_username else user + try: + return bool(auth_manager.rename_user(new_username, old_username, rollback_user)) + except Exception as rollback_err: + logger.error( + "Failed to roll back auth rename %s -> %s after owner migration failure: %s", + new_username, old_username, rollback_err, + ) + return False + # Usernames are ownership keys for user data. Rename the common - # owner-scoped DB rows before changing auth so the account keeps - # access to its sessions, docs, email accounts, tasks, etc. + # owner-scoped DB rows so the account keeps access to its sessions, + # docs, email accounts, tasks, etc. try: from sqlalchemy import func from core.database import Base, SessionLocal @@ -316,6 +343,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: db.close() except Exception as e: logger.error("Failed to rename owner references %s -> %s: %s", old_username, new_username, e) + if not _rollback_auth_rename(): + logger.error( + "Auth rename %s -> %s could not be rolled back after owner migration failure", + old_username, new_username, + ) raise HTTPException(500, "Failed to rename user data") # Per-user prefs are JSON-backed, not SQL-backed. @@ -335,9 +367,116 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: except Exception as e: logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e) - ok = auth_manager.rename_user(old_username, new_username, user) - if not ok: - raise HTTPException(400, "Cannot rename user") + # In-flight deep-research tasks live in the process-local + # ResearchHandler registry. They are not covered by the persisted JSON + # migration above, but the research routes filter and cancel by this + # owner field while the job is running. Do this before sweeping + # completed JSON files so a job that finishes during the rename saves + # with the new owner or is caught by the disk sweep below. + try: + rh = getattr(request.app.state, "research_handler", None) + rename_owner = getattr(rh, "rename_owner", None) + if callable(rename_owner): + rename_owner(old_username, new_username) + except Exception as e: + logger.warning("Failed to rename active research tasks %s -> %s: %s", old_username, new_username, e) + + # deep_research: each completed report is a standalone JSON file with + # an `owner` field. research_routes filters by d.get("owner") == user, + # so a stale owner makes every report invisible to the renamed user. + try: + dr_dir = Path(DEEP_RESEARCH_DIR) + if dr_dir.is_dir(): + for p in dr_dir.glob("*.json"): + try: + d = json.loads(p.read_text(encoding="utf-8")) + if str(d.get("owner", "")).strip().lower() == old_username: + d["owner"] = new_username + atomic_write_json(str(p), d) + except Exception as err: + logger.warning("Failed to update research owner in %s: %s", p.name, err) + except Exception as e: + logger.warning("Failed to rename research owner references %s -> %s: %s", old_username, new_username, e) + + # memory.json: a flat JSON array where each entry carries an `owner` + # field. memory_manager.load(owner=user) filters on it, so stale + # entries disappear from the memory panel. + try: + if os.path.isfile(MEMORY_FILE): + with open(MEMORY_FILE, encoding="utf-8") as fh: + entries = json.loads(fh.read()) + if isinstance(entries, list): + changed = False + for entry in entries: + if isinstance(entry, dict) and str(entry.get("owner", "")).strip().lower() == old_username: + entry["owner"] = new_username + changed = True + if changed: + atomic_write_json(MEMORY_FILE, entries) + except Exception as e: + logger.warning("Failed to rename memory.json owner references %s -> %s: %s", old_username, new_username, e) + + # uploads.json: upload rows use owner metadata for access checks and + # owner-prefixed index keys for dedupe. Rename both so attachments keep + # resolving after the account username changes. + try: + upload_handler = getattr(request.app.state, "upload_handler", None) + rename_owner = getattr(upload_handler, "rename_owner", None) + if callable(rename_owner): + rename_owner(old_username, new_username) + except Exception as e: + logger.warning("Failed to rename upload owner references %s -> %s: %s", old_username, new_username, e) + + # skills: SKILL.md frontmatter carries owner: ; the usage + # sidecar (_usage.json) keys entries as owner::skill-name. Both must + # be updated or the renamed user's Skills panel goes empty. + try: + skills_root = Path(SKILLS_DIR) + if skills_root.is_dir(): + _owner_re = re.compile( + r'(?m)^(owner:\s*)' + re.escape(old_username) + r'\s*$', + re.IGNORECASE, + ) + for p in skills_root.rglob("SKILL.md"): + try: + text = p.read_text(encoding="utf-8") + new_text = _owner_re.sub(r'\g<1>' + new_username, text) + if new_text != text: + atomic_write_text(str(p), new_text) + except Exception as err: + logger.warning("Failed to update skill owner in %s: %s", p, err) + usage_path = skills_root / "_usage.json" + if usage_path.is_file(): + try: + usage = json.loads(usage_path.read_text(encoding="utf-8")) + if isinstance(usage, dict): + new_usage = {} + changed = False + for k, v in usage.items(): + owner_part, sep, skill_part = k.partition("::") + if sep and owner_part.lower() == old_username: + new_usage[new_username + "::" + skill_part] = v + changed = True + else: + new_usage[k] = v + if changed: + atomic_write_json(str(usage_path), new_usage) + except Exception as err: + logger.warning("Failed to update skills usage keys %s -> %s: %s", old_username, new_username, err) + except Exception as e: + logger.warning("Failed to rename skills owner references %s -> %s: %s", old_username, new_username, e) + + # The in-memory session cache (session_manager.sessions) stores each + # session's owner at load time. Without this patch the renamed user's + # sessions are invisible on the next /api/sessions call because + # get_sessions_for_user does an exact `s.owner == username` comparison + # against stale in-memory values. + sm = getattr(request.app.state, "session_manager", None) + if sm is not None: + for sess in list(getattr(sm, "sessions", {}).values()): + if str(getattr(sess, "owner", None) or "").strip().lower() == old_username: + sess.owner = new_username + # The owner-rename loop above updated ApiToken.owner in the DB, but the # bearer-token cache still maps each token to the OLD owner. Without # refreshing it, the renamed user's API tokens resolve to the old (now @@ -378,7 +517,23 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") - ok = auth_manager.delete_user(body.username, user) + + def _invalidate_api_token_cache(): + try: + invalidator = getattr(request.app.state, "invalidate_token_cache", None) + if invalidator: + invalidator() + except Exception: + pass + + try: + ok = auth_manager.delete_user(body.username, user) + except Exception: + # delete_user can touch ApiToken rows before a later auth-store write + # fails. Dirty the bearer cache anyway so a partial token purge does + # not leave already-cached tokens authenticating until restart. + _invalidate_api_token_cache() + raise if not ok: raise HTTPException(400, "Cannot delete user") # delete_user removes the user's ApiToken rows, but the bearer-auth @@ -386,12 +541,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: # rebuilds when flagged dirty. Without this, a deleted user's already # cached token keeps authenticating until some other token op or a # restart clears the cache. Mirror what the token routes do. - try: - invalidator = getattr(request.app.state, "invalidate_token_cache", None) - if invalidator: - invalidator() - except Exception: - pass + _invalidate_api_token_cache() return {"ok": True} # ---- Feature visibility (admin-managed) ---- diff --git a/routes/backup_routes.py b/routes/backup_routes.py index 5ca403f81..313369370 100644 --- a/routes/backup_routes.py +++ b/routes/backup_routes.py @@ -101,11 +101,17 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo # ── Skills ── if "skills" in body and isinstance(body["skills"], list): existing = skills_manager.load_all() - existing_names = {s.get("name") for s in existing if s.get("name")} - existing_ids = {s.get("id") for s in existing if s.get("id")} + # Dedup against THIS user's own skills only. Using every tenant's + # rows (load_all) meant a skill whose id/name/title matched any + # other user's was silently skipped, so the importing user lost + # their own data — same cross-tenant bug fixed for memories above. + # The full store is still saved back below. + own = [s for s in existing if s.get("owner") == user] + existing_names = {s.get("name") for s in own if s.get("name")} + existing_ids = {s.get("id") for s in own if s.get("id")} existing_titles = { (s.get("title") or s.get("description") or "").strip().lower() - for s in existing + for s in own } added = 0 for skill in body["skills"]: diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 345280528..7b36df06a 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -851,28 +851,27 @@ def setup_calendar_routes() -> APIRouter: from src.caldav_sync import sync_caldav return await sync_caldav(owner) + @router.delete("/calendars/{cal_id}") - async def delete_calendar(cal_id: str, request: Request): + async def delete_calendar(request: Request, cal_id: str): owner = _require_user(request) db = SessionLocal() try: - cal = db.query(CalendarCal).filter( - CalendarCal.id == cal_id, - CalendarCal.owner == owner, - ).first() - if not cal: - raise HTTPException(404, "Calendar not found") + cal = _get_or_404_calendar(db, cal_id, owner) + db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete() db.delete(cal) db.commit() return {"ok": True} except HTTPException: raise except Exception as e: + db.rollback() logger.error("Failed to delete calendar %s: %s", cal_id, e) raise HTTPException(500, "Failed to delete calendar") finally: db.close() + @router.get("/calendars") async def list_calendars(request: Request): owner = _require_user(request) @@ -1152,23 +1151,6 @@ def setup_calendar_routes() -> APIRouter: finally: db.close() - @router.delete("/calendars/{cal_id}") - async def delete_calendar(request: Request, cal_id: str): - owner = _require_user(request) - db = SessionLocal() - try: - cal = _get_or_404_calendar(db, cal_id, owner) - db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete() - db.delete(cal) - db.commit() - return {"ok": True} - except HTTPException: - raise - except Exception as e: - db.rollback() - return {"error": str(e)} - finally: - db.close() # Hard cap on ICS upload (ICS_MAX_BYTES, default 10 MB). Loading the whole # file into memory is unavoidable with python-icalendar, so an unbounded diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 0b1c5d8ba..c32161bb1 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -615,6 +615,26 @@ async def build_chat_context( # Build messages messages = preface + sess.get_context_messages() + # Current date/time — injected as a standalone *user*-role context message + # placed immediately before the latest user turn, NOT folded into the + # system prompt. Its text changes every minute, and local OpenAI-compatible + # backends (llama.cpp / LM Studio) key their KV-cache prefix off the + # system message byte-for-byte; mixing ever-changing timestamp text into + # it would invalidate the cached prefix on every request (issue #2927). + # Placing it at the tail also keeps it out of the stable + # preface+history prefix, so that prefix stays byte-identical turn over + # turn (modulo the genuinely new history entries) and the cache survives. + if not agent_mode: + try: + from src.user_time import current_datetime_context_message + _dt_msg = current_datetime_context_message() + if messages and messages[-1].get("role") == "user": + messages.insert(len(messages) - 1, _dt_msg) + else: + messages.append(_dt_msg) + except Exception: + logger.debug("Failed to add current date/time context", exc_info=True) + # Auto-compact messages, context_length, was_compacted = await maybe_compact( sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user, @@ -911,6 +931,54 @@ def save_assistant_response( return None +def _is_session_stream_active(session_id: str) -> bool: + """Best-effort check for "is a chat completion currently streaming for + this session?" — used to keep background extraction from overlapping a + main completion and competing for the local backend's processing slots + (issue #2927). Lazily imports the route module's live registry to avoid + a circular import (chat_routes imports this module at load time).""" + try: + from routes import chat_routes as _cr + return session_id in getattr(_cr, "_active_streams", {}) + except Exception: + return False + + +async def _run_extraction_jobs_sequentially(session_id: str, jobs: list, max_wait_s: float = 120.0): + """Run queued background-extraction coroutines one at a time, only once + no chat completion is actively streaming for this session. + + As diagnosed in issue #2927, firing memory/skill extraction concurrently + with the main chat completion (or with each other) makes them compete for + the local backend's limited processing slots, evicting the main + conversation's cached KV-cache checkpoint and forcing a full prompt + re-evaluation on the next turn. Waiting for the stream to go idle and then + running the jobs strictly in sequence keeps at most one "side" request in + flight against the backend at any time, and never alongside the user's + own conversation. + """ + # Wait for the triggering turn's own stream to finish winding down (it + # almost always already has by the time this task gets scheduled — this + # is a small safety margin, not the primary mechanism). + waited = 0.0 + poll = 0.25 + while _is_session_stream_active(session_id) and waited < max_wait_s: + await asyncio.sleep(poll) + waited += poll + + for name, job in jobs: + # Re-check before each job: a fast follow-up message from the user + # may have started a new stream for this session while we waited. + waited = 0.0 + while _is_session_stream_active(session_id) and waited < max_wait_s: + await asyncio.sleep(poll) + waited += poll + try: + await job + except Exception: + logger.warning("[bg-extract] %s extraction job failed for session %s", name, session_id, exc_info=True) + + def run_post_response_tasks( sess, session_manager, @@ -933,7 +1001,22 @@ def run_post_response_tasks( extract_skills: bool = True, allow_background_extraction: bool = True, ): - """Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.""" + """Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction. + + Memory/skill extraction are queued to run *sequentially*, after the main + completion stream for this session has fully wound down — never + concurrently with it or with each other. As diagnosed in issue #2927, + firing these "side" LLM calls in parallel with the main chat completion + makes them compete for the local backend's limited processing slots + (llama.cpp defaults to 4), evicting the main conversation's cached + checkpoint and forcing a full prompt re-evaluation on the next turn. By + the time this function runs the main response is already saved, but the + extraction calls themselves are still async — queuing them through + ``_queue_background_extraction`` keeps them from overlapping the *next* + turn's request too. + """ + _extraction_jobs: list = [] + # Memory extraction — only every 4th message pair to avoid excess LLM calls _msg_count = len(sess.history) if hasattr(sess, 'history') else 0 _should_extract = (_msg_count >= 4) and (_msg_count % 4 == 0) @@ -943,10 +1026,10 @@ def run_post_response_tasks( t_url, t_model, t_headers = resolve_task_endpoint( sess.endpoint_url, sess.model, sess.headers, owner=owner, ) - asyncio.create_task(extract_and_store( + _extraction_jobs.append(("memory", extract_and_store( sess, memory_manager, memory_vector, t_url, t_model, t_headers, - )) + ))) # Skill extraction from complex agent runs. Only when the user actually # chose agent mode — not a chat we auto-escalated for a notes/calendar @@ -982,12 +1065,15 @@ def run_post_response_tasks( sess.endpoint_url, sess.model, sess.headers, owner=owner, ) logger.debug("[skill-extract] dispatching extractor (model=%s)", s_model) - asyncio.create_task(maybe_extract_skill( + _extraction_jobs.append(("skill", maybe_extract_skill( sess, skills_manager, s_url, s_model, s_headers, agent_rounds, agent_tool_calls, owner=owner, - )) + ))) + + if _extraction_jobs: + asyncio.create_task(_run_extraction_jobs_sequentially(session_id, _extraction_jobs)) # Token accumulation if last_metrics: diff --git a/routes/chat_routes.py b/routes/chat_routes.py index c9f5ec3d5..1849a983e 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -62,6 +62,33 @@ def _stream_set(session_id: str, **fields) -> None: rec.update(fields) +def _resolve_request_workspace(request, raw_value) -> tuple: + """Resolve the posted workspace for this request: (workspace, rejected). + + Privilege is checked BEFORE the path ever touches the filesystem. Only + admin/single-user callers can use the workspace-backed file/shell tools, + so only they get vet_workspace() and the workspace_rejected signal. For + any other caller the submitted value is dropped uniformly, with no vetting + and no event: otherwise the presence/absence of workspace_rejected would + let a non-admin chat caller probe which host paths exist. + + vet_workspace rejects non-directories, sensitive roots (.ssh, .gnupg, + ...), and filesystem roots; on rejection there is no confinement and the + default tool-path allowlist applies. The rejected value is surfaced so the + stream can tell an admin client (which believes a workspace is active) + that it was dropped. + """ + requested = (raw_value or "").strip() + if not requested: + return "", "" + from src.tool_security import owner_is_admin_or_single_user + if not owner_is_admin_or_single_user(get_current_user(request)): + return "", "" + from src.tool_execution import vet_workspace + workspace = vet_workspace(requested) or "" + return workspace, (requested if not workspace else "") + + def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool: if not session_url or not endpoint_base: return False @@ -400,6 +427,7 @@ def setup_chat_routes( temperature=ctx.preset.temperature, max_tokens=ctx.preset.max_tokens, prompt_type=preset_id, + session_id=session, ) _clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model}) sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md)) @@ -446,20 +474,23 @@ def setup_chat_routes( use_research = form_data.get("use_research") time_filter = form_data.get("time_filter") preset_id = form_data.get("preset_id") - allow_bash = form_data.get("allow_bash") - allow_web_search = form_data.get("allow_web_search") + # Issue #3229: API callers send JSON, not FormData. Read from the + # JSON body as fallback so callers who send {"allow_bash": true} + # actually get bash enabled. + allow_bash = form_data.get("allow_bash") or (body or {}).get("allow_bash") + allow_web_search = form_data.get("allow_web_search") or (body or {}).get("allow_web_search") use_rag = form_data.get("use_rag") search_context = form_data.get("search_context") # pre-fetched web search results (compare mode) compare_mode = str(form_data.get("compare_mode", "")).lower() == "true" incognito = str(form_data.get("incognito", "")).lower() == "true" - plan_mode = str(form_data.get("plan_mode", "")).lower() == "true" + # Plan mode is not part of the merge-ready UI. Ignore stale clients or + # manual form posts that still send plan_mode=true. + plan_mode = False chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent' - # Workspace: confine the agent's file/shell tools to this folder. Validate - # it's a real directory; ignore (no confinement) otherwise. - workspace = (form_data.get("workspace") or "").strip() - if workspace: - _ws_real = os.path.realpath(os.path.expanduser(workspace)) - workspace = _ws_real if os.path.isdir(_ws_real) else "" + # Workspace: confine the agent's file/shell tools to this folder. + workspace, workspace_rejected = _resolve_request_workspace( + request, form_data.get("workspace") + ) # Plan mode is a modifier on agent mode — it only makes sense with tools. if plan_mode: chat_mode = "agent" @@ -638,7 +669,7 @@ def setup_chat_routes( # leak a doc that belongs to a DIFFERENT session. if not active_doc: try: - from src.tool_implementations import get_active_document + from src.agent_tools.document_tools import get_active_document _mem_id = get_active_document() if _mem_id: _mem_q = _doc_db.query(DBDocument).filter(DBDocument.id == _mem_id) @@ -659,9 +690,13 @@ def setup_chat_routes( # Build disabled-tools set from frontend toggles + user privileges disabled_tools = set() - if str(allow_bash).lower() != "true": + # Only disable bash/web_search when the caller *explicitly* set them + # to a falsy value. When unset (None), defer to per-user privilege + # checks below — this lets admins with can_use_bash=True use bash + # by default without having to send allow_bash in every request. + if allow_bash is not None and str(allow_bash).lower() != "true": disabled_tools.add("bash") - if str(allow_web_search).lower() != "true": + if allow_web_search is not None and str(allow_web_search).lower() != "true": disabled_tools.add("web_search") disabled_tools.add("web_fetch") @@ -764,6 +799,13 @@ def setup_chat_routes( # Register active stream for partial-save safety net _active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": effective_do_research, "mode": _effective_mode} + # The client sent a workspace the server refused to bind (deleted + # folder, file path, sensitive dir, filesystem root). Tell it up + # front so the UI can clear the pill instead of displaying a + # confinement that is not actually in effect. + if workspace_rejected: + yield f"data: {json.dumps({'type': 'workspace_rejected', 'data': {'path': workspace_rejected}})}\n\n" + if ctx.preprocessed.attachment_meta: yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n" @@ -992,6 +1034,7 @@ def setup_chat_routes( max_tokens=ctx.preset.max_tokens, prompt_type=preset_id, tools=None, + session_id=session, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -1138,9 +1181,9 @@ def setup_chat_routes( tool_policy=tool_policy, owner=_user, fallbacks=_fallback_candidates, - workspace=workspace or None, plan_mode=plan_mode, approved_plan=approved_plan or None, + workspace=workspace or None, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -1272,8 +1315,7 @@ def setup_chat_routes( # without waiting on the next streamed chunk. # # Normal chat/agent streams keep the DETACHED behavior below: they - # survive the client closing the tab / navigating away (true - # terminal-agent semantics). The SSE response just subscribes (replay + # survive the client closing the tab / navigating away. The SSE response just subscribes (replay # buffered output + live); dropping the SSE only removes a subscriber — # the run keeps going and saves the assistant message on completion # regardless. Reconnect via /api/chat/resume. diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py index e4e8ce759..58a57a1e1 100644 --- a/routes/contacts_routes.py +++ b/routes/contacts_routes.py @@ -729,8 +729,11 @@ def setup_contacts_routes(): @router.post("/import") async def import_vcf(data: dict, _admin: str = Depends(require_admin)): """Import contacts from .vcf or CSV. Body: {"vcf": "..."} or {"csv": "..."}.""" - text = data.get("vcf") or data.get("text") or "" - csv_text = data.get("csv") or "" + # Coerce defensively: a non-string vcf/text/csv (e.g. a number or list + # in the JSON body) would otherwise reach .strip() and 500 with an + # AttributeError instead of degrading to a clean "no data" response. + text = str(data.get("vcf") or data.get("text") or "") + csv_text = str(data.get("csv") or "") if text.strip(): if "BEGIN:VCARD" not in text.upper(): return {"success": False, "error": "No vCard data found"} diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index 39a18f715..c2f93cb77 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -1,16 +1,19 @@ """cookbook_helpers.py — validators + small helpers shared by the cookbook routes. Extracted from cookbook_routes.py; the routes module imports the symbols it needs.""" +import json import logging import ntpath import os import posixpath import re import shlex +from pathlib import Path from fastapi import HTTPException from pydantic import BaseModel +from routes._validators import validate_remote_host, validate_ssh_port from core.platform_compat import _ssh_exec_argv logger = logging.getLogger(__name__) @@ -30,20 +33,24 @@ _LOCAL_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _OLLAMA_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$") # Include pattern is a glob: allow typical safe glyphs only. _INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$") -# Remote host: user@host (optionally with :port-free hostname parts). -_REMOTE_HOST_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$") # HF tokens and API tokens are url-safe base64-like. _TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$") # Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef". # Anything beyond plain alphanumerics + dash + underscore could break out # of the shell/PowerShell contexts the value lands in. _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -_SSH_PORT_RE = re.compile(r"^\d{1,5}$") _GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$") # A download target directory. Absolute or ~-relative path; safe path glyphs -# only (no quotes, shell metacharacters, or spaces) since it lands in a shell -# command. A leading ~ is expanded to $HOME at command-build time. -_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$") +# only (no quotes or shell metacharacters). Spaces are allowed because command +# builders pass the value through quoted shell/Python contexts. The character +# class uses ``\w`` — Unicode word characters under Python 3's default str +# matching — so non-ASCII folder names pass validation too: Cyrillic, accented +# Latin, CJK, e.g. ``/Volumes/Модели`` or ``D:\AI Models\Модели``. This stays +# shell-safe: none of ``; & | ` $ '' "" () {}`` newlines etc. are in ``[\w. -]``, +# so injection vectors remain rejected. A leading ~ is expanded to $HOME at +# command-build time. (Drive letters stay ASCII: ``[A-Za-z]:``.) +_LOCAL_DIR_RE = re.compile(r"^~?(?:/[\w. -]*)+$|^~$") +_WINDOWS_LOCAL_DIR_RE = re.compile(r"^[A-Za-z]:[\\/](?:[\w. -]+(?:[\\/][\w. -]+)*[\\/]?)?$") _WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]") @@ -77,14 +84,6 @@ def _validate_include(v: str | None) -> str | None: return v -def _validate_remote_host(v: str | None) -> str | None: - if v is None or v == "": - return None - if not _REMOTE_HOST_RE.match(v): - raise HTTPException(400, "Invalid remote_host — must be user@host, no SSH option syntax") - return v - - def _validate_token(v: str | None) -> str | None: if v is None or v == "": return None @@ -93,26 +92,43 @@ def _validate_token(v: str | None) -> str | None: return v +def load_stored_hf_token(*, state_path: Path | str | None = None) -> str: + """Return the decrypted HF token from cookbook_state.json, else env fallback.""" + path = Path(state_path) if state_path else Path(os.environ.get("DATA_DIR", "data")) / "cookbook_state.json" + token = "" + if path.exists(): + try: + state = json.loads(path.read_text(encoding="utf-8")) + env = state.get("env") if isinstance(state, dict) else {} + if isinstance(env, dict) and env.get("hfToken"): + from src.secret_storage import decrypt + token = decrypt(env.get("hfToken") or "") + except Exception: + token = "" + if not token: + token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip() + return token + + def _validate_local_dir(v: str | None) -> str | None: if v is None or v == "": return None + if len(v) >= 2 and v[0] == v[-1] and v[0] in {"'", '"'}: + v = v[1:-1] v = v.rstrip("/") or "/" - if not _LOCAL_DIR_RE.match(v): - raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no spaces or shell metacharacters") + if not (_LOCAL_DIR_RE.match(v) or _WINDOWS_LOCAL_DIR_RE.match(v)): + raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no shell metacharacters") + # Reject path segments that start with '-' (option injection). '-' is in the + # allowlist, so a dir like ``/models/-rf`` or ``D:\models\-rf`` could be read + # as a CLI flag by hf/etc. — and quoting does NOT stop a value from being + # parsed as an option. This is the one residual that command-build-time + # quoting can't cover, so the guard lives here, keeping the safety wholly + # inside the validator rather than relying on consumers. + if any(seg.startswith("-") for seg in re.split(r"[\\/]", v) if seg): + raise HTTPException(400, "Invalid local_dir — path segments cannot start with '-'") return v -def _validate_ssh_port(v: str | None) -> str | None: - if v is None or v == "": - return None - if not _SSH_PORT_RE.fullmatch(str(v)): - raise HTTPException(400, "Invalid ssh_port") - port = int(v) - if port < 1 or port > 65535: - raise HTTPException(400, "Invalid ssh_port") - return str(port) - - def _validate_gpus(v: str | None) -> str | None: if v is None or v == "": return None @@ -124,7 +140,7 @@ def _validate_gpus(v: str | None) -> str | None: def _shell_path(p: str) -> str: """Render a validated path for a double-quoted shell context, expanding a leading ~ to $HOME (single quotes wouldn't expand it). Safe because - _validate_local_dir already restricts the charset.""" + _validate_local_dir already rejects quotes and shell metacharacters.""" if p == "~": return '"$HOME"' if p.startswith("~/"): @@ -385,6 +401,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache: " for root, dirs, fns in safe_walk(base):", " for fn in sorted(fns):", " if not fn.lower().endswith('.gguf'): continue", + " if fn.startswith('._'): continue # macOS AppleDouble sidecar, not a real GGUF", " fp = os.path.join(root, fn)", " try: size = os.path.getsize(fp)", " except Exception: size = 0", @@ -787,6 +804,7 @@ def _llama_cpp_rebuild_cmd() -> str: class ModelDownloadRequest(BaseModel): repo_id: str + backend: str | None = None # "hf" (default) or "ollama" include: str | None = None # glob pattern e.g. "*Q4_K_M*" hf_token: str | None = None env_prefix: str | None = None # e.g. "source ~/venv/bin/activate" diff --git a/routes/cookbook_output.py b/routes/cookbook_output.py new file mode 100644 index 000000000..16a14adc2 --- /dev/null +++ b/routes/cookbook_output.py @@ -0,0 +1,19 @@ +"""Pure helpers for shaping cookbook task output for the status response. + +Kept dependency-free (no FastAPI / SQLAlchemy imports) so the behavior can be +unit-tested without standing up the whole app. +""" + + +def error_aware_output_tail(full_snapshot: str, status: str) -> str: + """Return the trailing slice of a task log for the status response. + + Failed tasks return the last 50 lines so the "Copy last 50 lines" action + surfaces the actual error context (stack traces, build output). Running and + other non-error tasks keep the cheaper 12-line tail to limit the payload on + the 10s polling interval. + """ + if not full_snapshot: + return "" + tail_lines = 50 if status == "error" else 12 + return "\n".join(full_snapshot.splitlines()[-tail_lines:]) diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 7a1ee85c6..edbba3ad7 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -19,36 +19,33 @@ from src.constants import COOKBOOK_STATE_FILE from pydantic import BaseModel from core.middleware import require_admin +from routes._validators import validate_remote_host, validate_ssh_port from core.platform_compat import ( IS_WINDOWS, - SSH_PATH_OVERRIDE, - NVIDIA_PATH_CANDIDATES, detached_popen_kwargs, find_bash, - git_bash_path, kill_process_tree, pid_alive, safe_chmod, which_tool, - translate_path, - get_wsl_windows_user_profile, ) from routes.shell_routes import TMUX_LOG_DIR -from src.constants import COOKBOOK_STATE_FILE +from routes.cookbook_output import error_aware_output_tail logger = logging.getLogger(__name__) from routes.cookbook_helpers import ( - _SSH_PORT_RE, _REMOTE_HOST_RE, _SESSION_ID_RE, - _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_remote_host, _validate_token, - _validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path, + _SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token, + _validate_local_dir, _validate_gpus, _shell_path, _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, _safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, + load_stored_hf_token, _append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, - _append_pip_install_runner_lines, _diagnose_serve_output, run_ssh_command_async, + _ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache, + _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, ModelDownloadRequest, ServeRequest, ) @@ -90,6 +87,127 @@ def setup_cookbook_routes() -> APIRouter: task["payload"].pop("hf_token", None) return state + def _diagnose_serve_output(text: str) -> dict | None: + """Server-side mirror of the Cookbook UI's common serve diagnoses. + + The browser uses cookbook-diagnosis.js for clickable fixes. This gives + the agent/tool path the same structured signal so it can retry with an + adjusted command instead of guessing from raw tmux output. + """ + if not text: + return None + tail = text[-6000:] + patterns = [ + ( + r"No available memory for the cache blocks|Available KV cache memory:.*-", + "No GPU memory left for KV cache after loading model.", + [ + {"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"}, + {"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"}, + ], + ), + ( + r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization", + "GPU ran out of memory during startup or warmup.", + [ + {"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"}, + {"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"}, + {"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"}, + ], + ), + ( + r"not divisib|must be divisible|attention heads.*divisible", + "Tensor parallel size is incompatible with the model.", + [ + {"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"}, + {"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"}, + ], + ), + ( + r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context", + "Context length is too large for available GPU memory.", + [ + {"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"}, + {"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"}, + ], + ), + ( + r"enable-auto-tool-choice requires --tool-call-parser", + "Auto tool choice requires an explicit tool call parser.", + [{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}], + ), + ( + r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not", + "Model requires custom code or newer model support.", + [{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}], + ), + ( + r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer", + "vLLM/Transformers kernel package mismatch.", + [{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}], + ), + ( + r"Address already in use|bind.*address.*in use", + "Port is already in use.", + [{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}], + ), + ( + r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid", + "No GPUs are visible to the serve process.", + [{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}], + ), + ( + r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available", + "vLLM could not find a supported GPU (CUDA or ROCm). " + "This machine may have integrated or unsupported graphics only.", + [ + {"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"}, + {"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"}, + ], + ), + ( + r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed", + "vLLM is not installed or not in PATH on this server.", + [{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}], + ), + ( + r"sglang.*command not found|No module named sglang|SGLang is not installed", + "SGLang is not installed or not in PATH on this server.", + [{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}], + ), + ( + r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'|git: command not found|cmake: command not found", + "llama.cpp / llama-cpp-python dependencies are missing.", + [{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"No GGUF found on this host|no \.gguf file|No GGUF file found", + "No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.", + [{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}], + ), + ( + r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers", + "Diffusion serving requires PyTorch and diffusers.", + [{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}], + ), + ( + r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review", + "Model access is gated or unauthorized.", + [{"label": "set HF token and request model access on HuggingFace", "op": "manual"}], + ), + ] + for pattern, message, suggestions in patterns: + if re.search(pattern, tail, re.I): + return {"message": message, "suggestions": suggestions} + if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search( + r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I + ): + return { + "message": "Python traceback detected during serve startup.", + "suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}], + } + return None + def _state_for_client(state): """Return cookbook state without raw secrets for browser clients.""" _strip_task_secrets(state) @@ -120,14 +238,7 @@ def setup_cookbook_routes() -> APIRouter: return state def _load_stored_hf_token() -> str: - if not _cookbook_state_path.exists(): - return "" - try: - state = json.loads(_cookbook_state_path.read_text(encoding="utf-8")) - env = state.get("env") if isinstance(state, dict) else {} - return _decrypt_secret(env.get("hfToken") if isinstance(env, dict) else "") - except Exception: - return "" + return load_stored_hf_token(state_path=_cookbook_state_path) def _cookbook_ssh_dir() -> Path: # The Docker image keeps cookbook keys under /app/.ssh; that path only @@ -183,7 +294,6 @@ def setup_cookbook_routes() -> APIRouter: safe_chmod(key_path.with_suffix(".pub"), 0o644) return {"ok": True, "public_key": _read_cookbook_public_key()} - def _needs_binary(cmd: str, binary: str) -> bool: return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or "")) @@ -244,8 +354,8 @@ def setup_cookbook_routes() -> APIRouter: # POSIX form + shell-quoting so drive paths / spaces survive. inner = TMUX_LOG_DIR / f"{session_id}_run.sh" inner.write_text("\n".join(bash_lines) + "\n", encoding="utf-8") - lp = shlex.quote(git_bash_path(log_path)) - ip = shlex.quote(git_bash_path(inner)) + lp = shlex.quote(log_path.as_posix()) + ip = shlex.quote(inner.as_posix()) script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path.write_text( f"bash {ip} > {lp} 2>&1\n", @@ -286,24 +396,33 @@ def setup_cookbook_routes() -> APIRouter: require_admin(request) # Defence-in-depth: even though this endpoint is admin-gated, refuse # values that would land in shell contexts with metacharacters. - _validate_repo_id(req.repo_id) - _validate_include(req.include) - _validate_remote_host(req.remote_host) - req.ssh_port = _validate_ssh_port(req.ssh_port) + backend = (req.backend or "").strip().lower() + is_ollama_download = backend == "ollama" or ("/" not in req.repo_id and ":" in req.repo_id) + if is_ollama_download: + _validate_serve_model_id(req.repo_id) + req.include = None + req.local_dir = None + else: + _validate_repo_id(req.repo_id) + _validate_include(req.include) + validate_remote_host(req.remote_host) + req.ssh_port = validate_ssh_port(req.ssh_port) req.local_dir = _validate_local_dir(req.local_dir) - req.hf_token = req.hf_token or _load_stored_hf_token() + req.hf_token = "" if is_ollama_download else (req.hf_token or _load_stored_hf_token()) _validate_token(req.hf_token) TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) session_id = f"cookbook-{uuid.uuid4().hex[:8]}" wrapper_script = TMUX_LOG_DIR / f"{session_id}.sh" - # When a download directory is set, target a per-model subfolder under it - # (/) so the flat-directory cache scan lists it as its own - # model. Without it, hf/snapshot_download falls back to the HF cache. - _dl_short = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id - _dl_base = (req.local_dir.rstrip("/") + "/" + _dl_short) if req.local_dir else None - _dl_shell = _shell_path(_dl_base) if _dl_base else None # for hf CLI / bash - _dl_pyarg = (", local_dir=os.path.expanduser(" + repr(_dl_base) + ")") if _dl_base else "" + # Custom download dir: point the HF cache at /hub via env vars + # (HF_HOME + HUGGINGFACE_HUB_CACHE) instead of --local-dir. local_dir + # produces a flat layout (//) and the local-dir + # bookkeeping files (.cache/huggingface/.gitignore.lock), and it + # also breaks robust resume on flaky transfers — the blob-based hub + # cache survives SSL ReadError mid-stream by reusing .incomplete, + # local_dir does not. See issue #2722. + _dl_hf_home_shell = _shell_path(req.local_dir.rstrip("/")) if req.local_dir else None + _dl_pyarg = "" # snapshot_download honors the env vars too — no kwarg needed # Build the hf download command. Redirection to suppress the interactive # "update available? [Y/n]" prompt is added per-platform further down @@ -311,8 +430,7 @@ def setup_cookbook_routes() -> APIRouter: hf_cmd = f"hf download {req.repo_id}" if req.include: hf_cmd += f" --include '{req.include}'" - if _dl_shell: - hf_cmd += f" --local-dir {_dl_shell}" + ollama_cmd = f"ollama pull {shlex.quote(req.repo_id)}" # Build the shell wrapper — runs hf download directly in tmux (which is a TTY) # No script/tee needed — we'll use tmux capture-pane to read output @@ -320,8 +438,15 @@ def setup_cookbook_routes() -> APIRouter: lines.extend(_user_shell_path_bootstrap()) if req.hf_token: lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + if _dl_hf_home_shell and not is_ollama_download: + # Make hf download / snapshot_download honor the chosen dir via the + # standard HF cache (gives us the models--org--name/blobs/... layout + # with resumable .incomplete blobs). + lines.append(f"export HF_HOME={_dl_hf_home_shell}") + lines.append(f"export HUGGINGFACE_HUB_CACHE={_dl_hf_home_shell}/hub") + lines.append(f"export HF_HUB_CACHE={_dl_hf_home_shell}/hub") # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH - lines.append('export PATH="$HOME/.local/bin:$PATH"') + lines.append('export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') # When Odysseus runs from a venv (e.g. native macOS install), put its bin # on PATH so the tmux shell finds the bundled `hf`/`python3` without an # activated venv. Local bash runs only — meaningless over SSH. @@ -332,14 +457,25 @@ def setup_cookbook_routes() -> APIRouter: # throughput. Retries set disable_hf_transfer to fall back to the plain, # slower-but-reliable downloader (resumes cleanly from the .incomplete files). # Use `python3 -m pip` not `pip` — macOS has no bare `pip` command. - lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}") - if req.disable_hf_transfer: - lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") - lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + if is_ollama_download: + lines.append('if command -v ollama >/dev/null 2>&1; then') + lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') + lines.append('elif command -v docker >/dev/null 2>&1; then') + lines.append(' ODYSSEUS_OLLAMA_CONTAINER="$(docker ps --format \'{{.Names}}\' 2>/dev/null | grep -E \'^(ollama-rocm|ollama-test)$\' | head -1)"') + lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') + lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') + lines.append(' fi') + lines.append('fi') + lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi') else: - lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer')}") - lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}") + if req.disable_hf_transfer: + lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") + lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + else: + lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer')}") + lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") remote = req.remote_host # None for local is_windows = req.platform == "windows" @@ -361,37 +497,48 @@ def setup_cookbook_routes() -> APIRouter: ps_lines = [] ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') - ps_lines.append('$env:PYTHONIOENCODING = "utf-8"') - ps_lines.append('$env:PYTHONUTF8 = "1"') if req.hf_token: ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") + if req.local_dir and not is_ollama_download: + # Mirror the bash branch — point the HF cache at the user's dir + # via env vars instead of --local-dir, so resume works on flaky + # transfers (issue #2722). + _dl_ps = _ps_squote(req.local_dir.rstrip("/")) + ps_lines.append(f"$env:HF_HOME = '{_dl_ps}'") + ps_lines.append(f"$env:HUGGINGFACE_HUB_CACHE = '{_dl_ps}/hub'") + ps_lines.append(f"$env:HF_HUB_CACHE = '{_dl_ps}/hub'") if req.env_prefix: ps_lines.append(_safe_env_prefix(req.env_prefix)) - # Try hf CLI, fall back to Python huggingface_hub, then auto-install - ps_lines.append('try {{') - ps_lines.append(' $hfPath = Get-Command hf -ErrorAction SilentlyContinue') - ps_lines.append(' if ($hfPath) {{') - # Pipe $null to stdin to suppress interactive "update available? [Y/n]" prompt - ps_lines.append(f' $null | {hf_cmd}') - ps_lines.append(' }} else {{') - ps_lines.append(' python -c "import huggingface_hub" 2>$null') - ps_lines.append(' if ($LASTEXITCODE -eq 0) {{') - ps_lines.append(' Write-Host "hf CLI not found, using Python huggingface_hub..."') - ps_lines.append(' python -m pip install -q hf_transfer 2>$null') - ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') - ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") - ps_lines.append(' }} else {{') - ps_lines.append(' Write-Host "Installing huggingface-hub..."') - ps_lines.append(' python -m pip install -q huggingface-hub hf_transfer') - ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') - ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") - ps_lines.append(' }}') - ps_lines.append(' }}') - ps_lines.append(' if ($LASTEXITCODE -eq 0) {{ Write-Host ""; Write-Host "DOWNLOAD_OK" }}') - ps_lines.append(' else {{ Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }}') - ps_lines.append('}} catch {{') - ps_lines.append(' Write-Host ""; Write-Host "DOWNLOAD_FAILED ($_)"') - ps_lines.append('}}') + if is_ollama_download: + ps_lines.append('if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) { Write-Host "ERROR: Ollama not found. Install from https://ollama.com/download/windows"; exit 127 }') + ps_lines.append(f"$null | ollama pull '{_ps_squote(req.repo_id)}'") + ps_lines.append('if ($LASTEXITCODE -eq 0) { Write-Host ""; Write-Host "DOWNLOAD_OK" } else { Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }') + else: + # Try hf CLI, fall back to Python huggingface_hub, then auto-install + ps_lines.append('try {{') + ps_lines.append(' $hfPath = Get-Command hf -ErrorAction SilentlyContinue') + ps_lines.append(' if ($hfPath) {{') + # Pipe $null to stdin to suppress interactive "update available? [Y/n]" prompt + ps_lines.append(f' $null | {hf_cmd}') + ps_lines.append(' }} else {{') + ps_lines.append(' python -c "import huggingface_hub" 2>$null') + ps_lines.append(' if ($LASTEXITCODE -eq 0) {{') + ps_lines.append(' Write-Host "hf CLI not found, using Python huggingface_hub..."') + ps_lines.append(' python -m pip install -q hf_transfer 2>$null') + ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') + ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") + ps_lines.append(' }} else {{') + ps_lines.append(' Write-Host "Installing huggingface-hub..."') + ps_lines.append(' python -m pip install -q huggingface-hub hf_transfer') + ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') + ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") + ps_lines.append(' }}') + ps_lines.append(' }}') + ps_lines.append(' if ($LASTEXITCODE -eq 0) {{ Write-Host ""; Write-Host "DOWNLOAD_OK" }}') + ps_lines.append(' else {{ Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }}') + ps_lines.append('}} catch {{') + ps_lines.append(' Write-Host ""; Write-Host "DOWNLOAD_FAILED ($_)"') + ps_lines.append('}}') ps_lines.append(f'Remove-Item -Force "$HOME\\{remote_runner}" -ErrorAction SilentlyContinue') runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1" runner_path.write_text("\r\n".join(ps_lines) + "\r\n", encoding="utf-8") @@ -422,6 +569,10 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append("deactivate 2>/dev/null; hash -r") if req.hf_token: runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + if _dl_hf_home_shell and not is_ollama_download: + runner_lines.append(f"export HF_HOME={_dl_hf_home_shell}") + runner_lines.append(f"export HUGGINGFACE_HUB_CACHE={_dl_hf_home_shell}/hub") + runner_lines.append(f"export HF_HUB_CACHE={_dl_hf_home_shell}/hub") if req.env_prefix: runner_lines.append(_safe_env_prefix(req.env_prefix)) else: @@ -432,42 +583,67 @@ def setup_cookbook_routes() -> APIRouter: 'done' ) # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH - runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') # Install hf CLI + optional hf_transfer best-effort. Retries disable # hf_transfer because the Rust parallel path is fast but has been # flaky near the end of very large multi-file downloads. - # The helper tries active pip first, then guarded user-site fallbacks. - runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}") - if req.disable_hf_transfer: - runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") - runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + # Use --break-system-packages on PEP-668 systems (Arch, newer Debian) so it doesn't bail. + if is_ollama_download: + runner_lines.append('if command -v ollama >/dev/null 2>&1; then') + runner_lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') + runner_lines.append('elif command -v docker >/dev/null 2>&1; then') + runner_lines.append(' ODYSSEUS_OLLAMA_CONTAINER="$(docker ps --format \'{{.Names}}\' 2>/dev/null | grep -E \'^(ollama-rocm|ollama-test)$\' | head -1)"') + runner_lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') + runner_lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') + runner_lines.append(' fi') + runner_lines.append('fi') + runner_lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi') else: - runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}") - runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") - # Surface whether the HF token actually reached THIS server, so a gated - # download's "not authorized" failure can be told apart from a missing - # token (the token is masked — we only print applied / not-set). - runner_lines.append(_HF_TOKEN_STATUS_SNIPPET) - # Try hf CLI first, fall back to Python huggingface_hub, then auto-install - runner_lines.append('if command -v hf &>/dev/null; then') - # < /dev/null suppresses interactive "update available? [Y/n]" prompt - runner_lines.append(f' {hf_cmd} < /dev/null') - runner_lines.append('elif python3 -c "import huggingface_hub" 2>/dev/null; then') - runner_lines.append(' echo "hf CLI not found, using Python huggingface_hub..."') - runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"') - runner_lines.append('else') - runner_lines.append(' echo "Installing huggingface-hub and dependencies..."') - runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null') - if req.disable_hf_transfer: - runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null') - runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0') + runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}") + if req.disable_hf_transfer: + runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + else: + runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}") + runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + # Surface whether the HF token actually reached THIS server, so a gated + # download's "not authorized" failure can be told apart from a missing + # token (the token is masked — we only print applied / not-set). + runner_lines.append(_HF_TOKEN_STATUS_SNIPPET) + # Wrap the download in a retry loop. Large HF/Ollama transfers can + # hit transient network failures; both backends resume cached partials. + mw = 4 if req.disable_hf_transfer else 8 + runner_lines.append('_max_retries=10; _attempt=0; _ec=0') + runner_lines.append('while [ $_attempt -lt $_max_retries ]; do') + runner_lines.append(' _attempt=$((_attempt+1))') + if is_ollama_download: + runner_lines.append(' eval "$ODYSSEUS_OLLAMA_PULL_CMD" < /dev/null') else: - runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') - runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"') - runner_lines.append('fi') - runner_lines.append('_ec=$?; if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec)"; fi') + runner_lines.append(' if command -v hf &>/dev/null; then') + runner_lines.append(f' {hf_cmd} < /dev/null') + runner_lines.append(' elif python3 -c "import huggingface_hub" 2>/dev/null; then') + runner_lines.append(' [ $_attempt -eq 1 ] && echo "hf CLI not found, using Python huggingface_hub..."') + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={mw})"') + runner_lines.append(' else') + runner_lines.append(' echo "Installing huggingface-hub and dependencies..."') + runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null') + if req.disable_hf_transfer: + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null') + runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0') + else: + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') + runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={mw})"') + runner_lines.append(' fi') + runner_lines.append(' _ec=$?') + runner_lines.append(' if [ $_ec -eq 0 ]; then break; fi') + runner_lines.append(' if [ $_attempt -lt $_max_retries ]; then') + runner_lines.append(' echo ""; echo "Download attempt $_attempt failed (exit $_ec) — retrying in 30s..."') + runner_lines.append(' sleep 30') + runner_lines.append(' fi') + runner_lines.append('done') + runner_lines.append('if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec after $_attempt attempts)"; fi') runner_lines.append(f"rm -f {remote_runner}") runner_lines.append('exec "${SHELL:-/bin/bash}"') runner_path = TMUX_LOG_DIR / f"{session_id}_run.sh" @@ -493,23 +669,30 @@ def setup_cookbook_routes() -> APIRouter: lines.append("deactivate 2>/dev/null; hash -r") # Show whether the HF token reached this run (masked) — tells a gated # "not authorized" failure apart from a missing token. - lines.append(_HF_TOKEN_STATUS_SNIPPET) - if IS_WINDOWS: - # Detached path: no controlling TTY, so skip `< /dev/null` - # (handled by Popen stdin=DEVNULL) and don't keep a shell open. - lines.append(hf_cmd) - lines.append('_ec=$?; if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec)"; fi') - else: - # < /dev/null suppresses interactive "update available? [Y/n]" prompt - lines.append(f"{hf_cmd} < /dev/null") - lines.append('_ec=$?; if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec)"; fi') + if not is_ollama_download: + lines.append(_HF_TOKEN_STATUS_SNIPPET) + # Retry loop — same rationale as the remote-bash path. Issue #2722. + _hf_invoke = 'eval "$ODYSSEUS_OLLAMA_PULL_CMD" < /dev/null' if is_ollama_download else (hf_cmd if IS_WINDOWS else f"{hf_cmd} < /dev/null") + lines.append('_max_retries=10; _attempt=0; _ec=0') + lines.append('while [ $_attempt -lt $_max_retries ]; do') + lines.append(' _attempt=$((_attempt+1))') + lines.append(f' {_hf_invoke}') + lines.append(' _ec=$?') + lines.append(' if [ $_ec -eq 0 ]; then break; fi') + lines.append(' if [ $_attempt -lt $_max_retries ]; then') + lines.append(' echo ""; echo "Download attempt $_attempt failed (exit $_ec) — retrying in 30s..."') + lines.append(' sleep 30') + lines.append(' fi') + lines.append('done') + lines.append('if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec after $_attempt attempts)"; fi') + if not IS_WINDOWS: lines.append(f"rm -f '{wrapper_script}'") lines.append('exec "${SHELL:-/bin/bash}"') wrapper_script.write_text("\n".join(lines) + "\n", encoding="utf-8") wrapper_script.chmod(0o755) setup_cmd = None if IS_WINDOWS else f"tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}" - logger.info(f"Model download: {req.repo_id} (include={req.include}, session={session_id}, remote={remote})") + logger.info(f"Model download: {req.repo_id} (backend={'ollama' if is_ollama_download else 'hf'}, include={req.include}, session={session_id}, remote={remote})") logger.info(f"Download setup_cmd: {setup_cmd}") if setup_cmd is None: @@ -554,9 +737,8 @@ def setup_cookbook_routes() -> APIRouter: # Validate shell-bound inputs, matching the sibling list_gpus endpoint — # `host`/`ssh_port` are interpolated into an ssh command below, so an # unvalidated value (e.g. "x'; rm -rf ~ #") would be command injection. - host = _validate_remote_host(host) - if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(host) + ssh_port = validate_ssh_port(ssh_port) TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) model_dirs = [] @@ -564,35 +746,24 @@ def setup_cookbook_routes() -> APIRouter: for d in model_dir.split(','): d = d.strip() if d: - translated_d = translate_path(d) if not host else d - model_dirs.append(translated_d) - win_hf_hub = None - if not host: - win_profile = get_wsl_windows_user_profile() - win_hf_hub = os.path.join(win_profile, ".cache", "huggingface", "hub") if win_profile else None - - paths_code = _cached_model_scan_script(model_dirs, win_hf_hub) + model_dirs.append(d) + paths_code = _cached_model_scan_script(model_dirs) scan_py = TMUX_LOG_DIR / "scan_cache.py" scan_py.write_text(paths_code, encoding="utf-8") - scan_payload = scan_py.read_bytes() if host: + _pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else "" if platform == "windows": - remote_cmd = "python -" + # Windows: use 'python' and pipe via stdin with double-quote wrapping + cmd = f'ssh {_pf}{host} "python -" < \'{scan_py}\'' else: - # POSIX: use 'python3' if available, fall back to 'python'; throw if neither is found. - remote_cmd = ( - "if command -v python3 >/dev/null 2>&1; then python3 -; " - "elif command -v python >/dev/null 2>&1; then python -; " - "else echo \"python3/python not found\" >&2; exit 127; fi" - ) - rc, stdout_b, stderr_b = await run_ssh_command_async( - host, - ssh_port, - remote_cmd, - timeout=60, - stdin_data=scan_payload, + cmd = f"ssh {_pf}{host} 'python3 -' < '{scan_py}'" + proc = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(Path.home()), ) else: # LOCAL scan: use sys.executable (the venv Python Odysseus is already @@ -612,7 +783,7 @@ def setup_cookbook_routes() -> APIRouter: stderr=asyncio.subprocess.PIPE, cwd=str(Path.home()), ) - stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=60) + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=60) models = [] try: @@ -716,11 +887,16 @@ def setup_cookbook_routes() -> APIRouter: # listening" check without requiring ss/netstat/nmap. ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"] if ssh_port and str(ssh_port) != "22": - if not _SSH_PORT_RE.match(str(ssh_port)): + try: + ssh_port = validate_ssh_port(ssh_port) + except HTTPException: return None ssh_base.extend(["-p", str(ssh_port)]) - host_arg = remote - if not _REMOTE_HOST_RE.match(host_arg): + try: + host_arg = validate_remote_host(remote) + except HTTPException: + return None + if not host_arg: return None probe_ports = " ".join(str(start_port + i) for i in range(max_offset + 1)) script = ( @@ -752,6 +928,100 @@ def setup_cookbook_routes() -> APIRouter: return p return None + async def _serve_crash_watchdog( + endpoint_id: str, + session_id: str, + remote: str | None, + ssh_port: str | None, + is_windows: bool, + ) -> None: + """Drop a freshly-registered endpoint when the cookbook serve dies early. + + The runner script always emits ``=== Process exited with code N ===`` + when the launched cmd terminates (success or failure). We poll the + tmux pane periodically; on a non-zero exit detected within the watch + window, the endpoint row is deleted so the picker doesn't keep a + dead model around. A zero exit (rare for a long-running serve, but + possible for fast-failing builds that the runner reports as code 0) + and "missing exit marker" both leave the endpoint alone — that's + the loading-but-not-yet-bound state, which the probe-marks-offline + logic already handles. + + Times are picked to outlast realistic vLLM load times (Qwen3.5-122B + takes ~3 min to load) without burning resources on a stuck-forever + wait. After the last check, the watchdog gives up — the picker's + per-endpoint probe takes over from there. + """ + # Cumulative wait points: 25 s, 60 s, 2 min, 5 min. + _waits = [25, 35, 60, 180] + # Tmux capture-pane equivalent of the polling path used elsewhere in + # this file. Build it once and reuse on each tick. Skip the watchdog + # entirely on native-Windows local runs (no tmux). The Windows + # detached-process path writes its log to a known file and has its + # own lifecycle tracking; punting here keeps the code simple. + local_win = is_windows and not remote + if local_win: + return + if remote: + ssh_args = ["ssh"] + if ssh_port and ssh_port != "22": + ssh_args.extend(["-p", str(ssh_port)]) + capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"] + else: + capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"] + + _exit_re = re.compile(r"=== Process exited with code (-?\d+) ===") + for wait_s in _waits: + await asyncio.sleep(wait_s) + try: + proc = await asyncio.create_subprocess_exec( + *capture_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=8) + output = stdout.decode("utf-8", errors="replace") + except Exception as e: + logger.debug(f"crash-watchdog: capture-pane failed (will retry): {e!r}") + continue + # Last occurrence wins — a serve that exits/restarts under the + # runner's "exec bash -i" trail will emit multiple markers; the + # most-recent code is the one that matters. + matches = list(_exit_re.finditer(output)) + if not matches: + continue + try: + exit_code = int(matches[-1].group(1)) + except (ValueError, IndexError): + continue + if exit_code == 0: + # Exit 0 on a long-running serve is unusual (a normal "loaded + # then ready" path keeps the process alive) but it happens for + # commands like "ollama pull" the user might launch through + # the same form. Don't drop the endpoint on a clean exit; + # let the probe layer mark it offline if nothing's listening. + logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}") + return + # Non-zero exit — drop the endpoint. + try: + from core.database import SessionLocal as _SL, ModelEndpoint as _ME + db = _SL() + try: + ep = db.query(_ME).filter(_ME.id == endpoint_id).first() + if ep: + logger.info( + f"crash-watchdog: dropping endpoint {endpoint_id} " + f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}" + ) + db.delete(ep) + db.commit() + finally: + db.close() + except Exception as e: + logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}") + return + logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}") + def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None: """Register a freshly-served LLM as a model endpoint so it appears in the model picker without a manual /setup step — the text-model sibling of @@ -763,6 +1033,10 @@ def setup_cookbook_routes() -> APIRouter: probing /v1/models and dims the endpoint until the server is reachable, so registering immediately (before the server finishes loading) is safe. """ + logger.info( + f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} " + f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}" + ) import re from core.database import SessionLocal, ModelEndpoint @@ -787,16 +1061,20 @@ def setup_cookbook_routes() -> APIRouter: else: port = 8080 # llama.cpp's llama-server default — the Apple Silicon path - # Determine host (mirrors the image path: SSH alias for remote serves). - # For local serves while Odysseus runs inside Docker, "localhost" - # resolves to the container itself — useless. Use host.docker.internal - # which compose maps to the actual host, matching what /setup adds - # for Ollama by hand. + # Determine host. The cookbook tmux for `local=true` serves runs INSIDE + # the odysseus container — so the right URL for the in-container + # backend to reach it is `localhost`, NOT `host.docker.internal` + # (the latter points at the docker HOST, which doesn't have a server + # on that port). The previous host.docker.internal fallback only made + # sense for /setup-added external services like systemd Ollama on the + # host — and those go through manual setup, not this auto-register + # code path. For remote serves we still use the SSH host alias. if remote: host = remote.split("@")[-1] if "@" in remote else remote + elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""): + host = "host.docker.internal" else: - from routes.model_routes import _docker_host_gateway_reachable - host = "host.docker.internal" if _docker_host_gateway_reachable() else "localhost" + host = "localhost" base_url = f"http://{host}:{port}/v1" @@ -805,7 +1083,9 @@ def setup_cookbook_routes() -> APIRouter: # If the serve command opts models into OpenAI tool-calling, record it so # agent_loop trusts emitted tool_calls instead of the name heuristic. + is_ollama_endpoint = "ollama" in (req.cmd or "").lower() supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None + pinned_models = [req.repo_id] if is_ollama_endpoint and req.repo_id else [] db = SessionLocal() try: @@ -815,14 +1095,43 @@ def setup_cookbook_routes() -> APIRouter: existing.is_enabled = True existing.model_type = "llm" existing.name = display_name + if is_ollama_endpoint: + existing.endpoint_kind = "ollama" + if pinned_models: + existing.cached_models = json.dumps(pinned_models) + existing.pinned_models = json.dumps(pinned_models) if supports_tools is not None: existing.supports_tools = supports_tools - # Wipe stale model lists so the picker re-probes and discovers - # the newly-served model instead of showing the old one. - existing.cached_models = None - existing.hidden_models = None db.commit() logger.info(f"Updated existing local model endpoint: {base_url}") + # Re-probe so cached_models matches what the server actually + # serves right now (the URL may have stayed the same but the + # model behind it changed across launches). + try: + from routes.model_routes import _probe_endpoint + import json as _json2 + probed = _probe_endpoint(base_url, existing.api_key, timeout=5) + if probed: + existing.cached_models = _json2.dumps(probed) + db.commit() + except Exception as _pe: + logger.warning(f"Re-probe failed for {base_url}: {_pe!r}") + # Sweep stale dupes: other endpoints with the same display name + # at DIFFERENT URLs (likely failed earlier-attempt ports) get + # deleted so the picker doesn't show an offline ghost next to + # the working one. Only sweeps endpoints whose id starts with + # `local-` so we never touch a user's hand-added DeepSeek/OpenAI/ + # etc. entry with a coincidentally matching name. + stale = (db.query(ModelEndpoint) + .filter(ModelEndpoint.name == display_name) + .filter(ModelEndpoint.base_url != base_url) + .filter(ModelEndpoint.id.like("local-%")) + .all()) + for s in stale: + logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})") + db.delete(s) + if stale: + db.commit() return existing.id ep_id = f"local-{uuid.uuid4().hex[:8]}" @@ -833,11 +1142,42 @@ def setup_cookbook_routes() -> APIRouter: api_key=None, is_enabled=True, model_type="llm", + endpoint_kind="ollama" if is_ollama_endpoint else "auto", + cached_models=json.dumps(pinned_models) if pinned_models else None, + pinned_models=json.dumps(pinned_models) if pinned_models else None, supports_tools=supports_tools, ) db.add(ep) db.commit() logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}") + # Same sweep on first-register path: drop any pre-existing local-* + # endpoints with this display name pointed elsewhere. + stale = (db.query(ModelEndpoint) + .filter(ModelEndpoint.name == display_name) + .filter(ModelEndpoint.id != ep_id) + .filter(ModelEndpoint.id.like("local-%")) + .all()) + for s in stale: + logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})") + db.delete(s) + if stale: + db.commit() + # Probe /v1/models NOW and write cached_models so the chat + # picker actually shows the model on the next /api/models + # call. Without this immediate probe, the endpoint has empty + # cached_models until the next background refresh fires (up + # to a minute later) and the picker shows nothing — even + # though the endpoint is in the DB and the server is up. + try: + from routes.model_routes import _probe_endpoint + import json as _json2 + probed = _probe_endpoint(base_url, None, timeout=5) + if probed: + ep.cached_models = _json2.dumps(probed) + db.commit() + logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}") + except Exception as _pe: + logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}") return ep_id except Exception as e: logger.error(f"Failed to auto-register local model endpoint: {e}") @@ -859,8 +1199,8 @@ def setup_cookbook_routes() -> APIRouter: """ require_admin(request) # Defence-in-depth: reject values that could break out of shell contexts. - _validate_remote_host(req.remote_host) - req.ssh_port = _validate_ssh_port(req.ssh_port) + validate_remote_host(req.remote_host) + req.ssh_port = validate_ssh_port(req.ssh_port) req.gpus = _validate_gpus(req.gpus) req.hf_token = req.hf_token or _load_stored_hf_token() _validate_token(req.hf_token) @@ -877,16 +1217,6 @@ def setup_cookbook_routes() -> APIRouter: in_venv=sys.prefix != sys.base_prefix, ) is_pip_install = bool(req.cmd and "pip install" in req.cmd) - remote = req.remote_host - is_windows = req.platform == "windows" - local_windows = IS_WINDOWS and not remote - if is_windows or local_windows: - if req.cmd.startswith("python3 "): - req.cmd = "python " + req.cmd[len("python3 "):] - if is_pip_install and ("llama-cpp-python" in req.cmd or "llama_cpp" in req.cmd) and (is_windows or local_windows): - if "--extra-index-url" not in req.cmd: - req.cmd += " --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu" - if is_pip_install: # Keep big dependency wheel builds (vLLM, …) off the home filesystem's # pip cache so they don't fail mid-build with "No space left" (#1219) @@ -920,7 +1250,12 @@ def setup_cookbook_routes() -> APIRouter: # Otherwise the runner script picks one at runtime and `_auto_register` # below still registers the stale 11434 default — which on a host with # a systemd ollama lands on the wrong (unreachable-from-docker) service. - if "ollama" in req.cmd and "OLLAMA_HOST=" not in req.cmd: + # Match "ollama serve" as a phrase (with optional flags after), not + # any substring containing "ollama" — otherwise commands like + # `docker exec ollama-test ollama-import …` get wrapped as if they + # were native `ollama serve`, prepending OLLAMA_HOST=… and then + # running the ollama-not-found preflight which exits 127. + if re.search(r"\bollama\s+serve\b", req.cmd) and "OLLAMA_HOST=" not in req.cmd: _ollama_bind_host = "0.0.0.0" if remote else "127.0.0.1" _ollama_chosen_port = _pick_free_port_for_ollama( remote, req.ssh_port, start_port=11434, max_offset=10, @@ -950,8 +1285,6 @@ def setup_cookbook_routes() -> APIRouter: ps_lines = [] ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') - ps_lines.append('$env:PYTHONIOENCODING = "utf-8"') - ps_lines.append('$env:PYTHONUTF8 = "1"') if req.hf_token: ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") if req.gpus: @@ -970,7 +1303,7 @@ def setup_cookbook_routes() -> APIRouter: ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}') ps_lines.append('if ($LASTEXITCODE -ne 0) {') ps_lines.append(' Write-Host "Installing llama-cpp-python..."') - ps_lines.append(' python -m pip install llama-cpp-python[server] --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu') + ps_lines.append(' python -m pip install llama-cpp-python[server]') ps_lines.append('}') elif "vllm" in req.cmd: ps_lines.append('Write-Host "ERROR: vLLM is not supported on Windows. Use Ollama or llama.cpp instead."') @@ -1045,58 +1378,46 @@ def setup_cookbook_routes() -> APIRouter: # ollama is found (otherwise macOS falls back to a slow source build). # /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux. runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') - if local_windows: - # LOCAL Windows: no native source compilation (no cmake/compiler on Git Bash). - # Just check python bindings (using native `python` binary) and fall back to pip install. - runner_lines.append('if ! command -v llama-server &>/dev/null && ! python -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "llama-server not found — installing Python bindings..."') - runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='python')} || true") - runner_lines.append('fi') - runner_lines.append('if ! command -v llama-server &>/dev/null && ! python -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install attempts."') - runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') - runner_lines.append('fi') - else: - runner_lines.append('if [ -d /data/data/com.termux ]; then') - runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') - runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' pkg install -y cmake 2>/dev/null') - runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') - runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true') - runner_lines.append(' fi') - runner_lines.append('elif ! command -v llama-server &>/dev/null; then') - runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') - runner_lines.append(' mkdir -p ~/bin') - runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') - # Build with the right accelerator: Metal on macOS (llama.cpp - # enables it automatically, no flag), CUDA on Linux when present, - # else a plain CPU build. nproc is Linux-only — fall back to - # `sysctl hw.ncpu` on macOS. (Tip: `brew install llama.cpp` ships - # a prebuilt llama-server and skips this whole source build.) - runner_lines.append(' NPROC="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"') - runner_lines.append(' if [ "$(uname -s)" = "Darwin" ]; then') - runner_lines.append(' command -v cmake >/dev/null 2>&1 || echo "WARNING: cmake not found — install it with: brew install cmake (or: brew install llama.cpp for a prebuilt llama-server)."') - # Start from a clean cache: a prior failed configure (e.g. a CUDA - # attempt) poisons build/CMakeCache.txt, so a plain `cmake -B build` - # would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is - # explicit so the binary is optimized (Metal auto-enables on macOS). - runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\') - runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\') - runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') - runner_lines.append(' else') - _append_llama_cpp_linux_accel_build_lines(runner_lines) - runner_lines.append(' fi') - # If the native build failed, fall back to the Python bindings. - runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') - runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true") - runner_lines.append(' fi') - runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."') - runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') - runner_lines.append(' fi') - runner_lines.append('fi') - elif "ollama" in req.cmd: + runner_lines.append('if [ -d /data/data/com.termux ]; then') + runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') + runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' pkg install -y cmake 2>/dev/null') + runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') + runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true') + runner_lines.append(' fi') + runner_lines.append('elif ! command -v llama-server &>/dev/null; then') + runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') + runner_lines.append(' mkdir -p ~/bin') + runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') + # Build with the right accelerator: Metal on macOS (llama.cpp + # enables it automatically, no flag), CUDA on Linux when present, + # else a plain CPU build. nproc is Linux-only — fall back to + # `sysctl hw.ncpu` on macOS. (Tip: `brew install llama.cpp` ships + # a prebuilt llama-server and skips this whole source build.) + runner_lines.append(' NPROC="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"') + runner_lines.append(' if [ "$(uname -s)" = "Darwin" ]; then') + runner_lines.append(' command -v cmake >/dev/null 2>&1 || echo "WARNING: cmake not found — install it with: brew install cmake (or: brew install llama.cpp for a prebuilt llama-server)."') + # Start from a clean cache: a prior failed configure (e.g. a CUDA + # attempt) poisons build/CMakeCache.txt, so a plain `cmake -B build` + # would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is + # explicit so the binary is optimized (Metal auto-enables on macOS). + runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\') + runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\') + runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') + runner_lines.append(' else') + _append_llama_cpp_linux_accel_build_lines(runner_lines) + runner_lines.append(' fi') + runner_lines.append(' # If the native build failed, fall back to the Python bindings.') + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') + runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true") + runner_lines.append(' fi') + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append(' fi') + runner_lines.append('fi') + elif re.search(r"\bollama\s+serve\b", req.cmd): handled_ollama_serve = True _ollama_default_host = "0.0.0.0" if remote else "127.0.0.1" _ollama_host, _ollama_port = _ollama_bind_from_cmd( @@ -1117,23 +1438,13 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_try_port"') runner_lines.append(' break') runner_lines.append(' fi') - runner_lines.append(' echo "[odysseus] Ollama API ready on port ${ODYSSEUS_OLLAMA_PORT}: ${ODYSSEUS_OLLAMA_URL}"') - runner_lines.append(' echo "[odysseus] This task is monitoring an existing Ollama server; stopping it here will not stop an external Docker/system service."') - if local_windows: - # Windows detached process has no TTY; exec bash -i crashes. - # Keep the monitoring task alive with a sleep loop. - runner_lines.append(' while true; do sleep 60; done') - else: - runner_lines.append(' exec bash -i') - runner_lines.append('fi') + runner_lines.append(' exec 3<&-; exec 3>&-') + runner_lines.append('done') runner_lines.append('if ! command -v ollama &>/dev/null; then') runner_lines.append(' echo "ERROR: Ollama not found on this server. Install it from https://ollama.com/download or `curl -fsSL https://ollama.com/install.sh | sh`."') runner_lines.append(' echo') runner_lines.append(' echo "=== Process exited with code 127 ==="') - if local_windows: - runner_lines.append(' exit 127') - else: - runner_lines.append(' exec bash -i') + runner_lines.append(' exec bash -i') runner_lines.append('fi') runner_lines.append('ODYSSEUS_OLLAMA_URL="http://${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}"') if remote and _ollama_host in ("0.0.0.0", "::"): @@ -1141,20 +1452,24 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append('echo "[odysseus] Ollama has no built-in authentication; expose this only on a trusted LAN/VPN or provide an explicit OLLAMA_HOST with your own access controls."') runner_lines.append('echo "Starting ollama server on ${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}..."') runner_lines.append('OLLAMA_HOST="${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}" ollama serve') - if local_windows: - _append_serve_exit_code_lines(runner_lines, keep_shell_open=False) - else: - runner_lines.append('_ody_exit=$?') - runner_lines.append('echo') - runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') - runner_lines.append('exec bash -i') + runner_lines.append('_ody_exit=$?') + runner_lines.append('echo') + runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') + runner_lines.append('exec bash -i') elif "vllm serve" in req.cmd: # vLLM is CUDA/ROCm-only and does not run on macOS at all. runner_lines.append('if [ "$(uname -s)" = "Darwin" ]; then') runner_lines.append(' echo "ERROR: vLLM does not run on macOS. Use Ollama or llama.cpp (Metal) instead."') runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=1') runner_lines.append('fi') - _append_vllm_linux_preflight_lines(runner_lines) + # Put ~/.local/bin on PATH first — without a venv, vllm installs + # there via --user and the non-login serve shell otherwise can't + # find the `vllm` CLI ("command not found"). Mirrors llama.cpp above. + runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + runner_lines.append('if ! command -v vllm &>/dev/null; then') + runner_lines.append(' echo "ERROR: vLLM is not installed."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append('fi') elif "sglang.launch_server" in req.cmd: runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') runner_lines.append('if ! command -v sglang &>/dev/null; then') @@ -1173,15 +1488,30 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') runner_lines.append('fi') - if not handled_ollama_serve: + handled_ollama_sidecar_probe = False + if (not handled_ollama_serve + and re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\s+ollama\s+show\b", req.cmd or "")): + handled_ollama_sidecar_probe = True _append_serve_preflight_exit_lines( runner_lines, keep_shell_open=not local_windows, ) - if is_pip_install: - _append_pip_install_runner_lines(runner_lines, req.cmd) - else: - runner_lines.append(req.cmd) + runner_lines.append(req.cmd) + runner_lines.append('_ody_exit=$?') + runner_lines.append('echo') + runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') + runner_lines.append('if [ "$_ody_exit" -eq 0 ]; then') + runner_lines.append(' echo "[odysseus] Ollama sidecar model is available; keeping Cookbook task attached to the persistent Ollama daemon."') + runner_lines.append(' while true; do sleep 3600; done') + runner_lines.append('fi') + runner_lines.append('exec bash -i') + + if not handled_ollama_serve and not handled_ollama_sidecar_probe: + _append_serve_preflight_exit_lines( + runner_lines, + keep_shell_open=not local_windows, + ) + runner_lines.append(req.cmd) if local_windows: # Detached background process — no interactive shell to keep open. # Print the exit marker the status poller looks for, then stop. @@ -1263,6 +1593,26 @@ def setup_cookbook_routes() -> APIRouter: elif not is_pip_install: endpoint_id = _auto_register_llm_endpoint(req, remote) + # Crash watchdog: the auto-register above writes the endpoint row + # IMMEDIATELY (before the server has even bound its port) so the + # picker shows the model as it warms up. When the serve process + # crashes right at startup (missing module, bad cmd, port collision, + # ModuleNotFoundError on llama_cpp, etc.), the endpoint is left + # dangling — every subsequent chat returns 503 or an empty response. + # Schedule a background task to read the tmux output for the + # "=== Process exited with code N ===" marker the runner emits; + # if N != 0 within the watch window, delete the endpoint we just + # created. Skipped for diffusion (different image-endpoint cleanup + # path) and pip-install tasks (no endpoint to drop). + if endpoint_id and not is_diffusion and not is_pip_install: + asyncio.create_task(_serve_crash_watchdog( + endpoint_id=endpoint_id, + session_id=session_id, + remote=remote, + ssh_port=req.ssh_port, + is_windows=is_windows, + )) + # Log to assistant try: from src.assistant_log import log_to_assistant @@ -1290,12 +1640,11 @@ def setup_cookbook_routes() -> APIRouter: async def server_setup(request: Request, req: SetupRequest): """Install required dependencies on a remote server via SSH.""" require_admin(request) - host = _validate_remote_host(req.host) + host = validate_remote_host(req.host) if not host: raise HTTPException(400, "host is required") port = req.ssh_port - if port is not None and port != "" and not re.fullmatch(r"\d{1,5}", port): - raise HTTPException(400, "Invalid ssh_port") + port = validate_ssh_port(port) pf = f"-p {port} " if port and port != "22" else "" # Detect platform: Windows first (echo %OS% → Windows_NT), then Termux, then Linux @@ -1342,8 +1691,8 @@ def setup_cookbook_routes() -> APIRouter: cmd = f"ssh {pf}{host} '{setup_script}'" else: # Linux: auto-install tmux (via whichever package manager is available) - # and huggingface_hub + hf_transfer (falling back to --user, then - # guarded --break-system-packages on PEP-668 locked distros). + # and huggingface_hub + hf_transfer (falling back to --user/--break-system-packages + # on PEP-668 locked distros like Arch / newer Debian). setup_script = ( # Install tmux if missing — try common package managers; skip if no sudo "if ! command -v tmux >/dev/null 2>&1; then " @@ -1355,15 +1704,10 @@ def setup_cookbook_routes() -> APIRouter: " fi; " "fi; " "command -v tmux >/dev/null 2>&1 || echo 'WARNING: tmux missing and auto-install failed (need passwordless sudo). Install manually.'; " - # Install Python bits. Try system install first; fall back to --user, - # then use --break-system-packages only when pip supports it. + # Install Python bits. Try system install first; fall back to --user --break-system-packages on PEP 668 systems. "pip install -q huggingface_hub hf_transfer 2>/dev/null || " - "pip install --user -q huggingface_hub hf_transfer 2>/dev/null || " - "( pip install --help 2>/dev/null | grep -q -- --break-system-packages && " - "pip install --user --break-system-packages -q huggingface_hub hf_transfer 2>/dev/null ) || " - "pip3 install --user -q huggingface_hub hf_transfer 2>/dev/null || " - "( pip3 install --help 2>/dev/null | grep -q -- --break-system-packages && " - "pip3 install --user --break-system-packages -q huggingface_hub hf_transfer 2>/dev/null ); " + "pip install --user --break-system-packages -q huggingface_hub hf_transfer 2>/dev/null || " + "pip3 install --user --break-system-packages -q huggingface_hub hf_transfer 2>/dev/null; " "python3 -c 'from huggingface_hub import snapshot_download; print(\"OK\")'" ) cmd = f"ssh {pf}{host} '{setup_script}'" @@ -1386,38 +1730,11 @@ def setup_cookbook_routes() -> APIRouter: async def _run_nvidia_smi(query: str, host: str | None, ssh_port: str | None, timeout: int = 8): """Run nvidia-smi locally or over SSH. Returns (stdout, error_or_None).""" if host: - candidates = [query] - stripped = query.strip() - if stripped.startswith("nvidia-smi "): - args = stripped[len("nvidia-smi "):] - candidates.append( - "bash -lc " - + shlex.quote( - f"{SSH_PATH_OVERRIDE}" - f"nvidia-smi {args}" - ) - ) - for nvidia_path in NVIDIA_PATH_CANDIDATES: - candidates.append(f"{nvidia_path} {args}") - - last_err = "nvidia-smi failed" - for candidate in candidates: - try: - rc, stdout, stderr = await run_ssh_command_async( - host, - ssh_port, - candidate, - connect_timeout=5, - timeout=timeout, - ) - except asyncio.TimeoutError: - return None, "nvidia-smi timed out" - if rc == 0: - return stdout.decode("utf-8", errors="replace"), None - err = (stderr.decode("utf-8", errors="replace") or "").strip()[:200] - if err: - last_err = err - return None, last_err + pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else "" + cmd = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {pf}{host} '{query}'" + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) else: proc = await asyncio.create_subprocess_exec( *shlex.split(query), @@ -1571,9 +1888,8 @@ def setup_cookbook_routes() -> APIRouter: `busy` is True when free_mb/total_mb < 0.5. """ require_admin(request) - host = _validate_remote_host(host) - if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(host) + ssh_port = validate_ssh_port(ssh_port) gpu_query = "nvidia-smi --query-gpu=index,name,memory.free,memory.total,memory.used,utilization.gpu,uuid --format=csv,noheader,nounits" nvidia_error = None try: @@ -1730,9 +2046,8 @@ def setup_cookbook_routes() -> APIRouter: sig = (req.signal or "TERM").upper() if sig not in ("TERM", "KILL", "INT"): raise HTTPException(400, "signal must be TERM, KILL, or INT") - host = _validate_remote_host(req.host) - if req.ssh_port and not _SSH_PORT_RE.fullmatch(req.ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(req.host) + req.ssh_port = validate_ssh_port(req.ssh_port) kill_cmd = f"kill -{sig} {req.pid}" try: if host: @@ -1996,30 +2311,58 @@ def setup_cookbook_routes() -> APIRouter: return {"models": out} - # Rate-limit for the orphan-tmux adoption sweep. The UI polls - # tasks/status every ~3s; we don't want to SSH every host on every - # poll. 20s is fast enough that a model the agent launched in the - # background shows up "almost immediately" in the UI without being - # wasteful. + # Rate-limit for the orphan-tmux adoption sweep. 60s interval so SSH + # work is genuinely sparse even on an actively-polled cookbook page. _last_orphan_sweep_ts = [0.0] - _ORPHAN_SWEEP_MIN_INTERVAL_S = 20.0 + _ORPHAN_SWEEP_MIN_INTERVAL_S = 60.0 + # Concurrency guard so two requests racing don't both spawn a sweep. + _orphan_sweep_inflight = [False] def _maybe_sweep_orphans(tasks: list, state: dict) -> None: """Scan each configured cookbook server for `serve-*` tmux sessions the cookbook doesn't know about and adopt them into state.tasks. - Writes are conditional: if no orphans are found, nothing is touched. - Rate-limited so polling UIs don't trigger SSH on every refresh. + Heavy SSH work runs in a background thread via asyncio.to_thread so + it never blocks the request that triggered it. Was previously + disabled because the sync implementation pegged uvicorn CPU during + active cookbook polling — re-enabled now with the work pushed off + the event loop and a slower (60s) cadence. """ import time as _time - import subprocess - logger.info(f"_maybe_sweep_orphans: entered, last_ts={_last_orphan_sweep_ts[0]}") now = _time.monotonic() + if _orphan_sweep_inflight[0]: + return if now - _last_orphan_sweep_ts[0] < _ORPHAN_SWEEP_MIN_INTERVAL_S: - logger.info(f"_maybe_sweep_orphans: rate-limited, {now - _last_orphan_sweep_ts[0]:.1f}s since last") return _last_orphan_sweep_ts[0] = now + _orphan_sweep_inflight[0] = True + # Snapshot inputs so the worker doesn't race with state mutations. + try: + tasks_snap = list(tasks or []) + except Exception: + tasks_snap = [] + state_snap = state if isinstance(state, dict) else {} + # Caller is _cookbook_tasks_status_sync (sync context, no event + # loop). Use a plain background thread — no asyncio needed. + import threading + def _run_sweep() -> None: + try: + _sync_sweep_orphans(tasks_snap, state_snap) + except Exception as _e: + logger.warning(f"orphan sweep thread failed: {_e!r}") + finally: + _orphan_sweep_inflight[0] = False + try: + threading.Thread(target=_run_sweep, daemon=True, name="orphan-sweep").start() + except Exception as _e: + logger.warning(f"orphan sweep thread spawn failed: {_e!r}") + _orphan_sweep_inflight[0] = False + return + + def _sync_sweep_orphans(tasks: list, state: dict) -> None: + """The actual sync sweep — never call this on the event loop.""" + import subprocess env = state.get("env") if isinstance(state, dict) else {} servers = env.get("servers") if isinstance(env, dict) else [] logger.info(f"orphan sweep starting: {len(servers) if isinstance(servers, list) else 0} server(s), known_sids={len([t for t in tasks if isinstance(t, dict) and t.get('sessionId')])}") @@ -2038,14 +2381,19 @@ def setup_cookbook_routes() -> APIRouter: host = (srv.get("host") or "").strip() if not host: continue # local-only entry; the /proc scan handles it - if not _REMOTE_HOST_RE.match(host): + try: + host = validate_remote_host(host) + except HTTPException: continue sport = str(srv.get("port") or "").strip() ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"] if sport and sport != "22": - if not _SSH_PORT_RE.match(sport): + try: + sport = validate_ssh_port(sport) + except HTTPException: continue - ssh_base.extend(["-p", sport]) + if sport != "22": + ssh_base.extend(["-p", sport]) try: ls = subprocess.run( @@ -2143,6 +2491,121 @@ def setup_cookbook_routes() -> APIRouter: except Exception as e: logger.warning(f"orphan sweep: state write failed: {e}") + # In-memory cache for the Ollama library scrape. ollama.com is a public + # site, but it doesn't expose a stable JSON listing — we fetch the HTML + # search page and regex out the model cards. Cached for 1 h so a busy + # cookbook view doesn't hammer the site on every render. + _ollama_library_cache: dict = {"models": [], "fetched_at": 0.0, "error": None} + + _OLLAMA_FALLBACK_LIBRARY = [ + {"name": "qwen2.5", "description": "Qwen2.5 series — strong general/coding model from Alibaba.", "sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b", "72b"]}, + {"name": "qwen2.5-coder", "description": "Code-specialized Qwen2.5 family.", "sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b"]}, + {"name": "qwen3", "description": "Qwen3 — newer Alibaba family with hybrid reasoning.", "sizes": ["0.6b", "1.7b", "4b", "8b", "14b", "32b"]}, + {"name": "llama3.2", "description": "Meta Llama 3.2 instruct (and tiny / vision variants).", "sizes": ["1b", "3b", "11b", "90b"]}, + {"name": "llama3.1", "description": "Meta Llama 3.1 instruct.", "sizes": ["8b", "70b", "405b"]}, + {"name": "llama3.3", "description": "Meta Llama 3.3 70B instruct.", "sizes": ["70b"]}, + {"name": "gemma3", "description": "Google Gemma 3 — multimodal capable open-weights.", "sizes": ["1b", "4b", "12b", "27b"]}, + {"name": "gemma2", "description": "Google Gemma 2 instruct.", "sizes": ["2b", "9b", "27b"]}, + {"name": "mistral", "description": "Mistral 7B instruct — small, fast generalist.", "sizes": ["7b"]}, + {"name": "mistral-nemo", "description": "Mistral NeMo 12B instruct.", "sizes": ["12b"]}, + {"name": "mistral-small", "description": "Mistral Small 22B / 24B instruct.", "sizes": ["22b", "24b"]}, + {"name": "mixtral", "description": "Mistral MoE 8x7B / 8x22B.", "sizes": ["8x7b", "8x22b"]}, + {"name": "phi3", "description": "Microsoft Phi-3 small / medium.", "sizes": ["mini", "medium"]}, + {"name": "phi4", "description": "Microsoft Phi-4 14B.", "sizes": ["14b"]}, + {"name": "deepseek-r1", "description": "DeepSeek R1 reasoning model (distilled variants).", "sizes": ["1.5b", "7b", "8b", "14b", "32b", "70b"]}, + {"name": "deepseek-v3", "description": "DeepSeek V3 MoE 671B (huge — needs serious VRAM).", "sizes": ["671b"]}, + {"name": "codellama", "description": "Meta Code Llama instruct family.", "sizes": ["7b", "13b", "34b", "70b"]}, + {"name": "starcoder2", "description": "BigCode StarCoder2 — code completion.", "sizes": ["3b", "7b", "15b"]}, + {"name": "deepseek-coder-v2", "description": "DeepSeek Coder V2 — code MoE.", "sizes": ["16b", "236b"]}, + {"name": "nomic-embed-text", "description": "Embedding model — text vector encoder.", "sizes": ["latest"]}, + {"name": "mxbai-embed-large", "description": "Embedding model — Mixedbread large.", "sizes": ["latest"]}, + {"name": "llava", "description": "LLaVA multimodal vision-language model.", "sizes": ["7b", "13b", "34b"]}, + {"name": "minicpm-v", "description": "MiniCPM-V multimodal.", "sizes": ["8b"]}, + {"name": "command-r", "description": "Cohere Command R — RAG-oriented.", "sizes": ["35b"]}, + {"name": "command-r-plus", "description": "Cohere Command R+ — larger RAG model.", "sizes": ["104b"]}, + {"name": "qwq", "description": "Qwen QwQ reasoning preview.", "sizes": ["32b"]}, + {"name": "smollm2", "description": "HuggingFaceTB SmolLM2 — tiny capable models.", "sizes": ["135m", "360m", "1.7b"]}, + {"name": "granite3.1-dense", "description": "IBM Granite 3.1 dense instruct.", "sizes": ["2b", "8b"]}, + {"name": "nemotron", "description": "NVIDIA Nemotron 70B.", "sizes": ["70b"]}, + {"name": "olmo2", "description": "AI2 OLMo 2 open-weights.", "sizes": ["7b", "13b"]}, + ] + + @router.get("/api/cookbook/ollama/library") + async def ollama_library(refresh: int = 0, request: Request = None, owner: str = Depends(require_user)): + """List popular Ollama library models for the Browse picker. + + Tries a 1-hour-cached fetch of ollama.com/library, falls back to a + curated hard-coded list so the picker always renders something.""" + import time as _time + import httpx as _httpx + TTL = 3600.0 + now = _time.time() + if refresh or (now - _ollama_library_cache["fetched_at"]) > TTL or not _ollama_library_cache["models"]: + models: list[dict] = [] + err = None + try: + async with _httpx.AsyncClient(timeout=8, follow_redirects=True) as client: + resp = await client.get( + "https://ollama.com/search?sort=popular", + headers={"User-Agent": "odysseus-cookbook/1.0"}, + ) + if resp.status_code == 200: + html = resp.text + # ollama.com renders each model card as a single anchor: + # + # The description + sizes live inside that anchor. Pull + # the whole block then extract pieces individually. + block_re = re.compile( + r']*href="/library/([A-Za-z0-9._-]+)"[^>]*>(.*?)', + re.DOTALL, + ) + desc_re = re.compile(r']*>([^<]{4,400})

', re.DOTALL) + # Size tags on ollama.com cards look like "0.5b", "14b", + # "8x7b", "27b". Pulled from short -wrapped chips. + size_re = re.compile(r'>\s*(\d+(?:\.\d+)?(?:x\d+)?[bBmM])\s*<') + seen: set[str] = set() + for bm in block_re.finditer(html): + name = bm.group(1).strip() + if name in seen: + continue + seen.add(name) + body = bm.group(2) + dm = desc_re.search(body) + desc = (dm.group(1).strip() if dm else "").replace("\n", " ") + sizes_raw = size_re.findall(body) + # Dedup sizes preserving order + sizes: list[str] = [] + for s in sizes_raw: + s_low = s.lower() + if s_low not in sizes: + sizes.append(s_low) + models.append({"name": name, "description": desc, "sizes": sizes}) + if len(models) >= 80: + break + else: + err = f"HTTP {resp.status_code}" + except Exception as e: + err = str(e)[:160] + # Merge curated fallback so classics (qwen2.5, llama3, deepseek-r1, + # …) stay reachable even when ollama.com's front page is dominated + # by brand-new releases the user might not be looking for. + live_names = {m["name"] for m in models} + for fb in _OLLAMA_FALLBACK_LIBRARY: + if fb["name"] not in live_names: + models.append(fb) + if not models: + models = list(_OLLAMA_FALLBACK_LIBRARY) + if err is None: + err = "parsed 0 results — using fallback list" + _ollama_library_cache["models"] = models + _ollama_library_cache["fetched_at"] = now + _ollama_library_cache["error"] = err + return { + "models": _ollama_library_cache["models"], + "fetched_at": _ollama_library_cache["fetched_at"], + "error": _ollama_library_cache["error"], + } + @router.get("/api/cookbook/tasks/status") async def cookbook_tasks_status(request: Request): """Check status of all active cookbook tmux sessions. @@ -2180,13 +2643,39 @@ def setup_cookbook_routes() -> APIRouter: "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)" ) - if remote_host: - cmd = ["python3", "-c", py, repo_id] - else: - # Local Windows: python3 can hit the Microsoft Store stub. Use the - # real Python Odysseus is running under (guaranteed to exist). - import sys as _sys_local - cmd = [_sys_local.executable, "-c", py, repo_id] + cmd = ["python3", "-c", py, repo_id] + try: + if remote_host: + ssh_base = ["ssh"] + if ssh_port and ssh_port != "22": + ssh_base.extend(["-p", str(ssh_port)]) + shell_cmd = " ".join(shlex.quote(x) for x in cmd) + proc = subprocess.run(ssh_base + [remote_host, shell_cmd], timeout=12, capture_output=True) + else: + proc = subprocess.run(cmd, timeout=12, capture_output=True) + return proc.returncode == 0 + except Exception: + return False + + def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool: + """Best-effort check for resumable HF partial blobs. + + A lost SSH/tmux session can leave a real download still incomplete. + Treat any *.incomplete blob as stronger evidence than stale + "100%" lines in the captured pane output. + """ + if not repo_id or "/" not in repo_id: + return False + py = ( + "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: if remote_host: ssh_base = ["ssh"] @@ -2258,12 +2747,18 @@ def setup_cookbook_routes() -> APIRouter: if not _SESSION_ID_RE.match(session_id): logger.warning(f"Skipping task with unsafe session_id: {session_id!r}") continue - if remote and not _REMOTE_HOST_RE.match(remote): - logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") - continue - if _tport and not _SSH_PORT_RE.match(str(_tport)): - logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") - continue + if remote: + try: + remote = validate_remote_host(remote) + except HTTPException: + logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") + continue + if _tport: + try: + _tport = validate_ssh_port(str(_tport)) + except HTTPException: + logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") + continue if task_platform == "windows" and remote: # Windows: check PID file + Get-Process, read log tail sd = "$env:TEMP\\odysseus-sessions" @@ -2333,35 +2828,61 @@ def setup_cookbook_routes() -> APIRouter: except Exception: pass else: - try: - alive = subprocess.run(check_cmd, timeout=10, capture_output=True) - is_alive = alive.returncode == 0 - except Exception: + # Skip the live SSH check entirely for tasks already in a + # terminal state — they won't change, and 10s timeouts + # stacked per task were the dominant cost of this whole + # status endpoint (3+ minute stalls with ~8 accumulated + # stopped tasks). The agent's `list_served_models` call + # was blocking the chat stream every time. + _task_status = (task.get("status") or "").lower() + if _task_status in {"stopped", "done", "completed", + "crashed", "error", "failed", + "ended", "killed"}: is_alive = False - - # Capture last lines for progress. Prefer the "Downloading" line - # (real aggregate bytes) over "Fetching N files" (whole-file count that - # lags with hf_transfer). Falls back to the true last line otherwise. - if is_alive: + # Keep the persisted output_tail for the UI — it's + # what the agent uses to diagnose past failures. + full_snapshot = (task.get("output") or "")[-12000:] + else: try: - cap = subprocess.run(capture_cmd, timeout=10, capture_output=True, text=True) - if cap.returncode == 0: - full_snapshot = cap.stdout.strip() - lines = [l.strip() for l in full_snapshot.split('\n') if l.strip()] - downloading_lines = [l for l in lines if l.startswith("Downloading")] - if downloading_lines: - progress_text = downloading_lines[-1] - elif lines: - progress_text = lines[-1] + alive = subprocess.run(check_cmd, timeout=4, capture_output=True) + is_alive = alive.returncode == 0 except Exception: - pass + is_alive = False + + # Capture last lines for progress. Prefer the "Downloading" line + # (real aggregate bytes) over "Fetching N files" (whole-file count that + # lags with hf_transfer). Falls back to the true last line otherwise. + if is_alive: + try: + cap = subprocess.run(capture_cmd, timeout=4, capture_output=True, text=True) + if cap.returncode == 0: + full_snapshot = cap.stdout.strip() + lines = [l.strip() for l in full_snapshot.split('\n') if l.strip()] + downloading_lines = [l for l in lines if l.startswith("Downloading")] + if downloading_lines: + progress_text = downloading_lines[-1] + elif lines: + progress_text = lines[-1] + except Exception: + pass # Determine status. For the local-Windows detached model the log file # persists after the process exits, so a finished download still has a # snapshot to classify (DOWNLOAD_OK / exit marker) — evaluate it even # when the PID is gone instead of blindly reporting "stopped". download_zero_files = False + exit_code = None status = "unknown" + download_has_ok = task_type == "download" and "DOWNLOAD_OK" in full_snapshot + download_has_failed = task_type == "download" and "DOWNLOAD_FAILED" in full_snapshot + download_has_incomplete_evidence = ( + task_type == "download" + and ( + ".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 _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or "")) + ) + ) if is_alive or (local_win_task and full_snapshot): lower = full_snapshot.lower() exit_match = re.search(r"=== process exited with code\s+(-?\d+)", full_snapshot, re.I) @@ -2374,20 +2895,24 @@ def setup_cookbook_routes() -> APIRouter: elif has_exit and task_type == "download": # Dependency installs are tracked as download tasks but only # emit the generic runner exit marker, not HF download markers. - status = "completed" if exit_code == 0 else "error" + if download_has_incomplete_evidence and not download_has_ok: + status = "running" if is_alive else "stopped" + else: + status = "completed" if exit_code == 0 else "error" elif has_exit and "unrecognized arguments" in lower: status = "error" elif has_error and not ("application startup complete" in lower): status = "error" - elif task_type == "download" and ("100%" in full_snapshot or "DOWNLOAD_OK" in full_snapshot): - # Only download tasks treat 100% as "completed". - # Serve tasks log 100%|██████| during inference progress - # (diffusion sampling, etc.) — that's "running", not done. + elif task_type == "download" and download_has_ok: if re.search(r"Fetching\s+0\s+files", full_snapshot, re.IGNORECASE): status = "error" download_zero_files = True else: status = "completed" + elif task_type == "download" and download_has_failed: + status = "error" + elif task_type == "download" and download_has_incomplete_evidence: + status = "running" if is_alive else "stopped" elif "application startup complete" in lower: status = "ready" elif not is_alive: @@ -2397,7 +2922,11 @@ def setup_cookbook_routes() -> APIRouter: status = "running" else: # Session is dead — check if it completed or crashed - if task_type == "download" and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")): + if ( + task_type == "download" + and not download_has_incomplete_evidence + and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")) + ): status = "completed" if not progress_text: progress_text = "Download complete" @@ -2407,16 +2936,16 @@ def setup_cookbook_routes() -> APIRouter: status = "stopped" # Parse structured phase info — single source of truth for the UI - phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and status == "running" and full_snapshot) else {} + phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and full_snapshot) else {} if phase_info.get("status") == "ready": status = "ready" serve_phase = phase_info.get("phase", "") diagnosis = _diagnose_serve_output(full_snapshot) if task_type == "serve" and full_snapshot else None - if diagnosis and status in {"running", "unknown", "stopped"}: + if diagnosis and status in {"running", "unknown", "stopped"} and phase_info.get("status") != "ready": status = "error" if download_zero_files: diagnosis = {"message": "No matching files were downloaded. The model repo or filename/quant pattern may be wrong (for example a ':Q4_K_M' tag that does not exist in the repo). Check the repo and the include/quant pattern."} - output_tail = "\n".join(full_snapshot.splitlines()[-12:]) if full_snapshot else "" + output_tail = error_aware_output_tail(full_snapshot, status) results.append({ "session_id": session_id, @@ -2427,6 +2956,7 @@ def setup_cookbook_routes() -> APIRouter: "phase": serve_phase, "diagnosis": diagnosis, "output_tail": output_tail, + "exit_code": exit_code, "cmd": _payload.get("_cmd") or "", "tps": phase_info.get("tps"), "reqs": phase_info.get("reqs"), diff --git a/routes/diagnostics_routes.py b/routes/diagnostics_routes.py index daebef8d2..d6763798d 100644 --- a/routes/diagnostics_routes.py +++ b/routes/diagnostics_routes.py @@ -16,9 +16,18 @@ def setup_diagnostics_routes( rag_manager, rag_available: bool, research_handler, + memory_vector=None, ) -> APIRouter: router = APIRouter(tags=["diagnostics"]) + @router.get("/api/diagnostics/services") + async def get_service_health(request: Request) -> Dict[str, Any]: + """Consolidated degraded-state report for ChromaDB, SearXNG, email, + ntfy, and provider endpoints. Non-intrusive probes — safe to poll.""" + require_admin(request) + from src.service_health import collect_service_health + return await collect_service_health(rag_manager, memory_vector) + @router.get("/api/db/stats") async def get_database_stats(request: Request) -> Dict[str, Any]: require_admin(request) diff --git a/routes/document_routes.py b/routes/document_routes.py index cb41108e0..e4598d925 100644 --- a/routes/document_routes.py +++ b/routes/document_routes.py @@ -108,10 +108,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: # to markdown for prose. language = req.language if not language: - from src.tool_implementations import _looks_like_email_document, _sniff_doc_language + from src.agent_tools.document_tools import _looks_like_email_document, _sniff_doc_language language = _sniff_doc_language(req.content) else: - from src.tool_implementations import _looks_like_email_document + from src.agent_tools.document_tools import _looks_like_email_document if _looks_like_email_document(req.content, req.title): language = "email" @@ -643,7 +643,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: # in-memory active-doc pointer so the last-resort injection # path doesn't re-surface this doc in a later chat (#1160). try: - from src.tool_implementations import clear_active_document + from src.agent_tools.document_tools import clear_active_document clear_active_document(doc_id) except Exception: pass @@ -672,7 +672,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: # Closed/deleted — drop the in-memory active-doc pointer so it isn't # re-injected into a later, unrelated chat (#1160). try: - from src.tool_implementations import clear_active_document + from src.agent_tools.document_tools import clear_active_document clear_active_document(doc_id) except Exception: pass diff --git a/routes/email_helpers.py b/routes/email_helpers.py index 890680a87..b3df6a560 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -304,6 +304,7 @@ OWNER_SCOPED_EMAIL_CACHE_TABLES = { "email_ai_replies", "email_calendar_extractions", "email_urgency_alerts", + "sender_signatures", } @@ -341,6 +342,55 @@ def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, co _lg.getLogger(__name__).warning(f"{table} owner-migration skipped: {_mig_e}") +def _ensure_sender_signatures_table(conn): + """Create/migrate learned sender signatures to an owner-scoped cache.""" + create_sql = """ + CREATE TABLE IF NOT EXISTS sender_signatures ( + from_address TEXT, + owner TEXT DEFAULT '', + signature_text TEXT, + sample_count INTEGER, + last_built_at TEXT NOT NULL, + model_used TEXT, + source TEXT, + PRIMARY KEY (from_address, owner) + ) + """ + conn.execute(create_sql) + try: + info = conn.execute("PRAGMA table_info(sender_signatures)").fetchall() + cols = [r[1] for r in info] + pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])] + if "owner" in cols and pk_cols == ["from_address", "owner"]: + return + + conn.execute("ALTER TABLE sender_signatures RENAME TO sender_signatures__old") + conn.execute(create_sql) + old_cols = [r[1] for r in conn.execute("PRAGMA table_info(sender_signatures__old)").fetchall()] + copy_cols = [ + c for c in ( + "from_address", + "signature_text", + "sample_count", + "last_built_at", + "model_used", + "source", + ) + if c in old_cols + ] + source_owner = "COALESCE(owner, '')" if "owner" in old_cols else "''" + conn.execute( + f"INSERT OR IGNORE INTO sender_signatures " + f"({', '.join([*copy_cols, 'owner'])}) " + f"SELECT {', '.join([*copy_cols, source_owner])} " + f"FROM sender_signatures__old" + ) + conn.execute("DROP TABLE sender_signatures__old") + except Exception as _mig_e: + import logging as _lg + _lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}") + + def attachment_extract_dir(folder: str, uid: str) -> Path: """Containment-safe extraction directory for an attachment. @@ -559,20 +609,10 @@ def _init_scheduled_db(): conn.execute("ALTER TABLE email_boundaries ADD COLUMN turns_json TEXT") except Exception: pass - # Per-sender signature cache. Populated by `learn_sender_signatures` - # action: the LLM extracts the common trailing block across N emails - # from each sender; the renderer folds it consistently for every - # future email from that address. - conn.execute(""" - CREATE TABLE IF NOT EXISTS sender_signatures ( - from_address TEXT PRIMARY KEY, - signature_text TEXT, - sample_count INTEGER, - last_built_at TEXT NOT NULL, - model_used TEXT, - source TEXT - ) - """) + # Per-sender signature cache. Populated by `learn_sender_signatures`. + # Message sender addresses are global, so signatures must be scoped to the + # mailbox owner before `/read` returns them to the renderer. + _ensure_sender_signatures_table(conn) conn.commit() conn.close() @@ -762,10 +802,14 @@ def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int imaplib._MAXLINE = 50_000_000 return conn -def _imap_connect(account_id: str | None = None, owner: str = ""): +def _imap_connect(account_id: str | None = None, owner: str = "", + timeout: int = _IMAP_TIMEOUT_SECONDS): # SECURITY: passing `owner` scopes the fallback config lookup so a brand # new user doesn't get connected against another user's default mailbox # when they have no account configured. + # + # `timeout` is overridable so short-lived callers (e.g. the service-health + # probe) can impose a tighter budget than the default IMAP timeout. cfg = _get_email_config(account_id, owner=owner) # Connection mode: # STARTTLS on → plain + upgrade @@ -778,7 +822,7 @@ def _imap_connect(account_id: str | None = None, owner: str = ""): cfg["imap_host"], cfg["imap_port"], starttls=bool(cfg.get("imap_starttls")), - timeout=_IMAP_TIMEOUT_SECONDS, + timeout=timeout, ) try: conn.login(cfg["imap_user"], cfg["imap_password"]) diff --git a/routes/email_routes.py b/routes/email_routes.py index 797a142f2..f8ad50e2e 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -249,6 +249,41 @@ def _uid_from_fetch_meta(meta_b: bytes) -> str: return m.group(1).decode() if m else "" +_FETCH_SEQ_RE = re.compile(rb"^(\d+)\s+\(") + + +def _group_uid_fetch_records(msg_data) -> list: + """Group an imaplib UID FETCH response into per-message (meta, payload). + + imaplib yields an interleaved list: ``(meta, literal)`` tuples for + attributes that carry a literal (``RFC822.HEADER {n}`` etc.) plus bare + ``bytes`` elements for everything the server sends outside a literal. + Where each attribute lands is server-specific: Dovecot sends FLAGS + *before* the header literal (so it ends up inside the tuple meta), while + Gmail sends FLAGS *after* it, arriving as a bare ``b' FLAGS (\\Seen))'`` + element. Dropping bare elements therefore silently loses FLAGS on Gmail + and every message renders as unread/unflagged. + + A tuple whose meta starts with a sequence number opens a new record; + every other part — continuation tuple or bare bytes — is folded into the + current record's meta so attribute regexes see the full meta text. + Plain ``b')'`` terminators get folded in too, which is harmless. + """ + grouped: list = [] # list of (meta_bytes, payload_bytes_or_None) + for part in (msg_data or []): + if isinstance(part, tuple): + meta_b = part[0] if isinstance(part[0], (bytes, bytearray)) else str(part[0]).encode() + if _FETCH_SEQ_RE.match(meta_b): + grouped.append((meta_b, part[1])) + elif grouped: + cur_meta, cur_payload = grouped[-1] + grouped[-1] = (cur_meta + b" " + meta_b, cur_payload or part[1]) + elif isinstance(part, (bytes, bytearray)) and grouped: + cur_meta, cur_payload = grouped[-1] + grouped[-1] = (cur_meta + b" " + bytes(part), cur_payload) + return grouped + + def _smtp_ready(cfg: dict) -> bool: return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")) @@ -799,20 +834,11 @@ def setup_email_routes(): except Exception as e: logger.warning(f"Batch fetch failed, falling back to per-UID: {e}") status, msg_data = "NO", [] - # imaplib batch responses interleave (meta, payload) tuples and - # `b')'` terminators. Group by message: each tuple where the - # meta begins with a seq number starts a new message record. - seq_re = re.compile(rb'^(\d+)\s+\(') - grouped = [] # list of (meta_str, payload_bytes) - for part in (msg_data or []): - if isinstance(part, tuple): - meta_b = part[0] if isinstance(part[0], (bytes, bytearray)) else str(part[0]).encode() - if seq_re.match(meta_b): - grouped.append((meta_b, part[1])) - elif grouped: - # continuation of previous message — concatenate meta info if any - cur_meta, cur_payload = grouped[-1] - grouped[-1] = (cur_meta + b" " + meta_b, cur_payload or part[1]) + # Group the batched response into per-message (meta, payload) + # records. Bare bytes parts must be kept: Gmail returns FLAGS + # after the header literal as a bare element, and dropping it + # rendered every Gmail message as unread/unflagged. + grouped = _group_uid_fetch_records(msg_data) if status != "OK" and not grouped: conn.logout() @@ -1098,14 +1124,15 @@ def setup_email_routes(): continue raw_header = None flags = "" - for part in msg_data: - if isinstance(part, tuple): - meta = part[0].decode() if isinstance(part[0], bytes) else str(part[0]) - if b"RFC822.HEADER" in part[0] if isinstance(part[0], bytes) else "RFC822.HEADER" in meta: - raw_header = part[1] - flag_match = re.search(r'FLAGS \(([^)]*)\)', meta) - if flag_match: - flags = flag_match.group(1) + # Same Gmail caveat as the list route: FLAGS may + # arrive after the header literal, so group bare + # parts back into the message meta before scanning. + for meta_b, payload in _group_uid_fetch_records(msg_data): + if payload and b"RFC822.HEADER" in meta_b: + raw_header = payload + flag_match = re.search(rb'FLAGS \(([^)]*)\)', meta_b) + if flag_match: + flags = flag_match.group(1).decode(errors="replace") if not raw_header: continue msg = email_mod.message_from_bytes(raw_header) @@ -1247,8 +1274,9 @@ def setup_email_routes(): try: if sender_addr: _rs = _c.execute( - "SELECT signature_text FROM sender_signatures WHERE from_address = ?", - (sender_addr.lower().strip(),), + f"SELECT signature_text FROM sender_signatures " + f"WHERE from_address = ? AND {owner_clause}", + (sender_addr.lower().strip(), *owner_params), ).fetchone() if _rs and _rs[0]: cached_sender_sig = _rs[0] diff --git a/routes/gallery_helpers.py b/routes/gallery_helpers.py index 5cab62791..e4005b8a7 100644 --- a/routes/gallery_helpers.py +++ b/routes/gallery_helpers.py @@ -11,6 +11,7 @@ from typing import Dict, Any, Optional from pydantic import BaseModel from core.database import GalleryImage +from src.auth_helpers import _auth_disabled logger = logging.getLogger(__name__) @@ -120,19 +121,18 @@ def _image_to_dict(img: GalleryImage, session_name: str = None) -> Dict[str, Any } -def _owner_filter(q, user): +def _owner_filter(q, user, model_cls=GalleryImage): """Apply owner filtering to a gallery query. - When auth is disabled (single-user mode) get_current_user returns None - and there is no per-user scoping. The main library list and stats already - treat None as "show everything" (`if user is not None`), so this helper - must too — otherwise the tag/model filter sidebars come back empty and the - tag-cleanup endpoints (clear-user-tags, clear-ai-tags, dedupe-tags) - silently affect zero rows in the most common self-hosted deployment. + ``get_current_user`` returns None both in auth-disabled single-user mode + and when auth is enabled but no current user was resolved. Preserve the + single-user behavior, but fail closed for auth-enabled null-user states. """ - if user is None: + if user is not None: + return q.filter(model_cls.owner == user) + if _auth_disabled(): return q - return q.filter(GalleryImage.owner == user) + return q.filter(False) diff --git a/routes/gallery_routes.py b/routes/gallery_routes.py index 43999344e..feadc2ec8 100644 --- a/routes/gallery_routes.py +++ b/routes/gallery_routes.py @@ -476,8 +476,7 @@ def setup_gallery_routes() -> APIRouter: .outerjoin(DbSession, GalleryImage.session_id == DbSession.id) .filter(GalleryImage.is_active == True) ) - if user is not None: - q = q.filter(GalleryImage.owner == user) + q = _owner_filter(q, user) # Search filter (prompt + tags + ai_tags) if search: @@ -579,28 +578,26 @@ def setup_gallery_routes() -> APIRouter: db = SessionLocal() try: q = db.query(GalleryAlbum) - if user: - q = q.filter(GalleryAlbum.owner == user) + q = _owner_filter(q, user, GalleryAlbum) albums = q.order_by(GalleryAlbum.created_at.desc()).all() result = [] for a in albums: _count_q = db.query(GalleryImage).filter( GalleryImage.album_id == a.id, GalleryImage.is_active == True ) - if user: - _count_q = _count_q.filter(GalleryImage.owner == user) + _count_q = _owner_filter(_count_q, user) count = _count_q.count() cover_url = None if a.cover_id: - cover = db.query(GalleryImage).filter(GalleryImage.id == a.cover_id).first() + cover_q = db.query(GalleryImage).filter(GalleryImage.id == a.cover_id) + cover = _owner_filter(cover_q, user).first() if cover: cover_url = f"/api/generated-image/{cover.filename}" elif count > 0: _cover_q = db.query(GalleryImage).filter( GalleryImage.album_id == a.id, GalleryImage.is_active == True ) - if user: - _cover_q = _cover_q.filter(GalleryImage.owner == user) + _cover_q = _owner_filter(_cover_q, user) first = _cover_q.order_by(GalleryImage.created_at.desc()).first() if first: cover_url = f"/api/generated-image/{first.filename}" @@ -643,10 +640,9 @@ def setup_gallery_routes() -> APIRouter: base = db.query(GalleryImage).filter(GalleryImage.is_active == True) size_q = db.query(func.sum(GalleryImage.file_size)).filter(GalleryImage.is_active == True) album_q = db.query(GalleryAlbum) - if user: - base = base.filter(GalleryImage.owner == user) - size_q = size_q.filter(GalleryImage.owner == user) - album_q = album_q.filter(GalleryAlbum.owner == user) + base = _owner_filter(base, user) + size_q = _owner_filter(size_q, user) + album_q = _owner_filter(album_q, user, GalleryAlbum) total = base.count() total_size = size_q.scalar() or 0 fav_count = base.filter(GalleryImage.favorite == True).count() @@ -674,8 +670,7 @@ def setup_gallery_routes() -> APIRouter: GalleryImage.is_active == True, (GalleryImage.ai_tags == None) | (GalleryImage.ai_tags == ""), ) - if user: - q = q.filter(GalleryImage.owner == user) + q = _owner_filter(q, user) if album_id: q = q.filter(GalleryImage.album_id == album_id) untagged = q.count() diff --git a/routes/hwfit_routes.py b/routes/hwfit_routes.py index a7af18b04..45c209b0b 100644 --- a/routes/hwfit_routes.py +++ b/routes/hwfit_routes.py @@ -1,7 +1,9 @@ import re from copy import deepcopy -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException + +from routes._validators import validate_remote_host, validate_ssh_port # Backends the manual hardware simulator accepts. Must stay a subset of what @@ -11,6 +13,14 @@ from fastapi import APIRouter _MANUAL_BACKENDS = {"cuda", "rocm", "metal", "cpu_x86", "cpu_arm"} +def _validate_detection_target(host: str = "", ssh_port: str = "") -> tuple[str, str]: + host_value = validate_remote_host(host) or "" + port_value = validate_ssh_port(ssh_port) or "" + if port_value and not host_value: + raise HTTPException(400, "ssh_port requires host") + return host_value, port_value + + def _apply_manual_hardware(system, manual_mode="", manual_gpu_count="", manual_vram_gb="", manual_ram_gb="", manual_backend=""): """Manual hardware is a "what if I had this setup" simulator — REPLACES the detected hardware entirely instead of adding to it. @@ -105,6 +115,7 @@ def setup_hwfit_routes(): """Detect and return current system hardware info. Pass host=user@server for remote. fresh=true bypasses the per-host cache (the Rescan button).""" from services.hwfit.hardware import detect_system + host, ssh_port = _validate_detection_target(host, ssh_port) return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) @router.get("/models") @@ -118,6 +129,7 @@ def setup_hwfit_routes(): from services.hwfit.hardware import detect_system from services.hwfit.fit import rank_models from services.hwfit.models import get_models, model_catalog_path + host, ssh_port = _validate_detection_target(host, ssh_port) system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)) if system.get("error"): return {"system": system, "models": [], "error": system["error"]} @@ -165,8 +177,14 @@ def setup_hwfit_routes(): system["gpu_name"] = g["name"] system["active_group"] = {**g, "use_count": n} - if gpu_count != "": - n = int(gpu_count) + # Parse the optional count defensively (matches the gpu_group guard + # above): a non-numeric query param previously raised ValueError -> + # HTTP 500. A malformed value is ignored, same as omitting it. + try: + n = int(gpu_count) if gpu_count != "" else None + except ValueError: + n = None + if n is not None: if n == 0: # RAM-only mode: rank against system memory, offload allowed. system["has_gpu"] = False @@ -196,7 +214,24 @@ def setup_hwfit_routes(): if target_context is not None: target_context = max(1024, min(target_context, 1000000)) - results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context, fit_only=fit_only) + rank_kwargs = { + "use_case": use_case or None, + "limit": limit, + "search": search or None, + "sort": sort, + "quant": quant or None, + "fit_only": fit_only, + } + if target_context is not None: + rank_kwargs["target_context"] = target_context + try: + import inspect + supported = set(inspect.signature(rank_models).parameters) + rank_kwargs = {k: v for k, v in rank_kwargs.items() if k in supported} + except Exception: + rank_kwargs.pop("target_context", None) + rank_kwargs.pop("fit_only", None) + results = rank_models(system, **rank_kwargs) return {"system": system, "models": results} @router.get("/profiles") @@ -212,6 +247,7 @@ def setup_hwfit_routes(): from services.hwfit.hardware import detect_system from services.hwfit.models import get_models from services.hwfit.profiles import compute_serve_profiles + host, ssh_port = _validate_detection_target(host, ssh_port) system = detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) if system.get("error"): return {"system": system, "profiles": [], "error": system["error"]} @@ -262,6 +298,7 @@ def setup_hwfit_routes(): """Rank image generation models against detected hardware.""" from services.hwfit.hardware import detect_system from services.hwfit.image_models import rank_image_models + host, ssh_port = _validate_detection_target(host, ssh_port) system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)) if system.get("error"): return {"system": system, "models": [], "error": system["error"]} diff --git a/routes/memory_routes.py b/routes/memory_routes.py index 7be3c6d32..45cfcb743 100644 --- a/routes/memory_routes.py +++ b/routes/memory_routes.py @@ -105,6 +105,13 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM if memory_manager.find_duplicates(text, user_mem): return {"ok": True, "count": len(user_mem), "message": "Memory already exists"} + if memory_data.session_id: + try: + session_obj = session_manager.get_session(memory_data.session_id) + except KeyError: + raise HTTPException(404, "Session not found") + _assert_session_owner(session_obj, user) + new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user) if memory_data.session_id: new_entry["session_id"] = memory_data.session_id @@ -163,8 +170,17 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM session_id = memory.get("session_id") if session_id and session_id in session_manager.sessions: - session = session_manager.get_session(session_id) - memory["session_name"] = session.name if session else f"Session {session_id[:6]}" + try: + session = session_manager.get_session(session_id) + if session: + _assert_session_owner(session, user) + memory["session_name"] = session.name if session else f"Session {session_id[:6]}" + except KeyError: + memory["session_name"] = "Unknown" + except HTTPException as exc: + if exc.status_code != 404: + raise + memory["session_name"] = "Unknown" else: memory["session_name"] = "Unknown" diff --git a/routes/model_routes.py b/routes/model_routes.py index 995705d75..e53a23552 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -4,8 +4,8 @@ import os import re import uuid import json -import socket import hashlib +import socket import time as _time import logging import httpx @@ -123,6 +123,21 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int: return cleared_users +def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint_ids) -> bool: + """Whether the global default chat endpoint should be (re)assigned. + + True when nothing is configured yet, or the configured default no longer + resolves to an enabled endpoint (e.g. the user disabled it). Without the + second case, adding a new endpoint after disabling the previous default + leaves `default_endpoint_id` pointing at the disabled endpoint, so features + that read the raw setting (Memory → Tidy) fail with "No default model + configured" even though an enabled endpoint exists. See #3586. + """ + if not current_default_id: + return True + return current_default_id not in enabled_endpoint_ids + + # Loopback hosts a user might type for a local model server (LM Studio, # llama.cpp, vLLM, …). Inside Docker these point at the *container*, not the # host the server actually runs on. @@ -283,11 +298,9 @@ _HOST_TO_CURATED = ( ("fireworks.ai", "fireworks"), ("googleapis.com", "google"), ("x.ai", "xai"), - + ("nvidia.com", "nvidia"), ("openrouter.ai", "openrouter"), ("ollama.com", "ollama"), - ("opencode.ai/zen/go", "opencode-go"), - ("opencode.ai/zen", "opencode-zen"), ) @@ -480,10 +493,17 @@ _NON_CHAT_PREFIXES = ( "dall-e", "tts-", "whisper", "text-embedding", "embedding", "davinci", "babbage", "moderation", "omni-moderation", "sora", "gpt-image", "chatgpt-image", + # embedding / retrieval / non-chat models (common across providers) + "snowflake/arctic-embed", "nvidia/nv-embed", "embed", ) _NON_CHAT_CONTAINS = ( "-realtime", "-transcribe", "-tts", "-codex", - "codex-", + "codex-", "content-safety", "-safety", "-reward", "nvclip", + "kosmos", "fuyu", "deplot", "vila", "neva", + "gliner", "riva", "-parse", "-embedqa", "-nemoretriever", + "topic-control", "calibration", + "ai-synthetic-video", "cosmos-reason2", + "bge", "llama-guard", ) _NON_CHAT_EXACT_PREFIXES = ( "gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat) @@ -494,8 +514,6 @@ _NON_CHAT_EXACT_PREFIXES = ( def _is_chat_model(model_id: str) -> bool: """Return True if the model ID looks like a chat/completions-capable model.""" mid = model_id.lower() - if mid in {"gpt-5.1-codex"}: - return True for prefix in _NON_CHAT_PREFIXES: if mid.startswith(prefix): return False @@ -509,15 +527,7 @@ def _is_chat_model(model_id: str) -> bool: def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Optional[str] = None) -> bool: - """Delete a ProviderAuthSession once no endpoint still references it. - - Subscription providers (e.g. ChatGPT Subscription) keep their refresh token - in ProviderAuthSession rather than ModelEndpoint.api_key. When the last - endpoint backed by that auth row is removed, the stored credentials should - be cleared instead of lingering. Returns True if a row was deleted. - ``exclude_ep_id`` drops the endpoint currently being deleted from the - reference count so it does not keep its own auth alive. - """ + """Delete a ProviderAuthSession once no endpoint still references it.""" if not auth_id: return False from core.database import ProviderAuthSession @@ -534,40 +544,52 @@ def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Op return True -def _is_discovery_only_provider(provider: str) -> bool: - """Provider that only supports model discovery, not live probing. +def _safe_detect_provider(base_url: str) -> str: + """Best-effort provider detection that must not break endpoint probing.""" + try: + return _detect_provider(base_url) + except Exception as exc: + logger.debug("Provider detection failed for %s: %s", base_url, exc) + return "" - ChatGPT Subscription speaks the Responses/Codex API and has no - chat-completions or general health endpoint, so completion probes and - reachability pings are skipped — status is derived from cached models. - """ + +def _safe_build_models_url(base_url: str) -> str: + """Build a /models URL without letting optional provider imports break probes.""" + try: + return build_models_url(base_url) + except Exception as exc: + logger.debug("Model URL detection failed for %s: %s", base_url, exc) + return f"{(base_url or '').rstrip('/')}/models" + + +def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict: + """Build auth headers without letting optional provider imports break probes.""" + try: + return build_headers(api_key, base_url) + except Exception as exc: + logger.debug("Header detection failed for %s: %s", base_url, exc) + return {"Authorization": f"Bearer {api_key}"} if api_key else {} + + +def _is_discovery_only_provider(provider: str) -> bool: return provider == "chatgpt-subscription" def _resolve_probe_key(ep) -> Optional[str]: - """API key/bearer to probe an endpoint with. - - Delegates to ``resolve_endpoint_runtime``, which already returns the static - ``ModelEndpoint.api_key`` for keyed endpoints and resolves (and refreshes) - the runtime bearer for session-backed providers (e.g. ChatGPT Subscription). - Returns None if resolution fails (e.g. re-auth required) so probing skips - rather than raising. Reads only already-loaded scalar attributes of ``ep``. - """ + """API key/bearer to probe an endpoint with.""" try: from src.endpoint_resolver import resolve_endpoint_runtime _base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None)) return key - except Exception as e: - logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), e) + except Exception as exc: + logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc) return None -def _probe_single_model(base: str, api_key: Optional[str], model_id: str, timeout: int = 10, with_tools: bool = False) -> dict: +def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 10, with_tools: bool = False) -> dict: """Send a realistic completion request to a single model. Returns {status, latency_ms, error?}.""" - provider = _detect_provider(base) + provider = _safe_detect_provider(base) if _is_discovery_only_provider(provider): - # Responses/Codex API, not chat-completions: a completion probe would - # 400 and the re-probe flow would then hide every model. Discovery-only. return {"status": "ok", "latency_ms": 0, "skipped": True} messages = [ {"role": "system", "content": "You are a helpful assistant."}, @@ -587,12 +609,12 @@ def _probe_single_model(base: str, api_key: Optional[str], model_id: str, timeou elif provider == "ollama": from src.llm_core import _build_ollama_payload target_url = build_chat_url(base) - h = build_headers(api_key, base) + h = _safe_build_headers(api_key, base) h["Content-Type"] = "application/json" payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools) else: target_url = build_chat_url(base) - h = build_headers(api_key, base) + h = _safe_build_headers(api_key, base) h["Content-Type"] = "application/json" from src.llm_core import _uses_max_completion_tokens, _restricts_temperature _max_key = "max_completion_tokens" if _uses_max_completion_tokens(model_id) else "max_tokens" @@ -682,14 +704,15 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis For Anthropic, queries their /v1/models API, falling back to hardcoded list.""" from src.endpoint_resolver import resolve_url base = resolve_url(_normalize_base(base_url)) - if _detect_provider(base) == "chatgpt-subscription": + provider = _safe_detect_provider(base) + if provider == "chatgpt-subscription": from src.chatgpt_subscription import fetch_available_models if api_key: return fetch_available_models(api_key, timeout=timeout) return [] - if _detect_provider(base) == "anthropic": + if provider == "anthropic": # Try Anthropic's /v1/models endpoint first - url = build_models_url(base) + url = _safe_build_models_url(base) headers = {"anthropic-version": "2023-06-01"} if api_key: headers["x-api-key"] = api_key @@ -712,12 +735,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis return [] logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}") return list(ANTHROPIC_MODELS) - url = build_models_url(base) - if not url: - curated_key = _match_provider_curated(base, None) - fallback = _PROVIDER_CURATED.get(curated_key) if curated_key else None - return list(fallback or []) - headers = build_headers(api_key, base) + url = _safe_build_models_url(base) + headers = _safe_build_headers(api_key, base) try: r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify()) r.raise_for_status() @@ -735,7 +754,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis for _e in _PROVIDER_CURATED.get(_ck, []): if _e not in set(models) and not any(m.startswith(_e) for m in models): models.append(_e) - return models + return [m for m in models if _is_chat_model(m)] except httpx.HTTPStatusError as e: if api_key: status = e.response.status_code if e.response is not None else "unknown" @@ -759,7 +778,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis data = r.json() models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")] if models: - return models + return [m for m in models if _is_chat_model(m)] except Exception as e: logger.debug(f"Ollama /api/tags probe failed for {base}: {e}") # Fall back to curated list if the provider has a URL-based match (e.g. z.ai has no /models endpoint) @@ -770,11 +789,12 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis return list(fallback) return [] + def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]: """Reachability probe that does not require installed/listed models.""" from src.endpoint_resolver import resolve_url base = resolve_url(_normalize_base(base_url)) - headers = build_headers(api_key, base) + headers = _safe_build_headers(api_key, base) # Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version, # /api/tags. Probe native paths for Ollama-style endpoints, but avoid using @@ -785,10 +805,6 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> or "ollama" in (parsed_base.hostname or "").lower() ) - # APFEL-specific detection - host = (parsed_base.hostname or "").lower() - looks_like_apfel = "apfel" in host or parsed_base.port == 11435 - def _result_from_response(r) -> Dict[str, Any]: if 300 <= r.status_code < 400: loc = r.headers.get("location", "") @@ -810,23 +826,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> last_error: Optional[str] = None try: - # APFEL does not behave like Ollama; use its health endpoint. - if looks_like_apfel: - root = base - for suffix in ("/v1", "/api"): - if root.endswith(suffix): - root = root[: -len(suffix)].rstrip("/") - break - try: - r = httpx.get(root + "/health", timeout=timeout, verify=llm_verify()) - result = _result_from_response(r) - if result["reachable"]: - return result - last_error = result.get("error") - except Exception as e: - last_error = str(e)[:120] - - elif looks_like_ollama: + if looks_like_ollama: root = base for suffix in ("/v1", "/api"): if root.endswith(suffix): @@ -847,17 +847,11 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> try: r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify()) result = _result_from_response(r) - # If the bare base URL returns a non-auth 4xx (e.g. 404), try /models - # as a fallback. OpenAI-compatible servers like llama-swap return 404 - # on the base /v1 prefix but 200 on /v1/models. Auth failures (401/403) - # are definitive — probing /models would just repeat the same rejection. - if ( - not result["reachable"] - and result.get("status_code") is not None - and 400 <= result["status_code"] < 500 - and result["status_code"] not in (401, 403) - ): - models_url = build_models_url(base) + if result["reachable"]: + return result + sc = result.get("status_code") or 0 + if 400 <= sc < 500 and sc not in (401, 403): + models_url = _safe_build_models_url(base) try: r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify()) result2 = _result_from_response(r2) @@ -865,12 +859,16 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> return result2 except Exception: pass - return result + if sc: + return result + last_error = result.get("error") or last_error except Exception as e: last_error = str(e)[:120] return {"reachable": False, "status_code": None, "error": last_error} + + def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str: """Return a provider-aware error message for failed endpoint probes.""" ping = ping or {} @@ -1068,17 +1066,6 @@ def setup_model_routes(model_discovery): ok, info = _should_refresh_endpoint(ep, now, force=force) if not ok: continue - if getattr(ep, "provider_auth_id", None): - try: - from src.endpoint_resolver import resolve_endpoint_runtime - info["base"], info["api_key"] = resolve_endpoint_runtime( - ep, - owner=getattr(ep, "owner", None), - ) - info["key"] = _refresh_key(info["base"], info["api_key"]) - except Exception as e: - logger.warning("Skipping model refresh for %s: could not resolve provider auth: %s", getattr(ep, "name", ep.id), e) - continue groups.setdefault(info["key"], { "base": info["base"], "api_key": info["api_key"], @@ -1156,7 +1143,7 @@ def setup_model_routes(model_discovery): for ep in endpoints: base = _normalize_base(ep.base_url) - provider = _detect_provider(base) + provider = _safe_detect_provider(base) # Merge cached + pinned models, then filter out hidden ones ep_model_type = getattr(ep, "model_type", None) or "llm" model_ids = _visible_models( @@ -1233,8 +1220,8 @@ def setup_model_routes(model_discovery): except HTTPException: raise except Exception as e: - logger.error('Auth gate error in GET /api/models, failing closed: %s', e) - raise HTTPException(status_code=500, detail='Internal error') + logger.error("Auth gate error in GET /api/models, failing closed: %s", e) + raise HTTPException(status_code=500, detail="Internal error") # Admins see every endpoint (they manage the global pool); regular # users get the owner-scoped view. _is_admin = False @@ -1298,7 +1285,14 @@ def setup_model_routes(model_discovery): t0 = _time.time() try: import asyncio as _asyncio - ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 1.5) + # Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping + # local vLLM endpoints on Tailscale links where the model + # server is still loading (Qwen3.5-122B takes 2–3 min to + # warm); /v1/models can take 500–2500 ms on a busy box, + # which pushed _ping_endpoint's full path-discovery sweep + # past the cap and marked the row offline despite the + # user actively chatting with it. + ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 3.5) lat = round((_time.time() - t0) * 1000) return { "alive": bool(ping.get("reachable")), @@ -1336,7 +1330,7 @@ def setup_model_routes(model_discovery): results = [] for ep in endpoints: base = _normalize_base(ep.base_url) - provider = _detect_provider(base) + provider = _safe_detect_provider(base) kind = _effective_endpoint_kind(ep, base) cached_count = len(_cached_model_ids(ep)) entry = { @@ -1348,20 +1342,12 @@ def setup_model_routes(model_discovery): "endpoint_kind": kind, } try: - if _is_discovery_only_provider(provider): - # No general health endpoint — an unauthenticated GET just - # 401s. Report status from cached models instead of pinging. - entry["latency_ms"] = None - entry["status"] = "online" if cached_count else "offline" - entry["error"] = None - entry["model_count"] = cached_count - else: - t0 = _time.time() - ping = _ping_endpoint(base, ep.api_key, timeout=1.5) - entry["latency_ms"] = round((_time.time() - t0) * 1000) - entry["status"] = "online" if ping.get("reachable") or cached_count else "offline" - entry["error"] = ping.get("error") - entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0) + t0 = _time.time() + ping = _ping_endpoint(base, ep.api_key, timeout=1.5) + entry["latency_ms"] = round((_time.time() - t0) * 1000) + entry["status"] = "online" if ping.get("reachable") or cached_count else "offline" + entry["error"] = ping.get("error") + entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0) except Exception as e: entry["latency_ms"] = None entry["status"] = "online" if cached_count else "offline" @@ -1394,7 +1380,7 @@ def setup_model_routes(model_discovery): if ep_id and ep_id not in endpoints_cache: ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first() if ep: - endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": _resolve_probe_key(ep)} + endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": ep.api_key} ep_data = endpoints_cache.get(ep_id) if not ep_data: # Try to find by base_url from the model's endpoint field @@ -1433,7 +1419,7 @@ def setup_model_routes(model_discovery): "id": ep.id, "name": ep.name, "base_url": ep.base_url, - "api_key": _resolve_probe_key(ep), + "api_key": ep.api_key, }) finally: db.close() @@ -1522,14 +1508,37 @@ def setup_model_routes(model_discovery): # Endpoint counts as reachable if it has any model — including # admin-pinned IDs that a probe would never surface. status = "online" if (all_models or pinned) else "offline" - base = _normalize_base(r.base_url) ping = None - # Discovery-only providers have no health endpoint — an - # unauthenticated ping just 401s, so don't bother. - if not all_models and not pinned and r.is_enabled and not _is_discovery_only_provider(_detect_provider(base)): - ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0) + # When cached_models is empty, do a quick reachability probe. + # Bumped 1.0s → 3.5s because the user reported endpoints they + # were ACTIVELY chatting with showed "offline" — the previous + # 1s timeout was clipping live cloud endpoints (DeepSeek can + # take 1.5–2.5s on /v1/models when their region is under load, + # vLLM on a remote GPU box behind SSH can also push past 1s). + # 3.5s still keeps the picker render snappy in the common + # "everything's already cached" path because this branch only + # runs for endpoints with an empty cached_models. + if not all_models and not pinned and r.is_enabled: + ping = _ping_endpoint(r.base_url, r.api_key, timeout=3.5) if ping.get("reachable"): status = "empty" + # Best-effort: if the probe came back reachable, try + # to populate cached_models in the background so the + # NEXT picker load shows "online" instead of "empty". + # Failure here is silent — we already returned the + # "empty" status, and the existing background refresh + # path will eventually fill it in too. + try: + probed = _probe_endpoint(r.base_url, r.api_key, timeout=5) + if probed: + r.cached_models = json.dumps(probed) + db.commit() + all_models = probed + visible = _visible_models(all_models, r.hidden_models, pinned) + status = "online" + except Exception as _refill_err: + logger.debug(f"opportunistic cached_models refill failed for {r.id}: {_refill_err!r}") + base = _normalize_base(r.base_url) kind = _effective_endpoint_kind(r, base) results.append({ "id": r.id, @@ -1603,11 +1612,10 @@ def setup_model_routes(model_discovery): ) explicit_timeout = _explicit_model_list_timeout(base_url, requested_kind, refresh_timeout) - # Dedupe: if an endpoint with the same base_url and compatible - # credentials already exists and is reachable by the caller (shared or - # owned by them), return it instead of creating a duplicate row. Keep - # same-url/different-key rows distinct so users can group the same - # provider URL under multiple credentials. + # Dedupe: if an endpoint with the same base_url already exists and + # is reachable by the caller (shared or owned by them), return it + # instead of creating a duplicate row. Fixes "Scan for Servers" + # re-adding manually-added endpoints under their host:port name. from src.auth_helpers import get_current_user as _gcu_dedup _caller = _gcu_dedup(request) or None _incoming_api_key = api_key.strip() @@ -1734,12 +1742,19 @@ def setup_model_routes(model_discovery): ) db.add(ep) db.commit() - # Auto-set as default chat endpoint if none configured yet. Seed - # the first CHAT model (not raw model_ids[0]) so we don't pin the - # global default to an embedding/tts/etc. entry a provider happens - # to list first. + # Auto-set as default chat endpoint when none is usable yet — either + # nothing is configured, or the configured default points at an + # endpoint that is now missing/disabled (#3586). Seed the first CHAT + # model (not raw model_ids[0]) so we don't pin the global default to + # an embedding/tts/etc. entry a provider happens to list first. settings = _load_settings() - if not settings.get("default_endpoint_id"): + enabled_ids = { + e.id + for e in db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True # noqa: E712 + ).all() + } + if _default_endpoint_needs_assignment(settings.get("default_endpoint_id") or "", enabled_ids): from src.endpoint_resolver import _first_chat_model settings["default_endpoint_id"] = ep.id settings["default_model"] = _first_chat_model(model_ids) or "" @@ -1805,7 +1820,7 @@ def setup_model_routes(model_discovery): ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first() if not ep: raise HTTPException(404, "Endpoint not found") - ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": _resolve_probe_key(ep)} + ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": ep.api_key} finally: db.close() @@ -1869,7 +1884,7 @@ def setup_model_routes(model_discovery): category = _classify_endpoint(base, kind) timeout = _manual_refresh_timeout(ep, category, refresh_timeout) try: - probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout) + probed = _probe_endpoint(base, ep.api_key, timeout=timeout) except Exception as exc: logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc) probed = [] @@ -2105,8 +2120,6 @@ def setup_model_routes(model_discovery): "name": ep.name, "model_type": ep.model_type, "base_url": ep.base_url, - "has_key": bool(ep.api_key), - "api_key_fingerprint": _api_key_fingerprint(ep.api_key), "pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)), "endpoint_kind": getattr(ep, "endpoint_kind", None) or "auto", "model_refresh_mode": getattr(ep, "model_refresh_mode", None) or "auto", diff --git a/routes/session_routes.py b/routes/session_routes.py index 5bd693383..1fb2a487a 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -10,8 +10,9 @@ import logging from core.session_manager import SessionManager from core.models import ChatMessage from src.request_models import SessionResponse -from core.database import Session as DbSession, SessionLocal, Document, GalleryImage -from src.auth_helpers import get_current_user, effective_user, _auth_disabled +from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive +from src.auth_helpers import get_current_user, effective_user, _auth_disabled, owner_filter +from src.session_actions import is_session_recently_active def _sanitize_export_filename(name: str) -> str: @@ -257,7 +258,9 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ last_msg_map = {} mode_map = {} msg_count_map = {} - rows = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False, DbSession.owner == user).all() + q = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False) + q = owner_filter(q, DbSession, user) + rows = q.all() for row in rows: folder_map[row.id] = row.folder token_map[row.id] = (row.total_input_tokens or 0) + (row.total_output_tokens or 0) @@ -276,17 +279,19 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ # Sessions with active documents that have content from sqlalchemy import func doc_session_ids = set( - r[0] for r in db.query(Document.session_id) - .filter(Document.is_active == True, - Document.current_content != None, - func.trim(Document.current_content) != "", - Document.owner == user) + r[0] for r in owner_filter( + db.query(Document.session_id) + .filter(Document.is_active == True, + Document.current_content != None, + func.trim(Document.current_content) != ""), + Document, user) .distinct().all() ) img_session_ids = set( - r[0] for r in db.query(GalleryImage.session_id) - .filter(GalleryImage.session_id != None, - GalleryImage.owner == user) + r[0] for r in owner_filter( + db.query(GalleryImage.session_id) + .filter(GalleryImage.session_id != None), + GalleryImage, user) .distinct().all() ) finally: @@ -1028,6 +1033,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)) .filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all() ) + cleanup_now = utcnow_naive() for row in rows: # Never delete important sessions if getattr(row, 'is_important', False): @@ -1040,6 +1046,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ if hasattr(session_manager, 'delete_session'): session_manager.delete_session(row.id) continue + if is_session_recently_active(row, now=cleanup_now): + continue msg_count = _counts.get(row.id, 0) should_delete = False if msg_count == 0: diff --git a/routes/task_routes.py b/routes/task_routes.py index 57f76d5c6..5734fcb22 100644 --- a/routes/task_routes.py +++ b/routes/task_routes.py @@ -519,6 +519,15 @@ def setup_task_routes(task_scheduler) -> APIRouter: else bool(req.notifications_enabled) if req.notifications_enabled is not None else True ) + # Validate chained task belongs to same owner + if req.then_task_id: + chain_target = db.query(ScheduledTask).filter( + ScheduledTask.id == req.then_task_id + ).first() + if not chain_target: + raise HTTPException(400, "Chained task not found") + if chain_target.owner != user: + raise HTTPException(403, "Cannot chain to another user's task") task = ScheduledTask( id=task_id, owner=user, diff --git a/routes/workspace_routes.py b/routes/workspace_routes.py index f7b27fbdc..ef70e78c2 100644 --- a/routes/workspace_routes.py +++ b/routes/workspace_routes.py @@ -1,10 +1,15 @@ -"""Workspace API — browse server directories to pick a tool workspace folder.""" +"""Workspace API - browse server directories to pick a tool workspace folder.""" import os from fastapi import APIRouter, Request, HTTPException, Query from src.auth_helpers import get_current_user from src.tool_security import owner_is_admin_or_single_user +# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS). +# A huge directory shouldn't dump thousands of rows into the picker; the user can +# type/paste a path to jump straight in instead. +_MAX_BROWSE_DIRS = 500 + def setup_workspace_routes(): router = APIRouter(prefix="/api/workspace", tags=["workspace"]) @@ -34,7 +39,7 @@ def setup_workspace_routes(): with os.scandir(target) as it: for entry in it: try: - # Don't follow symlinks when classifying — a symlinked + # Don't follow symlinks when classifying - a symlinked # dir is skipped rather than letting the browser wander # off via a link. Hidden entries are omitted. if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."): @@ -46,11 +51,35 @@ def setup_workspace_routes(): except (PermissionError, OSError): dirs = [] + dirs_sorted = sorted(dirs, key=lambda d: d["name"].lower()) + truncated = len(dirs_sorted) > _MAX_BROWSE_DIRS parent = os.path.dirname(target) + from src.tool_execution import vet_workspace return { "path": target, "parent": parent if parent and parent != target else None, - "dirs": sorted(dirs, key=lambda d: d["name"].lower()), + "dirs": dirs_sorted[:_MAX_BROWSE_DIRS], + "truncated": truncated, + # Whether this directory may be bound as a workspace (filesystem + # roots and sensitive dirs may be browsed through but not chosen). + "selectable": vet_workspace(target) is not None, } + @router.get("/vet") + def vet(request: Request, path: str = Query(default="")): + """Validate a workspace path without binding it. + + The UI calls this before persisting a manually typed path (/workspace + set) so a typo, file path, deleted folder, sensitive dir, or filesystem + root is rejected up front with the canonical path returned on success, + instead of being stored client-side and silently dropped at chat time. + Admin-gated like /browse: it confirms path existence on the host. + """ + owner = get_current_user(request) + if not owner_is_admin_or_single_user(owner): + raise HTTPException(status_code=403, detail="Workspace selection is admin-only") + from src.tool_execution import vet_workspace + resolved = vet_workspace(path) + return {"ok": resolved is not None, "path": resolved} + return router diff --git a/services/hwfit/data/hf_models.json b/services/hwfit/data/hf_models.json index e73cc26dc..35b55d9a9 100644 --- a/services/hwfit/data/hf_models.json +++ b/services/hwfit/data/hf_models.json @@ -14036,6 +14036,29 @@ "vision" ] }, + { + "name": "google/gemma-4-12B", + "provider": "Google", + "parameter_count": "12.0B", + "parameters_raw": 12000000000, + "min_ram_gb": 24.0, + "recommended_ram_gb": 32.0, + "min_vram_gb": 24.0, + "quantization": "BF16", + "context_length": 131072, + "use_case": "General purpose, multimodal", + "is_moe": false, + "num_experts": null, + "active_experts": null, + "active_parameters": null, + "architecture": "gemma4", + "pipeline_tag": "image-text-to-text", + "release_date": "2026-04-01", + "gguf_sources": [], + "capabilities": [ + "vision" + ] + }, { "name": "google/gemma-4-31B-it", "provider": "Google", @@ -19121,4 +19144,4 @@ ], "_discovered": true } -] \ No newline at end of file +] diff --git a/services/memory/skill_extractor.py b/services/memory/skill_extractor.py index e763bca4c..79e4c67c2 100644 --- a/services/memory/skill_extractor.py +++ b/services/memory/skill_extractor.py @@ -243,6 +243,20 @@ async def maybe_extract_skill( logger.debug("[skill-extract] '%s' already exists — dropped as duplicate", title) return None + # Auto-publish gate: if the user has `auto_approve_skills` on, the + # newly-extracted skill is created `published` immediately rather + # than waiting for the next audit batch. The audit still runs later + # and can demote it back to `draft` (or delete) on failure. Default + # ON matches the UI label "Auto-approve skills". + _initial_status = "draft" + try: + from routes.prefs_routes import _load_for_user as _load_prefs + _prefs = _load_prefs(owner) or {} + if _prefs.get("auto_approve_skills", True): + _initial_status = "published" + except Exception: + pass + entry = skills_manager.add_skill( title=title, problem=data.get("problem", ""), @@ -253,6 +267,7 @@ async def maybe_extract_skill( confidence=data.get("confidence", 0.7), session_id=getattr(session, "session_id", None), owner=owner, + status=_initial_status, ) try: from src.event_bus import fire_event diff --git a/services/research/research_handler.py b/services/research/research_handler.py index bd4c6bb15..2521f61e1 100644 --- a/services/research/research_handler.py +++ b/services/research/research_handler.py @@ -285,6 +285,7 @@ class ResearchHandler: query, report, stats, elapsed, findings=researcher.findings, evolving_report=researcher.evolving_report, + analyzed_urls=getattr(researcher, "analyzed_urls", None), ) except Exception as e: @@ -331,7 +332,8 @@ class ResearchHandler: def _format_research_report( self, query: str, full_report: str, stats: dict, elapsed: float, - findings: list = None, evolving_report: str = None, + findings: Optional[list] = None, evolving_report: Optional[str] = None, + analyzed_urls: Optional[list] = None, ) -> str: """Format research report with sources list and expandable raw findings.""" summary_lines = [ @@ -342,20 +344,34 @@ class ResearchHandler: ] summary_text = " | ".join(summary_lines) - # Build sources list with clickable links + # Build sources list with clickable links. Keep the curated Sources + # section filtered for citation quality, but also list every unique URL + # the research run inspected so the "URLs Analyzed" count is auditable. sources_section = "" - if findings: + analyzed_urls_section = "" + url_items = analyzed_urls if analyzed_urls is not None else findings + if findings or url_items: seen_urls = set() source_lines = [] - for f in findings: + analyzed_seen = set() + analyzed_lines = [] + for f in findings or []: url = f.get("url", "") title = f.get("title", "") or url summary = f.get("summary", "") or f.get("evidence", "") if url and url not in seen_urls and not is_low_quality(summary): seen_urls.add(url) source_lines.append(f"- [{title}]({url})") + for item in url_items or []: + url = item.get("url", "") + title = item.get("title", "") or url + if url and url not in analyzed_seen: + analyzed_seen.add(url) + analyzed_lines.append(f"{len(analyzed_lines) + 1}. [{title}]({url})") if source_lines: sources_section = "\n### Sources\n\n" + "\n".join(source_lines) + "\n" + if analyzed_lines: + analyzed_urls_section = "\n### Analyzed URLs\n\n" + "\n".join(analyzed_lines) + "\n" # Build raw findings section (individual extractions per source) raw_findings_section = "" @@ -391,6 +407,7 @@ class ResearchHandler: {full_report} {sources_section} +{analyzed_urls_section} {collected_section} --- diff --git a/services/search/content.py b/services/search/content.py index 2c1f5f64c..ac9b4a99c 100644 --- a/services/search/content.py +++ b/services/search/content.py @@ -299,6 +299,40 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) -> _cache_result(cache_file, cache_key, result, url) return result + # Plain-text / Markdown / JSON handling. Sources like + # raw.githubusercontent.com serve Markdown as `text/plain`, JSON APIs and + # raw config files serve `application/json`, and a lot of code and tool + # docs live in `.md` / `.txt`. These have no HTML structure, so the HTML + # branch below would extract nothing and report "no readable text content". + # Return the body verbatim instead. The `is_html` guard keeps real HTML + # (including `application/xhtml+xml`) on the parsing path; the `json` check + # covers `application/json` and `+json` suffixes; the URL-suffix fallback + # catches servers that mislabel text files as `application/octet-stream`. + is_html = "html" in content_type + is_json = "json" in content_type + url_path = url.lower().split("?", 1)[0].split("#", 1)[0] + looks_like_text_file = url_path.endswith( + (".md", ".markdown", ".txt", ".text", ".json", ".jsonl") + ) + if not is_html and (content_type.startswith("text/") or is_json or looks_like_text_file): + text_body = (response.text or "").strip() + result = { + "url": url, + "title": os.path.basename(url_path) or url, + "content": text_body, + "lists": [], + "tables": [], + "code_blocks": [], + "meta_description": "", + "meta_keywords": "", + "js_rendered": False, + "js_message": "", + "success": bool(text_body), + "error": "" if text_body else "Empty response body", + } + _cache_result(cache_file, cache_key, result, url) + return result + # HTML handling try: soup = BeautifulSoup(response.text, "html.parser") diff --git a/services/search/providers.py b/services/search/providers.py index f2d4a583b..b913e1c6f 100644 --- a/services/search/providers.py +++ b/services/search/providers.py @@ -134,9 +134,10 @@ _NEWS_HINTS = ("news", "nyheter", "headlines", "breaking", "latest", "today", "i _GENERAL_ENGINES = os.environ.get("SEARXNG_GENERAL_ENGINES", "bing,mojeek,presearch") -def searxng_search_api(query: str, count: int = 10, categories: str = "general", +def searxng_search_api(query: str, count: Optional[int] = None, categories: str = "general", time_filter: Optional[str] = None) -> List[dict]: """Search using SearXNG JSON API. Returns list of {title, url, snippet}.""" + count = count if count is not None else _get_result_count() instance = _get_search_instance() api_key = "" headers = {"User-Agent": "Mozilla/5.0"} @@ -282,8 +283,9 @@ def searxng_search(query, max_results=10): # ── Brave ── -def brave_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]: +def brave_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]: """Search using Brave API with key from admin settings or env var.""" + count = count if count is not None else _get_result_count() api_key = _get_provider_key("brave") or os.environ.get("DATA_BRAVE_API_KEY") or "" return _brave_search_impl(query, count, time_filter, search_config={"brave_api_key": api_key}) @@ -381,9 +383,9 @@ def _resolve_ddg_redirect(raw: str) -> str: return resolved -def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]: +def duckduckgo_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]: """Search using DuckDuckGo via the duckduckgo-search library. No API key needed.""" - + count = count if count is not None else _get_result_count() def _html_fallback() -> List[dict]: try: response = httpx.get( @@ -415,7 +417,7 @@ def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] = return [] try: - from duckduckgo_search import DDGS + from ddgs import DDGS except ImportError: logger.warning("duckduckgo-search package not installed; using HTML fallback") return _html_fallback() @@ -452,7 +454,7 @@ def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] = # ── Google Programmable Search Engine ── -def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]: +def google_pse_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]: """Search using Google PSE (Custom Search JSON API). Requires two keys in settings: @@ -460,6 +462,7 @@ def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] = - google_pse_cx: Programmable Search Engine ID (cx) Or env vars GOOGLE_API_KEY and GOOGLE_PSE_CX. """ + count = count if count is not None else _get_result_count() settings = _get_search_settings() api_key = _get_provider_key("google_pse") or os.environ.get("GOOGLE_API_KEY", "") cx = (settings.get("google_pse_cx") or "").strip() or os.environ.get("GOOGLE_PSE_CX", "") @@ -522,8 +525,9 @@ def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] = # ── Tavily ── -def tavily_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]: +def tavily_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]: """Search using Tavily API. Requires search_api_key or TAVILY_API_KEY env var.""" + count = count if count is not None else _get_result_count() api_key = _get_provider_key("tavily") or os.environ.get("TAVILY_API_KEY", "") if not api_key: logger.warning("Tavily: no API key configured") @@ -580,8 +584,9 @@ def tavily_search(query: str, count: int = 10, time_filter: Optional[str] = None # ── Serper.dev ── -def serper_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]: +def serper_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]: """Search using Serper.dev API. Requires search_api_key or SERPER_API_KEY env var.""" + count = count if count is not None else _get_result_count() api_key = _get_provider_key("serper") or os.environ.get("SERPER_API_KEY", "") if not api_key: logger.warning("Serper: no API key configured") diff --git a/src/agent_loop.py b/src/agent_loop.py index 96a43aaa0..95dd9a59b 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -22,7 +22,7 @@ from src.settings import get_setting from src.prompt_security import untrusted_context_message from src.tool_security import blocked_tools_for_owner, plan_mode_disabled_tools from src.tool_policy import GUIDE_ONLY_DIRECTIVE, ToolPolicy -from src.tool_utils import get_mcp_manager +from src.tool_utils import _truncate, get_mcp_manager from src.agent_tools import ( parse_tool_blocks, strip_tool_blocks, @@ -194,6 +194,120 @@ _API_AGENT_RULES = """\ - After `create_session` returns id `89effa28`: "Created [New Chat](#session-89effa28) — click to switch." - Listing sessions: "1. [Big Chat](#session-abc123) — 2h ago, 2. [Code Review](#session-def456) — 5h ago\"""" +_AGENT_PREAMBLE = """\ +You are an AI assistant with tool access. Only the tools listed below are available for this turn. +To use a tool, write a fenced code block with the tool name as the language tag. The block executes automatically and you see the output.""" + +_AGENT_RULES = """\ +## Base rules +- Only use tools when needed. For casual messages like "test", "yo", "thanks", answer normally. +- If a needed tool/domain is missing from this turn, say what is missing briefly instead of pretending. +- After a tool succeeds, do not second-guess it; reply with one short confirmation unless more work remains. +- After a tool fails, retry with a concrete fix or state what is blocking you. +- Finish only when the user's concrete request is actually done, or clearly state that you are blocked. +- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts. +""" + +_API_AGENT_RULES = """\ +## Base rules +- Prefer native tool/function calling when tools are needed. +- Only call tools when they materially help answer the request. For casual messages like "test", "yo", "thanks", answer normally. +- You MUST use tools to take action; do not claim you did something without a tool result. +- If a needed tool/domain is missing from this turn, say what is missing briefly instead of pretending. +- Keep answers concise unless the user asks for depth. +- After a tool succeeds, do not second-guess it; reply with one short confirmation unless more work remains. +- After a tool fails, retry with a concrete fix or state what is blocking you. +- Finish only when the user's concrete request is actually done, or clearly state that you are blocked. +- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts. +""" + +_LINK_RULES = """\ +## Link conventions +When referencing app entities by id, use clickable markdown anchors: +- Sessions: `[Name](#session-)` +- Documents: `[Title](#document-)` +- Notes: `[Title](#note-)` +- Emails: `[Subject](#email-)` +- Calendar events: `[Summary](#event-)` +- Tasks: `[Task name](#task-)` +- Skills: `[skill-name](#skill-)` +- Research jobs: `[Topic](#research-)` +""" + +_DOMAIN_RULES = { + "web": """\ +## Web rules +- For web lookup/search/latest/current requests, use `web_search` or `web_fetch`. +- Do not use shell, Python, curl, requests, or scraping code for web lookup unless web tools are unavailable or already failed. +- "Research X" means `trigger_research`, not a one-off `web_search`, unless the user explicitly asks for a quick lookup.""", + "documents": """\ +## Document rules +- For long code/content (>15 lines), use `create_document` instead of pasting into chat. +- If an active document is open, "fix this", "add X", "change Y", etc. usually refers to that document. +- Use `edit_document` for targeted changes. Use `update_document` only for genuine full rewrites. +- For feedback/review/suggestions on an open document, use `suggest_document`.""", + "email": """\ +## Email rules +- Email UIDs are the values after `UID:` in tool output, never list row numbers. +- For latest/newest email, list with `max_results: 1`, `unread_only: false`, then read the returned UID if needed. +- For named mailboxes/accounts, call `list_email_accounts` if needed and pass the exact `account` value. +- Bulk email actions use `bulk_email` once with explicit UIDs; do not loop one message at a time. +- "Open/start a reply" means open a draft via `ui_control open_email_reply`; only `reply_to_email` when the user clearly wants to send now.""", + "cookbook": """\ +## Cookbook/model-serving rules +- Cookbook is the LLM-serving subsystem. +- "What's running/serving" starts with `list_served_models`. "What's downloading" uses `list_downloads`. +- Launch known models by checking `list_serve_presets` before raw `serve_model`. +- Downloads/serves run on a Cookbook server; pass the named `host` when the user names one. +- Do not launch model servers manually with bash/ssh/tmux. Use `serve_model`/`serve_preset` so the UI can track and stop them. +- After a successful serve, verify with `list_served_models`; if an external server is running but invisible, use `adopt_served_model`.""", + "notes_calendar_tasks": """\ +## Notes/calendar/tasks rules +- Notes/todos/reminders use `manage_notes`, not memory. +- Calendar create/update/delete should call `manage_calendar` with `action=list_calendars` first. +- Recurring/automatic/scheduled requests create a `manage_tasks` task; do not just perform the action once.""", + "ui": """\ +## UI rules +- "Open/show " uses `ui_control open_panel `. +- Tool toggles like "turn off shell/search/research" use `ui_control toggle `, not memory.""", + "sessions": """\ +## Chat/session rules +- Odysseus chats are sessions. Use `list_sessions`/`manage_session`; do not shell out looking for chat files. +- Preserve clickable session links from tool output in your final answer.""", + "files": """\ +## File rules +- Use file tools for real disk files. Use document tools only for editor documents. +- Prefer `grep`, `glob`, and `ls` over shell equivalents when available. +- Use `edit_file`/`write_file` for writes; avoid shell redirection/heredocs for editing files.""", + "settings": """\ +## Settings/API rules +- Use `manage_settings` for preferences and tool enable/disable. +- 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.""", +} + +_DOMAIN_TOOL_MAP = { + "web": {"web_search", "web_fetch", "trigger_research", "manage_research"}, + "documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"}, + "email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"}, + "cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"}, + "notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"}, + "ui": {"ui_control"}, + "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"}, + "settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"}, +} + +def _domain_rules_for_tools(tool_names: set) -> list[str]: + names = set(tool_names or set()) + rules = [] + for domain, domain_tools in _DOMAIN_TOOL_MAP.items(): + if names & domain_tools: + rules.append(_DOMAIN_RULES[domain]) + if names & {"create_session", "list_sessions", "manage_session", "manage_documents", "manage_notes", "manage_calendar", "manage_tasks", "manage_skills", "manage_research"}: + rules.append(_LINK_RULES) + return rules + # Each tool section is keyed by tool name(s) it covers. # Sections with multiple tools use a tuple key. TOOL_SECTIONS = { @@ -217,6 +331,7 @@ NEVER pipe multi-line Python through `python -c "..."` — shell quoting eats re ``` Execute Python code. Use for computation, data processing, scripting. NOT for writing code for the user (use create_document for that). Same sandbox limits as bash — no TTY, no GUI, no `input()`; for anything the user should interact with, generate a single HTML file with inline JS instead. +Prefer a dedicated tool whenever one fits the job (reading, searching, or writing files); use python only for computation/processing no dedicated tool covers - not for reading or writing files. Do NOT use Python/requests for web lookup/search/latest/current requests when `web_search` or `web_fetch` is available.""", "web_search": """\ @@ -255,6 +370,11 @@ Write content to a file. First line is the path, rest is the content.""", ``` Edit an EXISTING file by exact string replacement. PREFER this over bash (sed/echo/redirects) for changing files — it shows a before/after diff. `old_string` must match the file exactly and be unique unless `replace_all` is true. Use write_file to create a new file.""", + "get_workspace": """\ +```get_workspace +``` +Return the absolute path of the active workspace folder. File tools are CONFINED to it (paths can be RELATIVE to it); the shell starts there (cwd) but is NOT sandboxed. Call this first when the user says "the project"/"the code"/"this folder" without a path, instead of asking them. No arguments.""", + "create_document": """\ ```create_document @@ -363,7 +483,7 @@ If the user asks for a reminder/alarm before the event, pass `reminder_minutes` "send_to_session": "- ```send_to_session``` — Send a message to another session. Line 1 = session_id, rest = message. Use for orchestrating work across sessions.", "search_chats": "- ```search_chats``` — Search past session transcripts for direct conversation evidence. Use when user asks 'did we discuss X?', 'find the conversation about Y', or when prior chat context is more appropriate than persistent memory.", "pipeline": "- ```pipeline``` — Run a multi-step AI pipeline. Args (JSON) with ordered steps, each specifying a model and prompt. Use for complex workflows.", - "ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute.", + "ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Built-in theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute. For any other vibe/name, use create_theme.", "ask_user": "- ```ask_user``` — Ask the user a multiple-choice question when the task is genuinely ambiguous and the answer changes what you do next (pick an approach, confirm an assumption, choose a target). Args (JSON): {\"question\": \"...\", \"options\": [{\"label\": \"...\", \"description\": \"...\"?}, ...], \"multi\": false?}. 2-6 options. The user gets clickable buttons; calling this ENDS your turn and their choice comes back as your next message. Prefer sensible defaults — only ask when you truly can't proceed well without their input.", "update_plan": "- ```update_plan``` — While executing an approved plan, write the plan back: tick steps done or revise them. Args (JSON): {\"plan\": \"- [x] done step\\n- [ ] next step\"}. Always pass the COMPLETE checklist, not a diff. Call it after finishing each step (mark it `- [x]`) and whenever the user asks to change the plan. The user's docked plan window updates live. Does nothing if there's no active plan.", "list_served_models": "- ```list_served_models``` — Show what the Cookbook (LLM-serving subsystem) is currently running. NO args. Use this for ANY 'what's running' / 'what's serving' / 'show my cookbook' / 'is anything up' query. DO NOT shell out (`ps aux`, `docker ps`, etc.) — this tool is the source of truth. Failed serve tasks include recent logs plus diagnosis/retry suggestions; use those suggestions to call `serve_model` again with an adjusted command when appropriate.", @@ -440,6 +560,7 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool f"Available tools: {tool_list}.", _API_AGENT_RULES, ] + parts.extend(_domain_rules_for_tools(included)) return "\n\n".join(parts) parts = [_AGENT_PREAMBLE] @@ -476,6 +597,7 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool parts.append(f"(Other tools available when needed: {hint})") parts.append(_AGENT_RULES) + parts.extend(_domain_rules_for_tools(included)) return "\n\n".join(parts) @@ -596,6 +718,117 @@ def _extract_last_user_message(messages: List[Dict]) -> str: return "" +_LOW_SIGNAL_RE = re.compile(r"^[\W_]*$", re.UNICODE) +_EXPLICIT_CONTINUATION_RE = re.compile( + r"^\s*(?:" + r"yes|y|yeah|yep|ok|okay|sure|do it|go ahead|continue|carry on|" + r"run it|launch it|start it|use that|that one|same|the same|" + r"first|second|third|the first one|the second one|the third one|" + r"[123]|[abc]" + r")\s*[.!?]*\s*$", + re.IGNORECASE, +) + + +def _is_explicit_continuation(text: str) -> bool: + """Only these terse replies may inherit older user turns for tool retrieval.""" + return bool(_EXPLICIT_CONTINUATION_RE.match(str(text or "").strip())) + + +def _assistant_requested_followup(messages: List[Dict]) -> bool: + """True when the previous assistant turn asked for missing task details. + + This allows natural replies like "buy milk" after "What would you like on + your to-do list?" to inherit the prior domain, without letting random + greetings inherit stale Cookbook/email/document context. + """ + seen_latest_user = False + for msg in reversed(messages): + role = msg.get("role") + if role == "user" and not seen_latest_user: + seen_latest_user = True + continue + if not seen_latest_user: + continue + if role != "assistant": + continue + content = msg.get("content", "") + if isinstance(content, list): + content = " ".join(b.get("text", "") for b in content if isinstance(b, dict)) + text = str(content or "").lower() + if "?" not in text: + return False + return bool(re.search( + r"\b(what would you like|what should|what do you want|which one|which model|" + r"what.+(?:todo|to-do|list|document|email|model|server|item)|" + r"any specific|give me|tell me)\b", + text, + )) + return False + + +def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, object]: + """Classify only whether this turn deserves domain tool retrieval. + + Normal chat should not inherit old Cookbook/email/document context. Recent + context is used only for explicit continuations ("yes", "do it", "1"). + This function does not inject tools directly; selected tools later decide + which domain rule packs get appended to the system prompt. + """ + text = str(last_user or "").strip() + continuation = _is_explicit_continuation(text) or _assistant_requested_followup(messages) + retrieval_query = _recent_context_for_retrieval(messages) if continuation else text + q = retrieval_query.lower() + + if not text or bool(_LOW_SIGNAL_RE.match(text)): + return { + "low_signal": True, + "continuation": False, + "domains": set(), + "retrieval_query": text, + } + + domains: Set[str] = set() + + def has(*patterns: str) -> bool: + return any(re.search(p, q) for p in patterns) + + if has(r"\b(cookbook|serve|serving|served|launch|start|preset|vllm|sglang|llama\.?cpp|ollama|download|downloading|pull|cached models?|running models?|model servers?|models? (?:are )?running|what models?|model picker|gpu box|kierkegaard|odysseus|ajax|qwen|gemma|llama|mistral|minimax)\b"): + domains.add("cookbook") + if has(r"\b(emails?|mails?|gmail|inbox|reply|forward|cc|bcc|send email|compose email|draft email|message chris|message him|message her)\b"): + domains.add("email") + if has(r"\b(note|todo|to-do|checklist|task list|remind me|reminder|buy|pickup|pick up)\b"): + domains.add("notes_calendar_tasks") + if has(r"\b(every day|every morning|every evening|recurring|automatically|cron|scheduled task|background task)\b"): + domains.add("notes_calendar_tasks") + if has(r"\b(calendar|event|meeting|appointment|schedule)\b"): + domains.add("notes_calendar_tasks") + if has(r"\b(documents?|docs?|draft|compose|poem|story|essay|outline|letter|edit|rewrite|proofread|suggest|feedback|review this|make a file)\b"): + domains.add("documents") + if "notes_calendar_tasks" not in domains and has(r"\bwrite\b"): + 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"): + domains.add("web") + if has(r"\b(research|deep dive|investigate|look into)\b"): + domains.add("web") + if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"): + domains.add("ui") + if has(r"\b(session|chat history|rename chat|delete chat|archive chat|fork chat|list chats)\b"): + domains.add("sessions") + if has(r"\b(file|folder|directory|repo|git|grep|find in files|read file|edit file|shell|terminal|bash|python)\b"): + domains.add("files") + if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"): + domains.add("settings") + + low_signal = not continuation and not domains + return { + "low_signal": low_signal, + "continuation": continuation, + "domains": domains, + "retrieval_query": retrieval_query, + } + + def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_chars: int = 600) -> str: """Build the tool-retrieval query from the last few USER turns, not just the latest one. @@ -650,7 +883,7 @@ def _build_system_prompt( _ov_sig = _hl.sha256(_json.dumps(get_builtin_overrides() or {}, sort_keys=True).encode()).hexdigest() except Exception: _ov_sig = "" - cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, suppress_local_context) + cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, owner, suppress_local_context) if _cached_base_prompt and _cached_base_prompt_key == cache_key and not active_document: agent_prompt = _cached_base_prompt # Skill index is user-editable (name + description), so it must never @@ -658,7 +891,7 @@ def _build_system_prompt( # when the cache hits. _, _skill_index_block = _build_base_prompt( disabled_tools, mcp_mgr, needs_admin, relevant_tools, - mcp_disabled_map=mcp_disabled_map, compact=compact, + mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner, suppress_local_context=suppress_local_context, ) else: @@ -669,6 +902,7 @@ def _build_system_prompt( relevant_tools, mcp_disabled_map=mcp_disabled_map, compact=compact, + owner=owner, suppress_local_context=suppress_local_context, ) if not active_document: @@ -684,9 +918,20 @@ def _build_system_prompt( # Current date/time for every agent request. This is user-local when the # browser provided timezone headers, with a server-local fallback. + # + # IMPORTANT: this is intentionally NOT prepended into agent_prompt (the + # system message) anymore. Its text changes every minute, and local + # OpenAI-compatible backends (llama.cpp / LM Studio) key their KV-cache + # prefix off the system message byte-for-byte — mixing ever-changing + # timestamp text into the (already large, tool-laden) agent system prompt + # would invalidate the cached prefix on every single request, forcing a + # full prompt re-evaluation each turn (issue #2927). It's built here as a + # standalone *user*-role message and inserted near the end of the array, + # right alongside _doc_message / _skills_message, below. + _datetime_message = None try: - from src.user_time import current_datetime_prompt - agent_prompt = current_datetime_prompt() + agent_prompt + from src.user_time import current_datetime_context_message + _datetime_message = current_datetime_context_message() except Exception: pass @@ -1023,6 +1268,9 @@ def _build_system_prompt( last_user_idx += 1 # the document message is now at last_user_idx if _skills_message: merged.insert(last_user_idx, _skills_message) + last_user_idx += 1 + if _datetime_message: + merged.insert(last_user_idx, _datetime_message) return merged, mcp_schemas @@ -1041,6 +1289,7 @@ def _build_base_prompt( relevant_tools=None, mcp_disabled_map=None, compact: bool = False, + owner: Optional[str] = None, suppress_local_context: bool = False, ): """Build the agent prompt with only relevant tools included. @@ -1094,7 +1343,7 @@ def _build_base_prompt( from src.constants import DATA_DIR _sm = SkillsManager(DATA_DIR) active_tools = list(set(TOOL_SECTIONS.keys()) - set(disabled or [])) - skill_idx = _sm.index_for(owner=None, active_toolsets=active_tools) + skill_idx = _sm.index_for(owner=owner, active_toolsets=active_tools) if skill_idx: lines = ["## Available skills", "Procedures the assistant should consult before doing domain work. " @@ -1502,10 +1751,10 @@ async def stream_agent_loop( owner: Optional[str] = None, relevant_tools: Optional[Set[str]] = None, fallbacks: Optional[List[tuple]] = None, - workspace: Optional[str] = None, plan_mode: bool = False, approved_plan: Optional[str] = None, tool_policy: Optional[ToolPolicy] = None, + workspace: Optional[str] = None, _is_teacher_run: bool = False, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -1544,9 +1793,18 @@ async def stream_agent_loop( _t0 = time.time() _needs_admin = _detect_admin_intent(messages) _last_user = _extract_last_user_message(messages) - # Tool retrieval keys on recent conversation context (last few user turns), - # not just the latest message, so short follow-ups don't drop just-used tools. - _retrieval_query = _recent_context_for_retrieval(messages) or _last_user + _intent = _classify_agent_request(messages, _last_user) + # Tool retrieval uses the latest message by default. It may inherit recent + # user turns only for explicit continuations ("yes", "do it", "1"). + _retrieval_query = str(_intent.get("retrieval_query") or _last_user) + logger.info( + "[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s retrieval_query=%r", + _last_user[:120], + bool(_intent.get("continuation")), + bool(_intent.get("low_signal")), + sorted(_intent.get("domains") or []), + _retrieval_query[:200], + ) _mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {} if plan_mode and mcp_mgr: # Allow read-only MCP tools to investigate, block write/unknown ones: @@ -1563,6 +1821,20 @@ async def stream_agent_loop( _t1 = time.time() if _relevant_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")): + from src.tool_index import ALWAYS_AVAILABLE + _relevant_tools = set(ALWAYS_AVAILABLE) + if workspace: + # An active workspace IS the file-work signal: a vague "look at the + # project" means explore this folder. Surface only the READ-ONLY file + # tools (intersection with the plan-mode read-only allowlist) so the + # agent can investigate; write/shell tools stay out until the request + # actually calls for them (RAG retrieval adds those on a real ask). + from src.tool_security import 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") + else: + logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only") if not guide_only and not _relevant_tools: try: from src.tool_index import get_tool_index, ALWAYS_AVAILABLE @@ -1605,16 +1877,41 @@ async def stream_agent_loop( for keywords, tools in ToolIndex._KEYWORD_HINTS.items(): if any(kw in ql for kw in keywords): _relevant_tools.update(tools) - # Always include core document/memory tools - _relevant_tools.update({"create_document", "manage_memory", "manage_notes"}) logger.info(f"[tool-rag] Keyword fallback selected: {sorted(_relevant_tools - ALWAYS_AVAILABLE)}") + # If deterministic domain detection fired, seed the corresponding domain + # tools into the selected tool set. This is not direct prompt-pack + # injection: `_assemble_prompt()` still derives domain rules from the final + # tool names. It prevents obvious requests like "last 5 emails" from + # collapsing to only ask_user/manage_memory when vector retrieval misses or + # times out. + if not guide_only and _relevant_tools is not None: + for _domain in (_intent.get("domains") or set()): + _relevant_tools.update(_DOMAIN_TOOL_MAP.get(str(_domain), set())) + if "cookbook" in (_intent.get("domains") or set()): + _relevant_tools.update({ + "list_served_models", + "list_downloads", + "list_cached_models", + "list_cookbook_servers", + "list_serve_presets", + }) + if "email" in (_intent.get("domains") or set()): + _relevant_tools.add("ui_control") + if "web" in (_intent.get("domains") or set()): + _relevant_tools.update({"web_search", "web_fetch"}) + if "ui" in (_intent.get("domains") or set()): + _relevant_tools.add("ui_control") + # If a document is open the model needs the editing tools available # regardless of which selection path (RAG, keyword, caller-provided) ran # or what keywords were in the latest user message. if _relevant_tools is not None and active_document is not None: _relevant_tools.update({"edit_document", "update_document", "suggest_document"}) + if _relevant_tools is not None: + logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50]) + prep_timings["tool_selection"] = time.time() - _t1 _t2 = time.time() @@ -1692,27 +1989,6 @@ async def stream_agent_loop( owner=owner, suppress_local_context=guide_only, ) - if workspace and not guide_only: - # PREPEND (not append) so it dominates the large base prompt — appended - # at the end, small models ignored it and asked the user for code. The - # folder IS the project; the agent must explore it, not ask. - _ws_note = ( - f"## ACTIVE WORKSPACE — READ FIRST\n" - f"The user is working in this folder: {workspace}\n" - f"It IS the project. bash/python run with cwd set here and " - f"read_file/write_file are confined to it (paths outside are rejected).\n" - f"When the user says \"the code\" / \"this project\" / \"the workspace\" " - f"or asks to review/find/edit something WITHOUT a path, they mean THIS " - f"folder. Do NOT ask the user for code or a path, and do NOT read a file " - f"literally named \"workspace\". ALWAYS start by exploring it yourself: " - f"run `bash` → `git ls-files` (or `ls -R`) to see the files, then " - f"read_file the relevant ones by path RELATIVE to the workspace." - ) - if messages and messages[0].get("role") == "system": - messages[0]["content"] = _ws_note + "\n\n" + (messages[0].get("content") or "") - else: - messages.insert(0, {"role": "system", "content": _ws_note}) - logger.info("[workspace] active for this turn: %s", workspace) if plan_mode and not guide_only: # Steer the model to investigate-then-propose. Hard tool gating handles # every write path except shell; this directive is what keeps the @@ -1936,6 +2212,7 @@ async def stream_agent_loop( prompt_type=prompt_type if round_num == 1 else None, tools=all_tool_schemas if all_tool_schemas else None, timeout=agent_stream_timeout, + session_id=session_id, ): if time.time() > _round_deadline: logger.warning(f"[agent] round {round_num} stream exceeded wall-clock deadline; cutting off") @@ -2265,15 +2542,15 @@ async def stream_agent_loop( # every nudge — surface why the turn is ending instead of letting it # look like a clean completion. if _promise_shape and _intent_nudge_count >= _MAX_INTENT_NUDGES: - _matched_phrase = _intent_match.group(0).strip() + _matched_phrase = _redact_sensitive_text(_intent_match.group(0).strip()) _in_message = ( f"Intent-nudge cap reached on round {round_num}: the model " f"announced an action ({_matched_phrase!r}) without a tool call " f"after {_intent_nudge_count} nudge(s); ending the turn." ) logger.warning( - "[agent] intent-nudge cap exhausted on round %d (%d/%d): %r", - round_num, _intent_nudge_count, _MAX_INTENT_NUDGES, _matched_phrase, + "[agent] intent-nudge cap exhausted on round %d (%d/%d)", + round_num, _intent_nudge_count, _MAX_INTENT_NUDGES, ) yield f'data: {json.dumps({"type": "intent_nudge_exhausted", "round": round_num, "nudges": _intent_nudge_count, "max_nudges": _MAX_INTENT_NUDGES, "message": _in_message})}\n\n' break # no tools — done @@ -2473,57 +2750,9 @@ async def stream_agent_loop( result["results"] = _clean elif "stdout" in result: result["stdout"] = _clean - except (json.JSONDecodeError, Exception): + except Exception: pass - # Emit doc-specific event for document tools — the frontend - # document panel handles this; no need to show content in chat. - if is_doc_tool and "action" in result: - if result["action"] == "suggest": - yield ( - f'data: {json.dumps({"type": "doc_suggestions", "doc_id": result["doc_id"], "suggestions": result["suggestions"]})}\n\n' - ) - else: - yield ( - f'data: {json.dumps({"type": "doc_update", "doc_id": result["doc_id"], "content": result["content"], "version": result["version"], "title": result.get("title", ""), "language": result.get("language")})}\n\n' - ) - - # Emit ui_control event for frontend to apply UI changes - if "ui_event" in result: - yield ( - f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n' - ) - - # ask_user: the agent posed a multiple-choice question. Emit it so the - # frontend renders clickable options, then end the turn (below) and - # wait — the user's pick becomes the next message. - if "ask_user" in result: - # The question lives in the tool args. ChatMessage.to_dict() - # replays only role+content to the model next turn — tool_event - # metadata is dropped — so if the question is never in the saved - # assistant text, the model can't see it already asked and will - # loop and re-ask after the user answers. Stream it as assistant - # text (once) so it persists and is replayed. The card shows the - # options only, so this is the single visible copy of the question. - _auq = result["ask_user"] - _auq_q = (_auq.get("question") or "").strip() - if _auq_q and _auq_q not in full_response: - _auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q - full_response += _auq_delta - yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n' - yield ( - f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n' - ) - _awaiting_user = True - - # update_plan: agent wrote back to the plan (ticked a step / revised). - # Push it to the frontend so the stored plan + docked window update - # live. Does NOT end the turn — the agent keeps working. - if "plan_update" in result: - yield ( - f'data: {json.dumps({"type": "plan_update", "data": result["plan_update"]})}\n\n' - ) - # Build output for frontend tool bubble. # Document tools get a short summary — content goes to the editor panel. output_text = "" @@ -2541,28 +2770,30 @@ async def stream_agent_loop( # On a bash/python timeout the result carries error + (often # empty) stdout/stderr; fall back to the error so the "timed # out" reason reaches the UI instead of a blank result. - output_text = _redact_sensitive_text(result["stdout"] or result["stderr"] or result.get("error", ""))[:2000] + raw = result["stdout"] or result["stderr"] or result.get("error", "") + output_text = _truncate(_redact_sensitive_text(raw)) elif "output" in result: # bash / python canonical result: {"output": ..., "exit_code": ...} - output_text = _redact_sensitive_text(result["output"] or "")[:2000] + raw = result["output"] or "" + output_text = _truncate(_redact_sensitive_text(raw)) elif "response" in result: # AI interaction tools (chat_with_model, send_to_session) label = result.get("model", result.get("session_name", "AI")) - output_text = _redact_sensitive_text(f"{label}: {result['response']}")[:4000] + output_text = _truncate(_redact_sensitive_text(f"{label}: {result['response']}")) elif "content" in result: - output_text = _redact_sensitive_text(result["content"])[:2000] + output_text = _truncate(_redact_sensitive_text(result["content"])) elif "results" in result: - output_text = _redact_sensitive_text(result["results"])[:4000] + output_text = _truncate(_redact_sensitive_text(result["results"])) elif "session_id" in result and "name" in result: output_text = f"Session created: {result['name']} (id: {result['session_id']})" elif "success" in result: output_text = ( f"Written: {result.get('path', '')}" if result["success"] - else f"Error: {_redact_sensitive_text(result.get('error', ''))}" + else f"Error: {_truncate(_redact_sensitive_text(result.get('error', '')))}" ) elif "error" in result: - output_text = _redact_sensitive_text(result["error"])[:2000] + output_text = _truncate(_redact_sensitive_text(result["error"])) # Emit tool_output (include ui_event data if present) tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")} diff --git a/src/agent_tools.py b/src/agent_tools/__init__.py similarity index 76% rename from src/agent_tools.py rename to src/agent_tools/__init__.py index c7eea4541..52fe4a99c 100644 --- a/src/agent_tools.py +++ b/src/agent_tools/__init__.py @@ -18,6 +18,30 @@ from src.tool_utils import _truncate, get_mcp_manager, set_mcp_manager logger = logging.getLogger(__name__) +from .subprocess_tools import BashTool, PythonTool +from .web_tools import WebSearchTool, WebFetchTool +from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool +from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool + +TOOL_HANDLERS = { + "bash": BashTool().execute, + "python": PythonTool().execute, + "web_search": WebSearchTool().execute, + "web_fetch": WebFetchTool().execute, + "read_file": ReadFileTool().execute, + "write_file": WriteFileTool().execute, + "edit_file": EditFileTool().execute, + "ls": LsTool().execute, + "glob": GlobTool().execute, + "grep": GrepTool().execute, + "create_document": CreateDocumentTool().execute, + "update_document": UpdateDocumentTool().execute, + "edit_document": EditDocumentTool().execute, + "suggest_document": SuggestDocumentTool().execute, + "manage_documents": ManageDocumentTool().execute, + "get_workspace": GetWorkspaceTool().execute, +} + # --------------------------------------------------------------------------- # Constants (re-exported for backward compatibility — single source of truth # is src.constants; always prefer importing from there for new code) @@ -28,7 +52,7 @@ PYTHON_TIMEOUT = 30 # Tool types that trigger execution TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file", - "grep", "glob", "ls", + "grep", "glob", "ls", "get_workspace", "create_document", "update_document", "edit_document", "search_chats", "chat_with_model", "create_session", "list_sessions", @@ -92,15 +116,14 @@ from src.tool_execution import ( # noqa: E402, F401 format_tool_result, ) +# Document functions +from .document_tools import ( + set_active_document, + set_active_model +) + # Implementations from src.tool_implementations import ( # noqa: E402, F401 - set_active_document, - set_active_model, - get_active_document, - do_create_document, - do_update_document, - do_edit_document, - do_suggest_document, do_search_chats, do_manage_skills, do_manage_tasks, @@ -108,7 +131,6 @@ from src.tool_implementations import ( # noqa: E402, F401 do_manage_mcp, do_manage_webhooks, do_manage_tokens, - do_manage_documents, do_manage_settings, do_api_call, ) diff --git a/src/agent_tools/document_tools.py b/src/agent_tools/document_tools.py new file mode 100644 index 000000000..33b10c8d3 --- /dev/null +++ b/src/agent_tools/document_tools.py @@ -0,0 +1,644 @@ +from typing import Any, Dict, List, Optional +import logging +import re +import json +from src.constants import MAX_READ_CHARS + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Active document state +# --------------------------------------------------------------------------- + +_active_document_id: Optional[str] = None +_active_model: Optional[str] = None + + +def set_active_document(doc_id: Optional[str]): + """Set the active document ID for document tool execution.""" + global _active_document_id + _active_document_id = doc_id + + +def set_active_model(model: Optional[str]): + """Set the current model name for version summaries.""" + global _active_model + _active_model = model + + +def get_active_document(): + return _active_document_id + + +def clear_active_document(doc_id: Optional[str] = None) -> bool: + """Clear the in-memory active-document pointer. + + With ``doc_id`` given, only clears when it matches the current pointer, so a + different active document is left untouched. Returns True if it was cleared. + + Called when a document is detached from its session or deleted (its tab is + closed): without this, the stale pointer makes the last-resort doc-injection + path re-surface a closed document in a later, unrelated chat — even one whose + session no longer matches — because an unlinked doc has session_id NULL (#1160). + """ + global _active_document_id + if doc_id is None or _active_document_id == doc_id: + _active_document_id = None + return True + return False + + +def _owned_document_query(query, Document, owner: Optional[str]): + if owner is None: + # A bare Python `False` is not a valid SQL expression — SQLAlchemy 1.4 + # deprecates it and 2.0 raises ArgumentError. Use the SQL `false()` + # literal to return zero rows for an unscoped (owner-less) query. + from sqlalchemy import false + return query.filter(false()) + return query.filter(Document.owner == owner) + + +def _get_owned_document(db, Document, doc_id: str, owner: Optional[str], active_only: bool = False): + q = db.query(Document).filter(Document.id == doc_id) + if active_only: + q = q.filter(Document.is_active == True) + q = _owned_document_query(q, Document, owner) + return q.first() + + +def _most_recent_owned_document(db, Document, owner: Optional[str], active_only: bool = False): + q = db.query(Document) + if active_only: + q = q.filter(Document.is_active == True) + q = _owned_document_query(q, Document, owner) + return q.order_by(Document.updated_at.desc()).first() + + +# --------------------------------------------------------------------------- +# Document tools — create/update/edit/suggest living documents +# --------------------------------------------------------------------------- + +def _sniff_doc_language(text: str) -> str: + """Best-effort detect a document's language from its content when the model + didn't specify one. Defaults to 'markdown' (prose). Recognizes the common + markup/code types the editor supports so e.g. an SVG isn't saved as markdown.""" + import json as _json, re as _re2 + s = (text or "").strip() + if not s: + return "markdown" + head = s[:600] + hl = head.lower() + if _looks_like_email_document(s): + return "email" + # Markup (unambiguous) + if "<svg" in hl: + return "svg" + if hl.startswith("<?xml"): + return "xml" + if (hl.startswith("<!doctype html") or hl.startswith("<html") + or _re2.search(r"<(div|body|head|p|span|table|button|h[1-6]|ul|ol|li|img)\b", hl)): + return "html" + # JSON + if s[0] in "{[": + try: + _json.loads(s) + return "json" + except Exception: + pass + # Shebang + first = s.split("\n", 1)[0].strip().lower() + if first.startswith("#!"): + return "python" if "python" in first else "bash" + # Code by strong leading signals (line-anchored so prose with stray words won't match) + if _re2.search(r"(?m)^\s*(def \w|class \w|import \w|from \w[\w.]* import )", s): + return "python" + if _re2.search(r"(?m)^\s*(function \w|const \w|let \w|export |import .* from )", s): + return "javascript" + if _re2.search(r"(?mi)^\s*(select .* from |create table |insert into |update \w)", s): + return "sql" + if _re2.search(r"(?m)^[.#]?[\w-]+\s*\{[^{}]*:[^{}]*;", s): + return "css" + return "markdown" + +def _looks_like_email_document(text: str = "", title: str = "") -> bool: + import re as _re + title_l = (title or "").strip().lower() + if title_l in {"new email", "new mail", "new message"}: + return True + s = (text or "").lstrip() + if "\n---\n" in s and _re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s): + return True + return bool(_re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s)) + +def _coerce_email_document_content(existing: str, incoming: str) -> str: + """Keep email docs in the To/Subject/---/body shape even if a model writes + only the body or dumps header labels without the separator.""" + import re as _re + old = existing or "" + new = (incoming or "").strip() + if "\n---\n" in new: + return new + header = old.split("\n---\n", 1)[0] if "\n---\n" in old else "To: \nSubject: " + if _looks_like_email_document(new): + lines = new.splitlines() + last_header_idx = -1 + header_re = _re.compile(r"^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):", _re.I) + for i, line in enumerate(lines): + if header_re.match(line.strip()): + last_header_idx = i + body_lines = lines[last_header_idx + 1:] if last_header_idx >= 0 else lines + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + body = "\n".join(body_lines).strip() + else: + body = new + return header.rstrip() + "\n---\n" + body + +def _parse_tool_args(content): + """Parse a tool-call argument blob. + + Accepts either a JSON string or an already-decoded dict. Unwraps the + common `{"body": {...}}` envelope that smaller models emit when they + read tool descriptions like "Body is JSON: {...}" literally — they + pass `body` as a field name rather than treating it as a noun. + + Returns a dict on success, raises ValueError on bad JSON. + """ + if isinstance(content, str): + try: + args = json.loads(content) if content.strip() else {} + except (json.JSONDecodeError, TypeError) as e: + raise ValueError(str(e)) + elif isinstance(content, dict): + args = content + else: + args = {} + # Unwrap {"body": {...}} envelope — but only if `body` is the sole key + # and points at a dict. We don't want to clobber a legitimate `body` + # field on tools where it's a real arg (e.g. send_email body text). + if ( + isinstance(args, dict) + and len(args) == 1 + and "body" in args + and isinstance(args["body"], dict) + and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call + ): + args = args["body"] + return args + +def parse_edit_blocks(content: str) -> list: + """Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks.""" + edits = [] + pattern = r'<<<FIND>>>\n(.*?)\n<<<REPLACE>>>\n(.*?)\n<<<END>>>' + for m in re.finditer(pattern, content, re.DOTALL): + edits.append({"find": m.group(1), "replace": m.group(2)}) + return edits + +def parse_suggest_blocks(content: str) -> list: + """Parse <<<FIND>>>...<<<SUGGEST>>>...<<<REASON>>>...<<<END>>> blocks.""" + suggestions = [] + _skip_phrases = ["no change", "clear", "fine as", "looks good", "no improvement", "keep as"] + pattern = r'<<<FIND>>>\n(.*?)\n<<<SUGGEST>>>\n(.*?)\n<<<REASON>>>\n(.*?)\n<<<END>>>' + for m in re.finditer(pattern, content, re.DOTALL): + find_text = m.group(1) + replace_text = m.group(2) + reason = m.group(3).strip() + # Skip no-op suggestions where find == replace or reason says no change + if find_text.strip() == replace_text.strip(): + continue + if any(phrase in reason.lower() for phrase in _skip_phrases): + continue + suggestions.append({ + "id": f"sugg-{len(suggestions)+1}", + "find": find_text, + "replace": replace_text, + "reason": reason, + }) + return suggestions + + +class CreateDocumentTool: + async def execute(self, content: str, ctx: dict) -> dict: + """Create a new document. Supports two formats: + 1) Line-based: line 1 = title, line 2 (optional) = language, rest = content + 2) XML-like tags: <title>......... + Some models mix them — strip any XML-style tags and fall back to line parsing.""" + import uuid, re as _re + from src.database import SessionLocal, Document, DocumentVersion, Session as DbSession + + raw = content or "" + session_id = ctx.get("session_id") + owner = ctx.get("owner") + + # Known languages the editor understands (match the in HTML) - _KNOWN_LANGS = { - "python", "javascript", "typescript", "html", "css", "markdown", "json", - "yaml", "bash", "sql", "rust", "go", "java", "c", "cpp", "xml", "toml", - "ini", "ruby", "php", "csv", "email", "text", "plain", "svg", - } - - # Try XML tag extraction first - title = None - language = None - content = None - mt = _re.search(r"\s*(.*?)\s*", raw, _re.DOTALL | _re.IGNORECASE) - ml = _re.search(r"\s*(.*?)\s*", raw, _re.DOTALL | _re.IGNORECASE) - mc = _re.search(r"\s*(.*?)\s*", raw, _re.DOTALL | _re.IGNORECASE) - if mt or mc: - title = mt.group(1).strip() if mt else None - language = ml.group(1).strip().lower() if ml else None - content = mc.group(1) if mc else None - - # Fall back to line-based parsing. First strip any stray XML-ish tags. - if title is None or content is None: - cleaned = _re.sub(r"", "", raw) - lines = cleaned.strip().split("\n") - if title is None: - title = lines[0].strip() if lines else "Untitled" - lines = lines[1:] - # Only consume second line as language if it looks like a valid short lang token - if language is None and lines: - candidate = lines[0].strip().lower() - if candidate and len(candidate) < 20 and " " not in candidate and candidate in _KNOWN_LANGS: - language = candidate - lines = lines[1:] - if content is None: - content = "\n".join(lines) - - # Validate language: must be in known set, else default based on content - if language and language not in _KNOWN_LANGS: - language = None - if not language: - # No explicit language — sniff it from the content so an SVG / HTML / JSON - # / code document isn't silently saved as markdown. Prose → markdown. - language = _sniff_doc_language(content) - if _looks_like_email_document(content, title): - language = "email" - - if not title: - title = "Untitled" - - if not session_id: - return {"error": "No session context for document creation"} - - db = SessionLocal() - try: - doc_id = str(uuid.uuid4()) - ver_id = str(uuid.uuid4()) - - # Inherit ownership from the chat session so the doc survives that - # session later being deleted (session_id → NULL). - _sess = db.query(DbSession).filter(DbSession.id == session_id).first() - if owner is not None and (not _sess or _sess.owner != owner): - return {"error": "Cannot create document in another user's session"} - _owner = _sess.owner if _sess else None - - doc = Document( - id=doc_id, - session_id=session_id, - title=title, - language=language, - current_content=content, - version_count=1, - is_active=True, - owner=_owner, - ) - ver = DocumentVersion( - id=ver_id, - document_id=doc_id, - version_number=1, - content=content, - summary=f"Created by {_active_model or 'AI'}", - source="ai", - ) - db.add(doc) - db.add(ver) - db.commit() - - set_active_document(doc_id) - try: - from src.event_bus import fire_event - fire_event("document_created", _owner) - except Exception: - logger.debug("document_created event dispatch failed", exc_info=True) - - return { - "action": "create", - "doc_id": doc_id, - "title": title, - "language": language, - "content": content, - "version": 1, - } - except Exception as e: - db.rollback() - return {"error": f"Failed to create document: {e}"} - finally: - db.close() - - -async def do_update_document(content: str, doc_id: Optional[str] = None, owner: Optional[str] = None) -> Dict: - """Update an existing document. Content = full new document text.""" - import uuid - from src.database import SessionLocal, Document, DocumentVersion - - target_id = doc_id or _active_document_id - - db = SessionLocal() - try: - doc = None - if target_id: - doc = _get_owned_document(db, Document, target_id, owner) - if not doc: - doc = _most_recent_owned_document(db, Document, owner) - if doc: - target_id = doc.id - set_active_document(target_id) - logger.info(f"update_document: fell back to most recent doc id={target_id}") - if not doc: - return {"error": "No documents exist to update"} - - is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "") - new_content = _coerce_email_document_content(doc.current_content or "", content) if is_email_doc else content.strip() - if is_email_doc: - doc.language = "email" - - new_ver = doc.version_count + 1 - ver = DocumentVersion( - id=str(uuid.uuid4()), - document_id=target_id, - version_number=new_ver, - content=new_content, - summary=f"Updated by {_active_model or 'AI'}", - source="ai", - ) - doc.current_content = new_content - doc.version_count = new_ver - db.add(ver) - db.commit() - - return { - "action": "update", - "doc_id": target_id, - "title": doc.title, - "language": doc.language, - "content": new_content, - "version": new_ver, - } - except Exception as e: - db.rollback() - return {"error": f"Failed to update document: {e}"} - finally: - db.close() - - -def parse_edit_blocks(content: str) -> list: - """Parse <<>>...<<>>...<<>> blocks.""" - edits = [] - pattern = r'<<>>\n(.*?)\n<<>>\n(.*?)\n<<>>' - for m in re.finditer(pattern, content, re.DOTALL): - edits.append({"find": m.group(1), "replace": m.group(2)}) - return edits - - -async def do_edit_document(content: str, doc_id: Optional[str] = None, owner: Optional[str] = None) -> Dict: - """Apply targeted FIND/REPLACE edits to an existing document.""" - import uuid - from src.database import SessionLocal, Document, DocumentVersion - - target_id = doc_id or _active_document_id - - edits = parse_edit_blocks(content) - if not edits: - return {"error": "No valid <<>>...<<>>...<<>> blocks found"} - - db = SessionLocal() - try: - doc = None - if target_id: - doc = _get_owned_document(db, Document, target_id, owner) - if not doc: - # Fallback: most recently updated document. Avoids "no active doc" errors - # after server restart or when the agent loses track of which doc to edit. - doc = _most_recent_owned_document(db, Document, owner) - if doc: - target_id = doc.id - set_active_document(target_id) - logger.info(f"edit_document: fell back to most recent doc id={target_id} title={doc.title!r}") - if not doc: - return {"error": "No documents exist to edit"} - - updated_content = doc.current_content - applied = 0 - skipped = 0 - for edit in edits: - _find = edit["find"] - if _find in updated_content: - updated_content = updated_content.replace(_find, edit["replace"], 1) - applied += 1 - else: - # Defensive: the active-doc context shows a "N\t" line-number - # gutter for reference. Weaker models sometimes copy that prefix - # into FIND. If the exact match failed, retry with a leading - # "" stripped from each FIND line — but only use it - # when that stripped form actually matches, so we never corrupt a - # legitimately tab-prefixed document. - _stripped = "\n".join(re.sub(r"^\d+\t", "", _l) for _l in _find.split("\n")) - if _stripped != _find and _stripped in updated_content: - updated_content = updated_content.replace(_stripped, edit["replace"], 1) - applied += 1 - logger.info("edit_document: matched after stripping line-number gutter from FIND") - else: - logger.warning(f"edit_document: FIND text not found, skipping: {_find[:80]!r}") - skipped += 1 - - if applied == 0: - return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"} - - new_ver = doc.version_count + 1 - ver = DocumentVersion( - id=str(uuid.uuid4()), - document_id=target_id, - version_number=new_ver, - content=updated_content, - summary=f"Edited by {_active_model or 'AI'} ({applied} edit(s))", - source="ai", - ) - doc.current_content = updated_content - doc.version_count = new_ver - db.add(ver) - db.commit() - - return { - "action": "edit", - "doc_id": target_id, - "title": doc.title, - "language": doc.language, - "content": updated_content, - "version": new_ver, - "applied": applied, - "skipped": skipped, - } - except Exception as e: - db.rollback() - return {"error": f"Failed to edit document: {e}"} - finally: - db.close() - - -def parse_suggest_blocks(content: str) -> list: - """Parse <<>>...<<>>...<<>>...<<>> blocks.""" - suggestions = [] - _skip_phrases = ["no change", "clear", "fine as", "looks good", "no improvement", "keep as"] - pattern = r'<<>>\n(.*?)\n<<>>\n(.*?)\n<<>>\n(.*?)\n<<>>' - for m in re.finditer(pattern, content, re.DOTALL): - find_text = m.group(1) - replace_text = m.group(2) - reason = m.group(3).strip() - # Skip no-op suggestions where find == replace or reason says no change - if find_text.strip() == replace_text.strip(): - continue - if any(phrase in reason.lower() for phrase in _skip_phrases): - continue - suggestions.append({ - "id": f"sugg-{len(suggestions)+1}", - "find": find_text, - "replace": replace_text, - "reason": reason, - }) - return suggestions - - -async def do_suggest_document(content: str, doc_id: str = None, owner: Optional[str] = None) -> Dict: - """Create inline suggestions for the active document WITHOUT modifying it.""" - from src.database import SessionLocal, Document - - target_id = doc_id or _active_document_id - if not target_id: - return {"error": "No active document to suggest on"} - - suggestions = parse_suggest_blocks(content) - if not suggestions: - return {"error": "No valid <<>>...<<>>...<<>>...<<>> blocks found"} - - db = SessionLocal() - try: - doc = _get_owned_document(db, Document, target_id, owner) - if not doc: - return {"error": f"Document {target_id} not found"} - - # Validate that FIND text exists in document - valid = [] - for s in suggestions: - if s["find"] in doc.current_content: - valid.append(s) - else: - logger.warning(f"suggest_document: FIND text not found, skipping: {s['find'][:80]!r}") - - if not valid: - return {"error": "No suggestions matched the document content"} - - return { - "action": "suggest", - "doc_id": target_id, - "suggestions": valid, - "count": len(valid), - } - finally: - db.close() - - # --------------------------------------------------------------------------- # Search chats # --------------------------------------------------------------------------- @@ -664,6 +184,17 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict: proc = args.get("steps") or [] if not proc and not args.get("body_extra") and not args.get("solution"): return {"error": "procedure (or solution body) is required", "exit_code": 1} + # Same auto-publish gate as the extractor path — when the user + # has auto_approve_skills on and the caller didn't pin an explicit + # status, publish immediately. Audit later demotes/removes on fail. + _status_arg = args.get("status") + if not _status_arg: + try: + from routes.prefs_routes import _load_for_user as _load_prefs + _prefs = _load_prefs(owner) or {} + _status_arg = "published" if _prefs.get("auto_approve_skills", True) else "draft" + except Exception: + _status_arg = "draft" entry = sm.add_skill( name=args.get("name"), description=(args.get("description") or args.get("title") or "").strip(), @@ -677,7 +208,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict: procedure=proc, pitfalls=args.get("pitfalls") or [], verification=args.get("verification") or [], - status=args.get("status") or "draft", + status=_status_arg, version=args.get("version") or "1.0.0", confidence=args.get("confidence", 0.8), source=args.get("source", "learned"), @@ -1350,129 +881,6 @@ async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict: finally: db.close() - -# --------------------------------------------------------------------------- -# Document management tool (delete, list, organize) -# --------------------------------------------------------------------------- - -async def do_manage_documents(content: str, owner: Optional[str] = None) -> Dict: - """Manage documents: list, read/view/open, delete, tidy. - - Output format mirrors `manage_session`: list rows include a - clickable `[Title](#document-)` anchor + relative timestamps - so the user can click straight from chat to open the editor. - """ - from core.database import SessionLocal, Document - from datetime import datetime, timezone - - try: - args = _parse_tool_args(content) - except ValueError: - return {"error": "Invalid JSON arguments", "exit_code": 1} - - action = args.get("action", "list") - db = SessionLocal() - - def _rel(ts): - if not ts: - return 'never' - try: - now = datetime.now(timezone.utc) if ts.tzinfo is not None else datetime.utcnow() - diff = (now - ts).total_seconds() - except Exception: - return 'unknown' - if diff < 60: return 'just now' - if diff < 3600: return f'{int(diff / 60)}m ago' - if diff < 86400: return f'{int(diff / 3600)}h ago' - if diff < 86400 * 7: return f'{int(diff / 86400)}d ago' - return ts.strftime('%Y-%m-%d') - - try: - if action == "list": - q = db.query(Document).filter(Document.is_active == True) - q = _owned_document_query(q, Document, owner) - if args.get("search"): - q = q.filter(Document.title.ilike(f"%{args['search']}%")) - if args.get("language"): - q = q.filter(Document.language == args["language"]) - docs = q.order_by(Document.updated_at.desc()).limit(args.get("limit", 50)).all() - if not docs: - msg = "No documents found" + (f" matching '{args['search']}'" if args.get("search") else "") + "." - return {"response": msg, "documents": [], "exit_code": 0} - lines = [] - items = [] - for i, d in enumerate(docs): - size = len(d.current_content or "") - lang = d.language or "text" - ts = getattr(d, 'updated_at', None) or getattr(d, 'created_at', None) - marker = " ← most recent" if i == 0 else "" - lines.append( - f"- [{d.title}](#document-{d.id}) — {lang}, {size} chars, updated {_rel(ts)}{marker}" - ) - items.append({"id": d.id, "title": d.title, "language": lang, "size": size}) - header = f"Found {len(docs)} document(s), sorted most-recent first. Click a title to open:" - return { - "response": header + "\n" + "\n".join(lines), - "documents": items, - "exit_code": 0, - } - - elif action in ("read", "view", "open", "get"): - doc_id = args.get("document_id") or args.get("id") or args.get("uid") - if not doc_id: - return {"error": "Need document_id (use action=list to find one)", "exit_code": 1} - doc = _get_owned_document(db, Document, doc_id, owner, active_only=True) - if not doc: - return {"error": f"Document '{doc_id}' not found", "exit_code": 1} - body = doc.current_content or "" - preview_limit = int(args.get("limit", MAX_READ_CHARS)) - truncated = len(body) > preview_limit - preview = body[:preview_limit] + (f"\n... (truncated, {len(body)} chars total)" if truncated else "") - anchor = f"[{doc.title}](#document-{doc.id})" - return { - "response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```", - "document": { - "id": doc.id, - "title": doc.title, - "language": doc.language, - "size": len(body), - "content": preview, - "truncated": truncated, - }, - "exit_code": 0, - } - - elif action == "delete": - doc_id = args.get("document_id") or args.get("id") or args.get("uid") or _active_document_id - doc = None - if doc_id: - doc = _get_owned_document(db, Document, doc_id, owner) - if not doc: - # Fallback: most recently updated doc (likely what the user means) - doc = _most_recent_owned_document(db, Document, owner, active_only=True) - if not doc: - return {"error": "No document to delete", "exit_code": 1} - title = doc.title - doc.is_active = False - db.commit() - if _active_document_id == doc.id: - set_active_document(None) - return {"response": f"Deleted document '{title}'", "exit_code": 0} - - elif action == "tidy": - from src.document_actions import run_document_tidy - result = await run_document_tidy(owner or "") - return {"response": result, "exit_code": 0} - - else: - return {"error": f"Unknown action: {action}", "exit_code": 1} - except Exception as e: - logger.error(f"manage_documents error: {e}") - return {"error": str(e), "exit_code": 1} - finally: - db.close() - - # --------------------------------------------------------------------------- # Settings/preferences management tool # --------------------------------------------------------------------------- @@ -2045,6 +1453,42 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} + # ── Batch normalization ── + # Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]} + # instead of individual create_event calls. Iterate and create each. + if isinstance(args.get("events"), list) and not args.get("action"): + results = [] + for ev in args["events"]: + if not isinstance(ev, dict): + continue + # Normalize start/end from {dateTime: "..."} object to flat string + for field, target in [("start", "dtstart"), ("end", "dtend")]: + val = ev.pop(field, None) + if val and target not in ev: + ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val + ev.setdefault("action", "create_event") + r = await do_manage_calendar(json.dumps(ev), owner=owner) + results.append(r) + created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")] + failed = [r for r in results if r.get("error")] + + if not results: + return {"error": "No events to create", "exit_code": 1} + + # Surface both successes and failures + parts = [] + if created: + summaries = [r.get("response", "") for r in created] + parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries)) + if failed: + first_error = failed[0].get("error", "Unknown error") + parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}") + + response = "\n\n".join(parts) + # Non-zero exit code for partial or total failure + exit_code = 0 if not failed else 1 + return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)} + # Normalize action — some models emit hyphens ("list-calendars") instead # of underscores. Treat them as equivalent so we don't bounce a # cosmetic typo back to the model and waste a round-trip. Also accept @@ -2610,19 +2054,102 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]: else: env_prefix = f'eval "$(conda shell.bash hook)" && conda activate {env_path}' + from routes.cookbook_helpers import load_stored_hf_token return { "env_prefix": env_prefix, "env_type": env_kind, "env_path": env_path, "gpus": env_root.get("gpus") or "", "platform": platform, - "hf_token": env_root.get("hfToken") or "", + "hf_token": load_stored_hf_token(), "ssh_port": ssh_port, } -async def _cookbook_register_task(session_id: str, model: str, host: str, - cmd: str, task_type: str = "serve") -> bool: +def _infer_serve_port(cmd: str) -> int: + """Infer likely listen port from a serve command.""" + if not cmd: + return 8080 + m = re.search(r"--port\\s+(\\d+)", cmd) + if m: + try: + return int(m.group(1)) + except Exception: + pass + m = re.search(r"OLLAMA_HOST=[^\\s]*?:(\\d+)", cmd) + if m: + try: + return int(m.group(1)) + except Exception: + pass + if "ollama" in cmd: + return 11434 + return 8080 + + +def _infer_serve_host(host: str | None) -> tuple[str, bool]: + """Return (host, container_local) for registering a served endpoint.""" + if not (host or "").strip(): + return "localhost", True + base_host = host.split("@", 1)[-1] if "@" in host else host + return base_host, False + + +async def _ensure_served_endpoint( + *, + model: str, + cmd: str, + host: str | None, +) -> Dict[str, Any]: + """Register/fetch a model endpoint for a running serve session.""" + import httpx + endpoint_host, container_local = _infer_serve_host(host) + port = _infer_serve_port(cmd) + base_url = f"http://{endpoint_host}:{port}/v1" + short_name = model.split("/")[-1] if "/" in model else model + is_image = "diffusion_server.py" in (cmd or "") + payload = { + "name": short_name if not is_image else f"{short_name} (image)", + "base_url": base_url, + "skip_probe": "true", + "model_type": "image" if is_image else "llm", + "container_local": "true" if container_local else "false", + } + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{_INTERNAL_BASE}/api/model-endpoints", + data=payload, + headers=_internal_headers(), + ) + data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + if resp.status_code >= 400: + logger.debug( + f"ensure endpoint failed for {model!r}: status={resp.status_code} data={data}" + ) + return {"added": False, "endpoint_id": "", "base_url": base_url, "error": data} + ep_id = data.get("id") if isinstance(data, dict) else None + return { + "added": bool(ep_id), + "endpoint_id": ep_id or "", + "base_url": base_url, + "data": data, + } + except Exception as e: + logger.debug(f"ensure endpoint exception for {model!r}: {e}") + return {"added": False, "endpoint_id": "", "base_url": base_url, "error": str(e)} + + +async def _cookbook_register_task( + session_id: str, + model: str, + host: str, + cmd: str, + task_type: str = "serve", + *, + endpoint_added: bool = False, + endpoint_id: str = "", +) -> bool: """Append a task entry to cookbook_state.json after the agent launches via /api/model/serve or /api/model/download. The route spawns tmux but leaves state-writing to the UI; the agent needs to @@ -2672,7 +2199,8 @@ async def _cookbook_register_task(session_id: str, model: str, host: str, "sshPort": "", "platform": "linux", "_serveReady": False, - "_endpointAdded": False, + "_endpointAdded": bool(endpoint_added), + "_endpointId": endpoint_id or "", }) state["tasks"] = tasks try: @@ -3008,7 +2536,12 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict: if _servers.get("default_host"): host = _servers["default_host"] _host_defaulted = True + backend = (args.get("backend") or "").strip().lower() + if not backend and "/" not in repo_id and ":" in repo_id: + backend = "ollama" payload = {"repo_id": repo_id} + if backend: + payload["backend"] = backend if host: payload["remote_host"] = host if args.get("include"): @@ -3028,12 +2561,20 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict: sid = data.get("session_id", "?") registered = await _cookbook_register_task( session_id=sid, model=repo_id, host=host, - cmd=f"hf download {repo_id}", task_type="download", + cmd=(f"ollama pull {repo_id}" if backend == "ollama" else f"hf download {repo_id}"), + task_type="download", ) note = "" if registered else " (state-write failed — download may not show in UI)" where = host or "local" default_note = " (defaulted to the cookbook's selected server — pass host= or local=true to override)" if _host_defaulted else "" - return {"output": f"Download started: {repo_id} on {where} (session: {sid}){note}{default_note}", "session_id": sid, "host": host, "exit_code": 0} + return { + "output": f"Download started: {repo_id} on {where} (session: {sid}){note}{default_note}", + "session_id": sid, + "host": host, + "task_type": "download", + "phase": "running", + "exit_code": 0, + } return {"error": data.get("error", "Download failed"), "exit_code": 1} except Exception as e: return {"error": str(e), "exit_code": 1} @@ -3102,12 +2643,28 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict: data = resp.json() if data.get("ok"): sid = data.get("session_id", "?") + endpoint_id = data.get("endpoint_id") or "" + if endpoint_id: + endpoint_added = True + else: + endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host) + endpoint_added = bool(endpoint_meta.get("added")) + endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id registered = await _cookbook_register_task( session_id=sid, model=repo_id, host=host, cmd=cmd, task_type="serve", + endpoint_added=endpoint_added, endpoint_id=endpoint_id or "", ) note = "" if registered else " (state-write failed — task may not show in UI)" - return {"output": f"Serving {repo_id} (session: {sid}){note}", "session_id": sid, "exit_code": 0} + return { + "output": f"Serving {repo_id} (session: {sid}){note}", + "session_id": sid, + "task_type": "serve", + "phase": "running", + "host": host, + "endpoint_id": endpoint_id, + "exit_code": 0, + } # FastAPI HTTPException puts the message under `detail`, not `error`. # Surface BOTH so the agent sees "Invalid characters in cmd" (from # _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of @@ -3804,7 +3361,8 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict: if env_cfg.get("gpus"): payload["gpus"] = env_cfg["gpus"] if env_cfg.get("hf_token"): payload["hf_token"] = env_cfg["hf_token"] if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"] - if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"] + if env_cfg.get("ssh_port"): + payload["ssh_port"] = env_cfg["ssh_port"] try: async with httpx.AsyncClient(timeout=30) as client: @@ -3813,12 +3371,20 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict: data = resp.json() if data.get("ok"): sid = data.get("session_id", "?") + endpoint_id = data.get("endpoint_id") or "" + if endpoint_id: + endpoint_added = True + else: + endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host) + endpoint_added = bool(endpoint_meta.get("added")) + endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id registered = await _cookbook_register_task( session_id=sid, model=repo_id, host=host, cmd=cmd, task_type="serve", + endpoint_added=endpoint_added, endpoint_id=endpoint_id or "", ) note = "" if registered else " (state-write failed — task may not show in UI)" - return {"output": f"Launched preset {chosen.get('name')!r}: {repo_id} on {host or 'local'} (session: {sid}){note}", "session_id": sid, "exit_code": 0} + return {"output": f"Launched preset {chosen.get('name')!r}: {repo_id} on {host or 'local'} (session: {sid}){note}", "session_id": sid, "host": host, "endpoint_id": endpoint_id, "exit_code": 0} return {"error": data.get("error", "Serve failed"), "exit_code": 1} except Exception as e: return {"error": str(e), "exit_code": 1} diff --git a/src/tool_index.py b/src/tool_index.py index 20b7d04a2..32c7bcf41 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -28,34 +28,11 @@ except ImportError: logger = logging.getLogger(__name__) # Tools that are ALWAYS included regardless of retrieval results. -# These are the most commonly needed and should never be missing. +# Keep this deliberately tiny. Domain tools (web, documents, email, +# cookbook/model serving, files, settings, etc.) are injected by retrieval or +# keyword intent so a trivial agent prompt like "test" does not carry every +# domain's schemas and rules. ALWAYS_AVAILABLE = frozenset({ - "bash", "python", "web_search", "web_fetch", - # File tools: read AND write/edit. An agent with disk access should always - # be able to change files, not just read them — otherwise a bare "edit X" - # request can miss write_file/edit_file (RAG-only) and the model wrongly - # falls back to edit_document (editor panel). All admin-gated by tool_security. - "read_file", "write_file", "edit_file", - "grep", "glob", "ls", # code-navigation tools (admin-gated by tool_security) - "api_call", # For configured integrations (Miniflux, Gitea, Linkding, etc.) - # The two genuinely AMBIENT cookbook tools — "what's running" and - # "kill it" can be asked any time without prior cookbook context, - # and need to survive typos. The other cookbook tools (downloads, - # presets, serve, cached, servers) are CONTEXTUAL — they fire via - # keyword hints when the user is actually talking about cookbook. - # Keeping the always-on set small leaves room in the ~16-tool - # budget for manage_tasks / manage_calendar / etc. - "list_served_models", "stop_served_model", "tail_serve_output", - # Serving is a core agent capability — keep these always available so - # the router doesn't lose them on phrasings like "servic" / "fire up" / "boot". - "serve_model", "serve_preset", "list_serve_presets", - "list_cached_models", "list_cookbook_servers", - # Fallback when serve_model's allowlist rejects a cmd or when the - # model was launched out-of-band via bash+tmux — without this the - # session is invisible to the cookbook UI even though it's running. - "adopt_served_model", - # Generic API loopback — the catch-all when no named tool fits. - "app_api", # Memory is ambient — "remember this" can follow any message regardless # of topic. Without this, RAG drops it and the agent falls back to # app_api /api/memory/add which fails with 422 on first attempt. @@ -90,14 +67,15 @@ COLLECTION_NAME = "odysseus_tool_index" # Each tool gets a searchable description that helps retrieval. # These are richer than the system prompt one-liners — they're for embedding. BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { - "bash": "Run shell commands on the server. Install packages, check files, git operations, system info, and process management. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.", - "python": "Execute Python code for computation, data processing, math, scripting, and parsing. Not for writing code for the user. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.", + "bash": "Run shell commands on the server. Install packages, git operations, builds, system info, process management. Prefer a dedicated tool whenever one fits the job (file read/write/edit, search, listing); use bash only for what no dedicated tool covers. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.", + "python": "Execute Python code for computation, data processing, math, scripting, and parsing. Not for writing code for the user. Prefer a dedicated tool for reading, writing, or searching files; use python only for what no dedicated tool covers. Do not use for web lookup/search; use web_search or web_fetch when web tools are available.", "web_search": "Quick single web lookup for a fact, current event, latest/current information, or doc mid-task. Use this instead of bash/curl/python/requests for web searches. NOT for 'research X' / 'do research on X' requests — those are deep-research jobs (use trigger_research). web_search = one query; trigger_research = a full researched report in the sidebar.", "web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.", "read_file": "Read a file from disk and return its contents. View source code, config files, logs. Supports an optional line range (offset/limit) for large files.", "grep": "Search file CONTENTS for a regex across a directory tree (ripgrep-backed, honours .gitignore). Returns file:line:match. Use to find where code/symbols/strings live — prefer over bash grep.", "glob": "Find FILES by glob pattern (e.g. '**/*.py'), newest first. Use to locate files by name/extension — prefer over bash find/ls.", "ls": "List a directory's entries (folders then files with sizes). Use to see what's in a folder — prefer over bash ls.", + "get_workspace": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without giving a path, instead of asking them.", "write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.", "edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.", "create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.", @@ -355,6 +333,10 @@ class ToolIndex: r"|\bat\s+\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b", # at 7:30 am / at 7am re.I, ) + _WEB_RE = re.compile( + r"https?://|www\.|\b(?:visit|open|fetch|check|read)\s+(?:this\s+)?(?:url|link|site|website|page)\b", + re.I, + ) # Keyword hints: if the query mentions these words, force-include the tools. _KEYWORD_HINTS = { @@ -362,7 +344,7 @@ class ToolIndex: # request (e.g. "visit and tell me the title"), force-including the # whole email toolset and crowding out the relevant tools — the model then # believed it had only email tools and refused web/other tasks (#1707). - frozenset({"email", "mail", "gmail", "googlemail", "message", "send", "reply", "inbox", "unread"}): + frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}): {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"}, frozenset({"calendar", "event", "meeting", "schedule", "appointment"}): {"manage_calendar"}, @@ -426,14 +408,14 @@ class ToolIndex: # Document edit/update intent frozenset({"edit", "change", "fix", "rewrite", "update", "replace", "add a", "tweak", "modify", "rename", "paragraph", - "section", "line", "the doc", "the document", "in the doc"}): + "section", "line", "the doc", "the docs", "the document", "the documents", "in the doc", "in the docs", "in document"}): {"edit_document", "update_document", "create_document", "suggest_document"}, # Document deletion / management — include generic open/find/read/show # verbs + file/doc synonyms so "open my ", "find the ", "delete # " reach manage_documents even without the literal word "document". frozenset({"delete this doc", "delete the doc", "delete document", - "remove document", "remove the doc", "trash", "list documents", - "list docs", "all my docs", "my documents", "my docs", "my files", + "remove document", "remove the doc", "trash", "list document", "list documents", + "list doc", "list docs", "all my docs", "my document", "my documents", "my doc", "my docs", "my files", "open the", "open my", "open document", "open doc", "find the", "find my", "find document", "read the", "read my", "show me the", "show my", "the file", "my file", "the report", "the write-up", @@ -516,6 +498,11 @@ class ToolIndex: # the agent can actually create the cron job instead of fumbling. if self._SCHEDULE_RE.search(ql): base.add("manage_tasks") + # URL/site requests need web tools even when embedding retrieval is + # stubbed/unavailable. Keep this structural, not always-on, so trivial + # prompts do not drag web schemas into the agent context. + if self._WEB_RE.search(query): + base.update({"web_search", "web_fetch"}) return base diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 562b34973..5735208ec 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -25,7 +25,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "bash", - "description": "Run a shell command (full access)", + "description": "Run a shell command (full access). Prefer a dedicated tool whenever one fits the job (reading, writing, editing, searching, or listing files); use bash only for what no dedicated tool covers (installs, git, builds, running programs, system info). Do NOT create or edit files via bash redirects/heredocs/sed -- use the dedicated file tools.", "parameters": { "type": "object", "properties": { @@ -39,7 +39,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "python", - "description": "Execute Python code to compute a result or test something", + "description": "Execute Python code to compute a result or test something. Prefer a dedicated tool whenever one fits the job (reading, writing, or searching files); use python only for computation, data processing, or scripting no dedicated tool covers.", "parameters": { "type": "object", "properties": { @@ -141,6 +141,14 @@ FUNCTION_TOOL_SCHEMAS = [ } } }, + { + "type": "function", + "function": { + "name": "get_workspace", + "description": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without a path, instead of asking them. Takes no arguments.", + "parameters": {"type": "object", "properties": {}, "required": []} + } + }, { "type": "function", "function": { @@ -406,7 +414,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "ui_control", - "description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (presets: dark, light, midnight, paper, nord, monokai, gruvbox, dracula, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, vaporwave, lavender, gpt, coffee, claude), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the preset list, ALWAYS use create_theme.", + "description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.", "parameters": { "type": "object", "properties": { @@ -1246,6 +1254,8 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock content = args.get("path", "") elif tool_type in ("grep", "glob", "ls"): content = json.dumps(args) if args else "{}" + elif tool_type == "get_workspace": + content = "" elif tool_type == "write_file": content = args.get("path", "") + "\n" + args.get("content", "") elif tool_type == "edit_file": diff --git a/src/tool_security.py b/src/tool_security.py index 82d2c3d67..6d29a6ab9 100644 --- a/src/tool_security.py +++ b/src/tool_security.py @@ -20,6 +20,7 @@ NON_ADMIN_BLOCKED_TOOLS = { "grep", "glob", "ls", + "get_workspace", "search_chats", "manage_memory", "manage_skills", @@ -66,6 +67,7 @@ PLAN_MODE_READONLY_TOOLS = { "grep", "glob", "ls", + "get_workspace", "web_search", "web_fetch", "search_chats", @@ -162,13 +164,26 @@ def is_public_blocked_tool(tool_name: Optional[str]) -> bool: def owner_is_admin_or_single_user(owner: Optional[str]) -> bool: - """Return True for admins, or when auth is not configured yet.""" + """Return True for admins, or in intentional single-user mode. + + Single-user mode means the operator explicitly disabled auth + (``AUTH_ENABLED=false``) — the local/self-host default where the owner has + full access to their own box. + + The pre-setup window (auth ENABLED but no admin created yet) is treated as + NON-admin: returning True there would hand server-execution tools + (``bash``/``python``) to any caller before setup completes. The auth + middleware already 401s ``/api/`` requests pre-setup, so this is + defense-in-depth for callers that bypass it (e.g. trusted loopback). + """ try: from core.auth import AuthManager auth = AuthManager() if not auth.is_configured: - return True + from src.auth_helpers import _auth_disabled + + return _auth_disabled() return bool(owner and auth.is_admin(owner)) except Exception as exc: logger.warning("Unable to evaluate owner admin status: %s", exc) diff --git a/src/upload_handler.py b/src/upload_handler.py index 95bce306d..4c4e526bc 100644 --- a/src/upload_handler.py +++ b/src/upload_handler.py @@ -352,6 +352,86 @@ class UploadHandler: return dict(info) return None + def _renamed_upload_index_key(self, key: str, info: Dict[str, Any], old_owner: str, new_owner: str) -> str: + """Return the storage key to use after renaming an owned upload row.""" + if isinstance(key, str) and ":" in key: + owner_part, rest = key.split(":", 1) + if owner_part.strip().lower() == old_owner: + return f"{new_owner}:{rest}" + file_hash = info.get("hash") + if file_hash: + return f"{new_owner}:{file_hash}" + return key + + def _unique_upload_index_key(self, base_key: str, used_keys: set, reserved_keys: set, info: Dict[str, Any]) -> str: + """Choose a deterministic collision key without overwriting an existing row.""" + if base_key not in used_keys and base_key not in reserved_keys: + return base_key + + upload_id = str(info.get("id") or "renamed").strip() or "renamed" + candidate = f"{base_key}:{upload_id}" + if candidate not in used_keys and candidate not in reserved_keys: + return candidate + + index = 2 + while True: + candidate = f"{base_key}:{upload_id}:{index}" + if candidate not in used_keys and candidate not in reserved_keys: + return candidate + index += 1 + + def rename_owner(self, old_owner: str, new_owner: str) -> int: + """Rename upload metadata ownership from old_owner to new_owner. + + Upload rows are keyed by owner-qualified hashes for dedupe and also + carry an `owner` field for access checks. Both must move together when + usernames change. + """ + old_owner_normalized = str(old_owner or "").strip().lower() + new_owner = str(new_owner or "").strip() + if not old_owner_normalized or not new_owner: + return 0 + if old_owner_normalized == new_owner.lower(): + return 0 + + uploads_db_path = os.path.join(self.upload_dir, "uploads.json") + with self._index_lock: + current = self._load_upload_index() + if not current: + return 0 + + updated = {} + renamed = 0 + original_keys = set(current.keys()) + + for key, info in current.items(): + new_key = key + new_info = info + if isinstance(info, dict) and str(info.get("owner", "")).strip().lower() == old_owner_normalized: + new_info = dict(info) + new_info["owner"] = new_owner + base_key = self._renamed_upload_index_key(key, new_info, old_owner_normalized, new_owner) + new_key = self._unique_upload_index_key( + base_key, + set(updated.keys()), + original_keys - {key}, + new_info, + ) + if new_key != base_key: + logger.warning( + "Upload owner rename key collision for %s -> %s at %s; preserving row as %s", + old_owner_normalized, + new_owner, + base_key, + new_key, + ) + renamed += 1 + updated[new_key] = new_info + + if renamed: + self._atomic_write_json(uploads_db_path, updated) + return renamed + def _find_upload_path(self, upload_id: str) -> Optional[str]: """Find an upload file by ID while staying inside upload_dir.""" if not self.validate_upload_id(upload_id): diff --git a/src/user_time.py b/src/user_time.py index 44519c0fb..d3dee5eb7 100644 --- a/src/user_time.py +++ b/src/user_time.py @@ -9,7 +9,7 @@ from __future__ import annotations import re from contextvars import ContextVar from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Dict, Optional _USER_TZ_OFFSET_MIN: ContextVar[Optional[int]] = ContextVar("user_tz_offset_min", default=None) @@ -136,3 +136,26 @@ def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str: "When scheduling a task with manage_tasks, scheduled_time is in UTC: " "convert the user's stated local time using the UTC offset above.\n\n" ) + + +def current_datetime_context_message(now_utc: Optional[datetime] = None) -> Dict[str, str]: + """Build the current-date/time context as a standalone chat message. + + This intentionally returns a ``user``-role message rather than a + ``system``-role one. The text changes every turn (it embeds the current + clock time down to the minute), and local OpenAI-compatible backends + (llama.cpp / LM Studio) key their KV-cache prefix off the system message + byte-for-byte — folding ever-changing timestamp text into the system + message would invalidate the cached prefix on every single request (see + issue #2927). Keeping it as a separate message placed near the end of the + array (right before the latest user turn) lets the static system prompt + stay byte-identical across turns while the model still gets fresh + date/time grounding for relative-date reasoning. + """ + return { + "role": "user", + "content": ( + "[Context — current date/time, refreshed each turn; not part of " + "your instructions]\n" + current_datetime_prompt(now_utc) + ), + } diff --git a/src/webhook_manager.py b/src/webhook_manager.py index 267ceaa38..af28fe2a7 100644 --- a/src/webhook_manager.py +++ b/src/webhook_manager.py @@ -202,6 +202,18 @@ class WebhookManager: self._client = httpx.AsyncClient(timeout=10, follow_redirects=False) self._loop: Optional[asyncio.AbstractEventLoop] = None self._api_key_manager = api_key_manager + # Strong references to in-flight fire-and-forget tasks. asyncio only + # keeps weak references to tasks, so without this the GC can collect a + # delivery task mid-flight and the webhook is silently never sent. + self._bg_tasks: set = set() + + def _spawn_tracked(self, coro): + """Schedule a background task and hold a strong reference until it + finishes, so it can't be garbage-collected before delivery completes.""" + task = asyncio.ensure_future(coro) + self._bg_tasks.add(task) + task.add_done_callback(self._bg_tasks.discard) + return task def set_loop(self, loop: asyncio.AbstractEventLoop): self._loop = loop @@ -223,8 +235,8 @@ class WebhookManager: if event not in ALLOWED_EVENTS: return try: - loop = asyncio.get_running_loop() - loop.create_task(self.fire(event, payload)) + asyncio.get_running_loop() + self._spawn_tracked(self.fire(event, payload)) except RuntimeError: # Called from a sync thread (e.g. sync FastAPI route in threadpool) if self._loop and self._loop.is_running(): @@ -243,7 +255,7 @@ class WebhookManager: for wh in matching: decrypted_secret = self._decrypt_secret(wh.secret) - asyncio.create_task(self._deliver(wh.id, wh.url, decrypted_secret, event, payload)) + self._spawn_tracked(self._deliver(wh.id, wh.url, decrypted_secret, event, payload)) async def deliver_test(self, webhook_id: str, url: str, encrypted_secret: Optional[str]): """Public method for the test-webhook route.""" diff --git a/start-macos.sh b/start-macos.sh index b9f06f2bf..f324625c6 100755 --- a/start-macos.sh +++ b/start-macos.sh @@ -182,6 +182,35 @@ else echo "▶ Non-ARM macOS detected; skipping Apfel server bootstrap." fi +# ChromaDB backs the tool index and vector RAG. chromadb ships in the venv, so +# start a local server before launching. Skip when one is already reachable, or +# when CHROMADB_HOST points at a remote host. +CHROMA_PID="" +CHROMA_HOST="${CHROMADB_HOST:-localhost}" # what the app connects to +CHROMA_PORT="${CHROMADB_PORT:-8100}" +# Bind + probe on IPv4 loopback: the app's "localhost" resolves to 127.0.0.1, +# but binding chroma to the literal "localhost" can land on IPv6 ::1, which the +# app can't then reach. Pin both to 127.0.0.1. +CHROMA_BIN="$(dirname "$VENV_PY")/chroma" +case "$CHROMA_HOST" in + localhost|127.0.0.1) CHROMA_BIND="127.0.0.1" ;; + 0.0.0.0) CHROMA_BIND="0.0.0.0" ;; + *) CHROMA_BIND="" ;; # remote host - don't start locally +esac +if (exec 3<>"/dev/tcp/127.0.0.1/$CHROMA_PORT") 2>/dev/null; then + echo "▶ ChromaDB already running on 127.0.0.1:$CHROMA_PORT - using it." +elif [ -z "$CHROMA_BIND" ]; then + echo "▶ CHROMADB_HOST=$CHROMA_HOST is remote - not starting a local ChromaDB." +elif [ -x "$CHROMA_BIN" ]; then + CHROMA_LOG="${TMPDIR:-/tmp}/odysseus-chromadb.log" + echo "▶ Starting ChromaDB in the background on $CHROMA_BIND:$CHROMA_PORT…" + echo " logging to $CHROMA_LOG" + nohup "$CHROMA_BIN" run --host "$CHROMA_BIND" --port "$CHROMA_PORT" --path "$PWD/data/chroma" >"$CHROMA_LOG" 2>&1 & + CHROMA_PID=$! +else + echo "▶ ChromaDB CLI not found in venv; skipping (tool index will be degraded)." +fi + # 5. Launch. Bind to loopback by default; opt into LAN/Tailscale with # ODYSSEUS_HOST=0.0.0.0. URL_HOST="$HOST" @@ -224,7 +253,7 @@ fi # Setup is done — drop the setup-failure handler, and clean up the background # opener when the server exits or the user presses Ctrl+C. trap - ERR -trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null; [ -n "$APFEL_PID" ] && kill "$APFEL_PID" 2>/dev/null' EXIT INT TERM +trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null; [ -n "$APFEL_PID" ] && kill "$APFEL_PID" 2>/dev/null; [ -n "$CHROMA_PID" ] && kill "$CHROMA_PID" 2>/dev/null' EXIT INT TERM echo echo "▶ Starting Odysseus — it will open in your browser at $URL" diff --git a/static/app.js b/static/app.js index 8216d6485..ed8b6e49a 100644 --- a/static/app.js +++ b/static/app.js @@ -1160,7 +1160,7 @@ function initializeEventListeners() { if (!p.can_use_bash) { const bashToggle = document.getElementById('bash-toggle'); if (bashToggle) bashToggle.closest('.chat-input-toggle')?.style.setProperty('display', 'none'); - const bashBtn = document.getElementById('tool-bash-btn'); + const bashBtn = document.getElementById('bash-toggle-btn'); if (bashBtn) bashBtn.style.display = 'none'; } // Hide document button @@ -1177,11 +1177,7 @@ function initializeEventListeners() { const resOverflow = document.getElementById('overflow-research-btn'); if (resOverflow) resOverflow.style.display = 'none'; } - // Hide image generation options - if (!p.can_generate_images) { - const imgBtn = document.getElementById('tool-image-btn'); - if (imgBtn) imgBtn.style.display = 'none'; - } + } }) .catch(() => {}); @@ -1555,7 +1551,6 @@ function initializeEventListeners() { const MODE_TOOLS = [ { btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' }, { btnId: 'bash-toggle-btn', checkboxId: 'bash-toggle', stateKey: 'bash' }, - { btnId: 'plan-toggle-btn', checkboxId: 'plan-toggle', stateKey: 'plan' }, ]; function _modeKey(stateKey, mode) { return `${stateKey}_${mode}`; } @@ -1564,9 +1559,6 @@ function initializeEventListeners() { const state = loadToggleState(); const key = _modeKey(stateKey, mode); if (Object.prototype.hasOwnProperty.call(state, key)) return !!state[key]; - // Plan mode is opt-in: never default it on, otherwise every agent turn - // would be forced into planning. - if (stateKey === 'plan') return false; return mode === 'agent'; // default: ON in agent, OFF in chat } @@ -1579,7 +1571,6 @@ function initializeEventListeners() { const TOOL_TOGGLE_TOAST_LABELS = { web: 'Web search', bash: 'Shell', - plan: 'Plan mode', }; function showToolToggleToast(stateKey, active) { @@ -1592,8 +1583,8 @@ function initializeEventListeners() { MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => { const btn = el(btnId); if (!btn) return; - // Hide bash and plan buttons in chat mode - if (mode === 'chat' && (stateKey === 'bash' || stateKey === 'plan')) { + // Hide bash button in chat mode + if (mode === 'chat' && stateKey === 'bash') { btn.style.display = 'none'; return; } @@ -1614,12 +1605,10 @@ function initializeEventListeners() { const state = loadToggleState(); let currentMode = state.mode || 'chat'; - // Immediately hide bash/plan buttons in chat mode on page load + // Immediately hide bash button in chat mode on page load if (currentMode === 'chat') { const bashBtn = el('bash-toggle-btn'); - const planBtn = el('plan-toggle-btn'); if (bashBtn) bashBtn.style.display = 'none'; - if (planBtn) planBtn.style.display = 'none'; } function setMode(mode) { @@ -1634,6 +1623,8 @@ function initializeEventListeners() { // Slide the pill to the active button const toggle = agentBtn.closest('.mode-toggle'); if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat'); + // Workspace pill + overflow entry are agent-only - hide immediately (no flash). + try { workspaceModule.applyMode(mode); } catch (_) {} // Delay tool glow-up for a staggered effect setTimeout(() => applyModeToToggles(mode), 500); } @@ -1709,81 +1700,6 @@ function initializeEventListeners() { } setupToggle('web-toggle-btn', 'web-toggle', 'web'); setupToggle('bash-toggle-btn', 'bash-toggle', 'bash'); - try { workspaceModule.initWorkspace(); } catch (_) {} - setupToggle('plan-toggle-btn', 'plan-toggle', 'plan'); - - // Set plan mode on/off directly (checkbox + button state + saved pref) WITHOUT - // going through the button's click handler — used by the plan menu and by the - // "Approve & Run" flow. Going through .click() would hit the plan-menu - // intercept below (a stored plan re-opens the menu instead of toggling), which - // is exactly the bug that left approved plans stuck in plan mode. - function _setPlanMode(on) { - const btn = el('plan-toggle-btn'); - const chk = el('plan-toggle'); - const mode = (loadToggleState().mode) || 'chat'; - if (chk) chk.checked = !!on; - if (btn) { btn.classList.toggle('active', !!on); btn.setAttribute('aria-pressed', String(!!on)); } - saveToolPref('plan', mode, !!on); - } - window._setPlanMode = _setPlanMode; - - // ── Plan-button menu ── - // When a plan exists for this chat, clicking the plan button opens a small - // menu (Show plan / Plan mode on-off) instead of plain-toggling — so the plan - // window can be re-opened and docked at any time while the agent works. With - // no plan, the button behaves as before (one-click toggle). - (function initPlanMenu() { - const planBtn = el('plan-toggle-btn'); - if (!planBtn) return; - const _hasPlan = () => { try { return !!(window._getStoredPlan && window._getStoredPlan()); } catch (_) { return false; } }; - const _close = () => { const m = document.getElementById('plan-menu'); if (m) m.remove(); }; - function _open() { - _close(); - const planChk = el('plan-toggle'); - const on = !!(planChk && planChk.checked); - const menu = document.createElement('div'); - menu.id = 'plan-menu'; - menu.className = 'overflow-menu plan-menu'; - menu.innerHTML = - '' - + ''; - document.body.appendChild(menu); - const r = planBtn.getBoundingClientRect(); - menu.style.position = 'fixed'; - menu.style.left = Math.round(r.left) + 'px'; - menu.style.top = Math.round(r.top - menu.offsetHeight - 6) + 'px'; - menu.querySelector('[data-act="show"]').addEventListener('click', () => { - _close(); - const txt = window._getStoredPlan ? window._getStoredPlan() : ''; - if (txt && window.planWindowModule) window.planWindowModule.openPlanWindow(txt, null); - }); - menu.querySelector('[data-act="toggle"]').addEventListener('click', () => { - _close(); - _setPlanMode(!on); // flip state directly (no click → no menu re-open) - }); - // Dismiss on any outside click (capture so it beats other handlers) / Escape. - setTimeout(() => { - const off = (e) => { - if (!menu.contains(e.target) && e.target !== planBtn) { - _close(); document.removeEventListener('click', off, true); document.removeEventListener('keydown', esc, true); - } - }; - const esc = (e) => { if (e.key === 'Escape') { _close(); document.removeEventListener('click', off, true); document.removeEventListener('keydown', esc, true); } }; - document.addEventListener('click', off, true); - document.addEventListener('keydown', esc, true); - }, 0); - } - planBtn.addEventListener('click', (e) => { - // With a stored plan, the button opens the menu (Show plan / toggle). - // Without one, it falls through to the normal one-click toggle. - if (_hasPlan()) { e.preventDefault(); e.stopImmediatePropagation(); _open(); } - }, true); // capture phase: intercept before setupToggle's bubble handler - })(); - try { workspaceModule.initWorkspace(); } catch (_) {} // Document editor toggle (special: uses module panel, not a checkbox) diff --git a/static/index.html b/static/index.html index 522129fe9..b717cd3e6 100644 --- a/static/index.html +++ b/static/index.html @@ -1079,17 +1079,11 @@ - - - + - - - -
@@ -2122,20 +2108,35 @@ + -
- + + +
- + - - + + + + - +
@@ -2143,7 +2144,15 @@
-

Added Models (Endpoints)

+

Added Models (Endpoints) + + + +

Manage the endpoints you've added.
@@ -2174,10 +2183,45 @@
+
+

API Tokens

+
Bearer tokens for external integrations (scripts, Codex, headless agent runs). Token value shown ONCE on create — copy it then.
+
+
+ + + +
+
+ +