diff --git a/.gitattributes b/.gitattributes index 8681aee12..e2ae3a6e6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,10 @@ entrypoint.sh text eol=lf docker/entrypoint.sh text eol=lf +# Maintained frontend modules and this lane's byte-identical deployment output. +frontend/src/** text eol=lf +static/js/actionMenuOrder.js text eol=lf + # Windows-native scripts stay CRLF. *.ps1 text eol=crlf *.cmd text eol=crlf @@ -32,3 +36,7 @@ static/lib/** -whitespace linguist-vendored *.ico binary *.woff binary *.woff2 binary + +# Notes reference deployment modules. +static/js/features/notes/** text eol=lf +static/js/shared/ui/confetti.js text eol=lf diff --git a/.github/scripts/validate-searxng-image.sh b/.github/scripts/validate-searxng-image.sh new file mode 100644 index 000000000..3edd5ba16 --- /dev/null +++ b/.github/scripts/validate-searxng-image.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Exercise an already-built image through the normal Compose startup path. +set -euo pipefail + +searxng_repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +searxng_image=${1:?usage: validate-searxng-image.sh IMAGE} +[[ "$searxng_image" =~ ^[a-zA-Z0-9_./:@+-]+$ ]] || { + echo "Invalid image reference" >&2 + exit 2 +} +searxng_checks=$(mktemp -d "${TMPDIR:-/tmp}/searxng-ci-XXXXXXXX") +searxng_project=$(basename "$searxng_checks" | tr '[:upper:]' '[:lower:]') +export SEARXNG_VALIDATION_IMAGE="$searxng_image" +export SEARXNG_VALIDATION_CHECKS="$searxng_checks" +export SEARXNG_VALIDATION_VOLUME="${searxng_project}-settings" +# Never forward an operator's existing secret or load their .env for a CI probe. +export SEARXNG_SECRET=searxng-ci-validation-only +searxng_volume_created=0 +searxng_started=0 +searxng_compose=(docker compose --env-file /dev/null --project-name "$searxng_project" + -f "$searxng_repo/docker-compose.yml" + -f "$searxng_repo/.github/searxng-validation.compose.yml") + +searxng_cleanup() { + local searxng_exit=$? + if [ "$searxng_started" = 1 ]; then + "${searxng_compose[@]}" down --remove-orphans >/dev/null 2>&1 || true + fi + if [ "$searxng_volume_created" = 1 ]; then + docker volume rm "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1 || true + fi + rm -rf -- "$searxng_checks" + return "$searxng_exit" +} +trap searxng_cleanup EXIT + +install -m 0644 "$searxng_repo/scripts/check_ca_trust.py" \ + "$searxng_repo/docker/searxng/validate_image.py" "$searxng_checks/" +install -m 0644 "$searxng_repo/tests/fixtures/ca-trust/incident.json" "$searxng_checks/incident.json" +install -m 0644 "$searxng_repo/config/searxng/settings.yml" "$searxng_checks/settings.yml" +install -m 0644 "$searxng_repo/scripts/migrate_searxng_settings.py" "$searxng_checks/migrate_searxng_settings.py" +if docker volume inspect "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1; then + echo "Refusing to reuse an existing validation volume" >&2 + exit 1 +fi +docker volume create --label "odysseus.searxng-validation=$searxng_project" \ + "$SEARXNG_VALIDATION_VOLUME" >/dev/null +searxng_volume_created=1 +searxng_started=1 +if ! "${searxng_compose[@]}" up --detach --no-deps --no-build --pull never \ + --wait --wait-timeout 150 searxng; then + "${searxng_compose[@]}" logs --no-color searxng >&2 || true + exit 1 +fi +"${searxng_compose[@]}" exec -T searxng /usr/local/searxng/.venv/bin/python \ + /opt/odysseus-checks/validate_image.py --running --live diff --git a/.github/searxng-validation.compose.yml b/.github/searxng-validation.compose.yml new file mode 100644 index 000000000..d1e1c575c --- /dev/null +++ b/.github/searxng-validation.compose.yml @@ -0,0 +1,21 @@ +# CI-only isolation; retain the service's entrypoint, healthcheck and capabilities. +services: + searxng: + image: ${SEARXNG_VALIDATION_IMAGE:?set the image being validated} + ports: !reset [] + volumes: !override + - searxng-validation-data:/etc/searxng + - ${SEARXNG_VALIDATION_CHECKS:?}/settings.yml:/tmp/searxng-settings.yml.template:ro + - ${SEARXNG_VALIDATION_CHECKS:?}/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro + - ${SEARXNG_VALIDATION_CHECKS:?}:/opt/odysseus-checks:ro + networks: !override + - default + restart: "no" + +networks: + default: {} + +volumes: + searxng-validation-data: + external: true + name: ${SEARXNG_VALIDATION_VOLUME:?} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51e12d341..229a4d599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,8 @@ jobs: python-version: "3.11" # Byte-compile sources — catches syntax errors without installing deps. - run: python -m compileall -q app.py core routes src services scripts tests + - name: Check committed frontend deployment modules + run: python scripts/build_frontend.py --check node-syntax: name: JS syntax (node --check) @@ -91,14 +93,20 @@ jobs: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "20" + node-version: "22.23.4" + - run: npm ci --ignore-scripts --no-audit --no-fund + - name: Check frontend architecture + run: node scripts/check_frontend.mjs # Syntax-check our own JS (skip vendored libs in static/lib). - name: node --check run: | shopt -s globstar nullglob - for f in static/app.js static/js/**/*.js; do + for f in static/app.js static/js/**/*.js frontend/src/**/*.js; do node --check "$f" done + node --check scripts/check_frontend.mjs + - name: Test frontend architecture gates and stateless proof + run: node --test tests/frontendArchitecture.test.mjs python-tests: name: Python tests (pytest ${{ matrix.shard }}) @@ -147,20 +155,35 @@ jobs: if: steps.docs-check.outputs.docs_only != 'true' with: python-version: "3.11" + # Establish system trust before dependency installers or their hooks run. + # Keep one apt pass for FFmpeg and bubblewrap; containment is checked later. + - name: Install FFmpeg and bubblewrap and validate system CA trust + if: steps.docs-check.outputs.docs_only != 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates + python scripts/check_ca_trust.py --system + command -v ffmpeg + ffmpeg -version | head -n 1 + # uv resolves the same versions as pip but installs ~5x faster, and its # cache keeps each shard from re-downloading the whole requirement set. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 if: steps.docs-check.outputs.docs_only != 'true' with: + version: "0.13.5" enable-cache: true cache-dependency-glob: requirements.txt prune-cache: false - - run: uv pip install --system -r requirements.txt + - run: | + uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt + python scripts/check_ca_trust.py --python + uv pip install --system -r requirements.txt if: steps.docs-check.outputs.docs_only != 'true' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: steps.docs-check.outputs.docs_only != 'true' with: - node-version: "20" + node-version: "22.23.4" cache: npm - run: npm ci if: steps.docs-check.outputs.docs_only != 'true' @@ -176,15 +199,9 @@ jobs: if: steps.docs-check.outputs.docs_only != 'true' - run: mkdir -p data # sqlite DB lives at ./data/app.db if: steps.docs-check.outputs.docs_only != 'true' - # One apt pass for FFmpeg (media integration tests) and bubblewrap - # (containment, verified in the next step). - - name: Install FFmpeg and bubblewrap + - name: Validate Python and system CA trust if: steps.docs-check.outputs.docs_only != 'true' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap - command -v ffmpeg - ffmpeg -version | head -n 1 + run: python scripts/check_ca_trust.py --system --python - name: Establish functional bubblewrap containment if: steps.docs-check.outputs.docs_only != 'true' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0e4f9a1f1..e78bba158 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -70,5 +70,9 @@ jobs: - name: Run pip-audit on requirements run: | set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends ca-certificates + python scripts/check_ca_trust.py --system + python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt pip install pip-audit==2.10.0 pip-audit -r requirements.txt -r requirements-optional.txt --strict diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index da41c7140..6865d5120 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -29,7 +29,6 @@ permissions: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: build: @@ -52,6 +51,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Normalize image name + shell: bash + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Set up Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to GHCR @@ -65,6 +67,7 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . + pull: true platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.arch }} @@ -93,6 +96,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Normalize image name + shell: bash + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Read APP_VERSION + short sha id: ver run: | diff --git a/.github/workflows/searxng-image.yml b/.github/workflows/searxng-image.yml new file mode 100644 index 000000000..844617b10 --- /dev/null +++ b/.github/workflows/searxng-image.yml @@ -0,0 +1,79 @@ +name: ci / SearXNG trust + +on: + pull_request: + paths: + - 'docker/searxng/**' + - 'scripts/check_ca_trust.py' + - 'tests/fixtures/ca-trust/**' + - 'tests/test_searxng*' + - 'docker-compose*.yml' + - 'config/searxng/**' + - 'scripts/migrate_searxng_settings.py' + - '.github/scripts/*searxng*' + - '.github/searxng-validation.compose.yml' + - '.github/workflows/searxng-image.yml' + push: + branches: [dev, main] + paths: + - 'docker/searxng/**' + - 'scripts/check_ca_trust.py' + - 'tests/fixtures/ca-trust/**' + - 'docker-compose*.yml' + - 'config/searxng/**' + - 'scripts/migrate_searxng_settings.py' + - '.github/scripts/*searxng*' + - '.github/searxng-validation.compose.yml' + - '.github/workflows/searxng-image.yml' + workflow_dispatch: + +concurrency: + group: searxng-image-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate: + name: SearXNG validate (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Select the immutable upstream image from Compose + id: image + run: | + docker compose --env-file /dev/null -f docker-compose.yml config --format json | + python3 -c 'import json, re, sys; image = json.load(sys.stdin)["services"]["searxng"]["image"]; re.fullmatch(r"docker\.io/searxng/searxng:[0-9]{4}\.[^@\s]+@sha256:[0-9a-f]{64}", image) or sys.exit("Invalid SearXNG pin"); print("image=" + image)' >> "$GITHUB_OUTPUT" + - name: Pull the upstream image without registry credentials + env: + SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }} + run: docker pull "$SEARXNG_TEST_IMAGE" + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - name: Check unmodified upstream trust and actual entrypoint offline + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: docker/searxng/Dockerfile + target: validation + build-args: SEARXNG_IMAGE=${{ steps.image.outputs.image }} + network: none + platforms: ${{ matrix.platform }} + no-cache-filters: validation + - name: Validate the selected upstream runtime through Compose and live HTTPS + env: + SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }} + run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE" diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index b00cd03a4..18a197eb0 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -76,5 +76,9 @@ jobs: - name: Run zizmor run: | set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends ca-certificates + python scripts/check_ca_trust.py --system + python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt pip install zizmor==1.25.2 zizmor --offline --min-severity=low .github/workflows/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 3a51ae4c1..0eec3f1ae 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -21,3 +21,17 @@ regexes = [ '''^inherits_2\.0\.1$''', '''^inherits_2\.0\.3$''', ] + + +[[allowlists]] +description = "Reviewed Skills OpenAPI baseline SHA-256 digest" +targetRules = ["generic-api-key"] +condition = "AND" +paths = [ + '''(?:^|/)tests/fixtures/skills_api_baseline\.json$''', + '''(?:^|/)docs/skills-audit-architecture\.md$''', +] +regexTarget = "secret" +regexes = [ + '''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''', +] diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 38e184764..294289b7f 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -48,8 +48,8 @@ just composed. | Service | Image | Purpose | License | |---|---|---|---| | [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:2026.9.25-12f8b6515` (pinned tag; see compose) | Default metasearch backend | AGPL-3.0 | -| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:latest` | Vector store for memory / RAG | Apache-2.0 | -| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy` | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 | +| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:1.5.9` (pinned digest; see compose) | Vector store for memory / RAG | Apache-2.0 | +| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy:v2.32.0` (pinned digest; see compose) | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 | ## Bundled front-end libraries diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38586845f..8a7160245 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thanks for helping. The project is moving quickly, so the best contributions are Odysseus has two branches: - **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely. -- **`main`** — what users run. Curated and tested by the maintainer. Fast-forwarded to a stable `dev` commit at each release. +- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release. **Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed. @@ -62,6 +62,10 @@ docker compose logs --tail=120 odysseus Mention what you ran in the pull request description. If you could not run a check, say so. +## Maintainer governance and review guidance + +For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template. + ## Pull Requests Good pull requests usually include: diff --git a/Dockerfile b/Dockerfile index d6e7ca48f..936f35b90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,18 @@ # which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so # the final image / Cookbook never has to compile the broken sdists. See # docker/build-realesrgan-wheels.sh for the full rationale. -FROM python:3.14-slim AS realesrgan-wheels -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +FROM python:3.14.9-slim AS realesrgan-wheels +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* +COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py +RUN python /usr/local/bin/check-ca-trust.py --system +COPY requirements-bootstrap.txt /tmp/requirements-bootstrap.txt +RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --no-deps -r /tmp/requirements-bootstrap.txt \ + && python /usr/local/bin/check-ca-trust.py --python COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels -FROM python:3.14-slim +FROM python:3.14.9-slim # System deps. tmux is required by Cookbook for background downloads/serves. # openssh-client is required for Cookbook remote server tests, setup, probes, @@ -28,6 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ cmake \ curl \ + ca-certificates \ git \ nodejs \ npm \ @@ -48,6 +54,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libmagic1 \ && rm -rf /var/lib/apt/lists/* +# Refresh both Python trust bundles before installing application dependencies. +# System roots also serve web fetches, webhooks, curl, and Node's OpenSSL mode. +COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py +RUN python /usr/local/bin/check-ca-trust.py --system +COPY requirements-bootstrap.txt /tmp/requirements-bootstrap.txt +RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --no-deps -r /tmp/requirements-bootstrap.txt \ + && python /usr/local/bin/check-ca-trust.py --python +ENV NODE_OPTIONS=--use-openssl-ca \ + GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt + +# Chromium has a separate root store. Distrust the compromised intermediate +# public keys explicitly, including cross-signs, without waiting for CRL updates. +COPY docker/chromium-emudhra.json /etc/chromium/policies/managed/odysseus-emudhra.json + # Private browser automation wrapper used by the native `private_browser` tool. # Chromium is installed above, so agent-browser can drive the existing browser # binary without paying `npx` startup/install overhead on each tool call. @@ -72,7 +92,8 @@ RUN npm install -g agent-browser@0.35.0 --omit=dev --loglevel=error # /var/run/docker.sock mount). The Debian `docker.io` package ships # dockerd but not the client binary on slim, so grab the static client # tarball from download.docker.com instead. -ARG DOCKER_CLI_VERSION=29.6.2 +# 29.9.2 includes Go 1.26.10's eMudhra intermediate-key distrust. +ARG DOCKER_CLI_VERSION=29.9.2 RUN ARCH="$(dpkg --print-architecture)" \ && case "$ARCH" in \ amd64) DARCH=x86_64 ;; \ @@ -108,6 +129,11 @@ COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ && rm -rf /tmp/odysseus-wheels +# Check gRPC's effective defaults after all Python dependencies are installed. +# The application minimum fixes its bundled roots; the checked override also +# keeps the effective Docker selection consistent with system trust. +RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc + # Copy app code COPY . . # Require the redistribution notices in the image build context. diff --git a/README.md b/README.md index 4ec557b11..06d4b5f1e 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,33 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled Deployment details are in the [setup guide](website/setup.md#security-notes). -## Star History +## Community & Growth - - - - - Star History Chart - - +

+ + odysseus-dev%2Fodysseus | Trendshift + +

+ +

+ + + + + Odysseus GitHub star history + + +

## License diff --git a/app.py b/app.py index 5215966fb..b0e6f9d55 100644 --- a/app.py +++ b/app.py @@ -516,28 +516,69 @@ app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static") @app.get("/api/generated-image/{filename}") async def serve_generated_image(filename: str, request: Request): """Serve generated images from the data directory.""" - img_path = resolve_generated_image_path(filename) - # SECURITY: filename is the only key, so anyone who knows / guesses a - # 12-hex content hash could pull another user's image bytes. Require - # auth and verify ownership via the gallery row (when one exists). - try: - from src.auth_helpers import get_current_user - from core.database import SessionLocal as _SL, GalleryImage as _GI - _user = get_current_user(request) - if _user: - _db = _SL() + # 1. Validate filename syntax upfront, rejecting malformed requests (400) + # without probing the filesystem or database. + from src.generated_images import GENERATED_IMAGE_RE + if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + + # 2. Ownership verification: + # If auth is enabled, verify caller identity and ownership BEFORE probing disk, + # preventing existence oracles during outages or unauthorized requests. + from src.auth_helpers import auth_disabled, effective_user + if not auth_disabled(): + _user = effective_user(request) + if not _user: + raise HTTPException(status_code=401, detail="Not authenticated") + + from src.generated_images import get_registered_image_owner + _reg_owner = get_registered_image_owner(filename) + if _reg_owner is not None: + if _reg_owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + else: try: - _row = _db.query(_GI).filter(_GI.filename == filename).first() - # Generated-but-not-yet-imported images have no row → allow. - # Row exists with a different owner → 404 (don't confirm existence). - if _row is not None and _row.owner and _row.owner != _user: - raise HTTPException(status_code=404, detail="Image not found") - finally: - _db.close() - except HTTPException: - raise - except Exception as _e: - logger.warning("Image ownership verification failed for %r", filename, exc_info=_e) + from core.database import ( + SessionLocal as _SL, + GalleryImage as _GI, + Session as _DbSession, + ChatMessage as _CM, + ) + _db = _SL() + try: + _row = _db.query(_GI).filter(_GI.filename == filename).first() + if _row is not None: + if _row.owner: + if _row.owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + elif _row.session_id: + _sess = _db.query(_DbSession).filter(_DbSession.id == _row.session_id).first() + if not _sess or not _sess.owner or _sess.owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + else: + # Gallery record has no owner or session link -> fail closed + raise HTTPException(status_code=404, detail="Image not found") + else: + # Rowless image: check if referenced in a chat session owned by this user + _chat_ref = ( + _db.query(_CM.id) + .join(_DbSession, _CM.session_id == _DbSession.id) + .filter(_DbSession.owner == _user, _CM.meta_data.like(f"%{filename}%")) + .first() + ) + if not _chat_ref: + raise HTTPException(status_code=404, detail="Image not found") + finally: + _db.close() + except HTTPException: + raise + except Exception as _e: + logger.warning("Image ownership verification failed for %r", filename, exc_info=_e) + raise HTTPException(status_code=503, detail="Ownership verification unavailable") + + # 3. Resolve confined path and verify existence on disk + img_path = resolve_generated_image_path(filename) + ext = filename.rsplit('.', 1)[-1].lower() mime = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", @@ -545,10 +586,6 @@ async def serve_generated_image(filename: str, request: Request): "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "mkv": "video/x-matroska", "m4v": "video/mp4", }.get(ext, "application/octet-stream") - # Generated-image filenames are content hashes → the bytes for a given - # filename never change. Cache them hard so the gallery doesn't - # re-download every full-size image each time it's opened. `immutable` - # tells the browser it never needs to revalidate within the max-age. return FileResponse( str(img_path), media_type=mime, @@ -718,7 +755,8 @@ app.include_router(setup_research_routes(research_handler, session_manager=sessi # History from routes.history.history_routes import setup_history_routes -app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler)) +history_router = setup_history_routes(session_manager, upload_handler=upload_handler) +app.include_router(history_router) # Search from routes.search.search_routes import setup_search_routes @@ -878,6 +916,7 @@ app.include_router(setup_codex_routes( memory_router=memory_router, calendar_router=calendar_router, document_router=document_router, + history_router=history_router, )) app.include_router(setup_claude_routes()) diff --git a/build-windows-portable.ps1 b/build-windows-portable.ps1 index 7da912f73..634ae8068 100644 --- a/build-windows-portable.ps1 +++ b/build-windows-portable.ps1 @@ -47,7 +47,8 @@ if (-not $pyExe) { Write-Host ("Using Python: " + $pyExe) Write-Step "Installing build dependencies" -& $pyExe -m pip install --upgrade pip --quiet +& $pyExe -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt +if ($LASTEXITCODE -ne 0) { Fail "TLS build dependency bootstrap failed." } & $pyExe -m pip install -r requirements.txt pyinstaller pystray Pillow if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed." } diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 8e6702b43..5c85014d6 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -127,11 +127,15 @@ services: - ${RENDER_GID:-render} chromadb: - image: docker.io/chromadb/chroma:latest + image: docker.io/chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 ports: - "${CHROMADB_BIND:-127.0.0.1}:8100:8000" volumes: - - chromadb-data:/chroma/chroma + # The image runs `chroma run /config.yaml`, whose persist_path is "/data". + # It declares no VOLUME, so anything written outside a mount lives in the + # container's writable layer and dies with it. /chroma/chroma is where + # pre-1.0 images kept the store; mounting there now catches nothing. + - chromadb-data:/data environment: - ANONYMIZED_TELEMETRY=FALSE restart: unless-stopped @@ -141,8 +145,8 @@ services: # (depends_on: condition: service_healthy), so a broken upstream `latest` # tag blocks the whole app from starting. 2026.6.2 crashes on boot with # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414). - # Bump this deliberately after verifying a newer tag boots clean. - image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 + # Update all default/GPU pins together after native startup and trust checks. + image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8 entrypoint: - /bin/sh - -c @@ -192,7 +196,7 @@ services: restart: unless-stopped ntfy: - image: docker.io/binwiederhier/ntfy + image: docker.io/binwiederhier/ntfy:v2.32.0@sha256:59250e1d8be2ee2186c4560322f625faef22ace380331f05b63f8842dbd62602 command: serve ports: - "${NTFY_BIND:-127.0.0.1}:8091:80" diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index eb3c9d7e3..5c628fcfb 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -130,11 +130,15 @@ services: capabilities: [gpu] chromadb: - image: docker.io/chromadb/chroma:latest + image: docker.io/chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 ports: - "${CHROMADB_BIND:-127.0.0.1}:8100:8000" volumes: - - chromadb-data:/chroma/chroma + # The image runs `chroma run /config.yaml`, whose persist_path is "/data". + # It declares no VOLUME, so anything written outside a mount lives in the + # container's writable layer and dies with it. /chroma/chroma is where + # pre-1.0 images kept the store; mounting there now catches nothing. + - chromadb-data:/data environment: - ANONYMIZED_TELEMETRY=FALSE restart: unless-stopped @@ -144,8 +148,8 @@ services: # (depends_on: condition: service_healthy), so a broken upstream `latest` # tag blocks the whole app from starting. 2026.6.2 crashes on boot with # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414). - # Bump this deliberately after verifying a newer tag boots clean. - image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 + # Update all default/GPU pins together after native startup and trust checks. + image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8 entrypoint: - /bin/sh - -c @@ -195,7 +199,7 @@ services: restart: unless-stopped ntfy: - image: docker.io/binwiederhier/ntfy + image: docker.io/binwiederhier/ntfy:v2.32.0@sha256:59250e1d8be2ee2186c4560322f625faef22ace380331f05b63f8842dbd62602 command: serve ports: - "${NTFY_BIND:-127.0.0.1}:8091:80" diff --git a/docker-compose.yml b/docker-compose.yml index 4fb49a45b..2b90227a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,11 +108,15 @@ services: restart: unless-stopped chromadb: - image: docker.io/chromadb/chroma:latest + image: docker.io/chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 ports: - "${CHROMADB_BIND:-127.0.0.1}:8100:8000" volumes: - - chromadb-data:/chroma/chroma + # The image runs `chroma run /config.yaml`, whose persist_path is "/data". + # It declares no VOLUME, so anything written outside a mount lives in the + # container's writable layer and dies with it. /chroma/chroma is where + # pre-1.0 images kept the store; mounting there now catches nothing. + - chromadb-data:/data environment: - ANONYMIZED_TELEMETRY=FALSE restart: unless-stopped @@ -122,8 +126,8 @@ services: # (depends_on: condition: service_healthy), so a broken upstream `latest` # tag blocks the whole app from starting. 2026.6.2 crashes on boot with # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414). - # Bump this deliberately after verifying a newer tag boots clean. - image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 + # Update all default/GPU pins together after native startup and trust checks. + image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8 entrypoint: - /bin/sh - -c @@ -173,7 +177,7 @@ services: restart: unless-stopped ntfy: - image: docker.io/binwiederhier/ntfy + image: docker.io/binwiederhier/ntfy:v2.32.0@sha256:59250e1d8be2ee2186c4560322f625faef22ace380331f05b63f8842dbd62602 command: serve ports: - "${NTFY_BIND:-127.0.0.1}:8091:80" diff --git a/docker/chromium-ca-policy.md b/docker/chromium-ca-policy.md new file mode 100644 index 000000000..b4852ac3a --- /dev/null +++ b/docker/chromium-ca-policy.md @@ -0,0 +1,20 @@ +# Chromium incident distrust policy + +`chromium-emudhra.json` is installed in the Docker image at `/etc/chromium/policies/managed/odysseus-emudhra.json`. Chromium 132 and later support `CADistrustedCertificates`: a list of base64-encoded DER X.509 certificates whose public keys must not be accepted for TLS server authentication. Matching is by SubjectPublicKeyInfo (SPKI), so one certificate per compromised key also blocks its cross-signs. This policy adds no trust, disables no certificate checks, and does not require a revocation-list download. Native browsers and other browser distributions require separately managed installation of the appropriate policy. + +Policy semantics: [Chromium's version-155 definition](https://github.com/chromium/chromium/blob/155.0.8059.79/components/policy/resources/templates/policy_definitions/CertificateManagement/CADistrustedCertificates.yaml). Linux installation paths: [Chromium policy documentation](https://www.chromium.org/administrators/configuring-policy-for-extensions/#linux). + +Public certificate source, retrieved 2026-10-21: [incident intermediate inventory](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). Its downloaded JSON has SHA-256 `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`. Each selected certificate's DER fingerprint and public-key fingerprint were checked against that inventory before inclusion. The eight unique keys below are covered, including the code-signing intermediate keys listed in the incident. The policy contains only public certificates; no private key is included. + +| Intermediate | SHA-256 of DER SPKI | +| --- | --- | +| EM OV TLS CA - G3B | `43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b` | +| EM DV TLS CA - G3C | `49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f` | +| EM EV TLS CA - G3C | `5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0` | +| EM OV TLS CA - G3C | `63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8` | +| EM OV CS CA - G3C | `92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30` | +| EM EV TLS CA - G3B | `e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41` | +| EM DV TLS CA - G3B | `ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105` | +| EM OV CS CA - G3B | `ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6` | + +`tests/test_chromium_ca_policy.py` verifies that the policy distrusts exactly these eight keys using valid intermediate CA certificates and adds no trust settings. Changes to the encoded material need a fresh source/fingerprint check and behavioral validation: all twelve published incident HTTPS chains must be rejected by the actual Chromium binary while ordinary HTTPS still succeeds. Confirm loading and any policy errors at `chrome://policy`. Do not treat a passing PEM-store check as a browser-policy check. diff --git a/docker/chromium-emudhra.json b/docker/chromium-emudhra.json new file mode 100644 index 000000000..104f82f60 --- /dev/null +++ b/docker/chromium-emudhra.json @@ -0,0 +1,12 @@ +{ + "CADistrustedCertificates": [ + "MIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAwMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEEAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe8Dz3bQjauvy9uw==", + "MIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1MDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxgyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNdAEKwaJ0mVzScy2+6", + "MIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUzOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11ZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49AgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+bSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3VmxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9i1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAqMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysGAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/fAal1GNxsOutLnr0Y", + "MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44cNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR794rSb9vGqxZzelQth", + "MIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0MFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEGBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9O8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3QbX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUNq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggrBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgGBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBgeuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=", + "MIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYwMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDAlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6MN/P+ByGeiRMghzB", + "MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/JhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPOIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8gt2TlfJbHii+KNvK", + "MIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgyN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2rzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsGAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYGZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03YEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9HTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=" + ] +} diff --git a/docker/searxng/Dockerfile b/docker/searxng/Dockerfile new file mode 100644 index 000000000..0b487e66c --- /dev/null +++ b/docker/searxng/Dockerfile @@ -0,0 +1,9 @@ +# Validation only. Deploy the unmodified upstream image selected by Compose. +ARG SEARXNG_IMAGE +FROM ${SEARXNG_IMAGE} AS validation +COPY scripts/check_ca_trust.py docker/searxng/validate_image.py /opt/odysseus-trust/ +COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json +RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py + +FROM validation AS live-validation +RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live diff --git a/docker/searxng/Dockerfile.dockerignore b/docker/searxng/Dockerfile.dockerignore new file mode 100644 index 000000000..3e7e75e3f --- /dev/null +++ b/docker/searxng/Dockerfile.dockerignore @@ -0,0 +1,10 @@ +** +!scripts/ +!scripts/check_ca_trust.py +!docker/ +!docker/searxng/ +!docker/searxng/** +!tests/ +!tests/fixtures/ +!tests/fixtures/ca-trust/ +!tests/fixtures/ca-trust/incident.json diff --git a/docker/searxng/validate_image.py b/docker/searxng/validate_image.py new file mode 100644 index 000000000..c768cf010 --- /dev/null +++ b/docker/searxng/validate_image.py @@ -0,0 +1,162 @@ +"""Verify incident distrust and startup in the unmodified upstream image.""" + +import hashlib +import asyncio +import json +import os +from pathlib import Path +import re +import signal +import ssl +import subprocess +import sys +import tempfile +import time +import urllib.request + +import certifi + +from check_ca_trust import check_bundle + + +def check_chains(): + incident = json.loads(Path(__file__).with_name("incident.json").read_text()) + system = Path(os.environ["SSL_CERT_FILE"]) + capath = os.environ["SSL_CERT_DIR"] + stores = [["-CAfile", str(system), "-no-CApath"], + ["-CAfile", certifi.where(), "-no-CApath"], + ["-no-CAfile", "-CApath", capath]] + check_bundle(system) + check_bundle(Path(certifi.where())) + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + for fingerprint, pem in incident["certificates"].items(): + if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint: + raise ValueError(f"incident fixture fingerprint mismatch: {fingerprint}") + (directory / fingerprint).write_text(pem) + positive = directory / "affected.pem" + positive.write_text("\n".join(incident["certificates"][fp] for fp in incident["roots"])) + for hostname, chain in incident["chains"].items(): + untrusted = directory / "untrusted.pem" + untrusted.write_text("\n".join(incident["certificates"][fp] for fp in chain[1:])) + base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]), + "-purpose", "sslserver", "-verify_hostname", hostname, + "-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)] + expectations = [(selection, False) for selection in stores] + for selection, expected in expectations + [ + (["-CAfile", str(positive), "-no-CApath"], True)]: + result = subprocess.run(base + selection + [str(directory / chain[0])], + capture_output=True, text=True) + if (result.returncode == 0) != expected: + raise RuntimeError(f"unexpected chain result: {hostname}: {selection}: {result.stderr}") + # Accept issuer lookup, self-signed and explicitly untrusted + # certificate errors, rather than tool or fixture failures. + if not expected and not re.search(r"error (?:2|18|19|20|21|27) at \d+ depth lookup", result.stderr): + raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}") + print(f"{len(incident['chains'])}/{len(incident['chains'])} incident chains rejected by system PEM, hashed directory and certifi; affected controls accepted") + + +def check_boot(live=False): + with tempfile.TemporaryDirectory() as directory: + settings = Path(directory) / "settings.yml" + settings.write_text("use_default_settings: true\nserver:\n secret_key: offline-build-validation-only\n limiter: false\nsearch:\n formats: [html, json]\n") + cache = Path(directory) / "cache" + cache.mkdir() + env = dict(os.environ, SEARXNG_SETTINGS_PATH=str(settings), + __SEARXNG_CONFIG_PATH=directory, __SEARXNG_DATA_PATH=str(cache), + GRANIAN_HOST="127.0.0.1", GRANIAN_PORT="18888") + # Do not expose a port or issue an outbound search during offline builds. + with tempfile.TemporaryFile() as log: + process = subprocess.Popen(["/usr/local/searxng/entrypoint.sh"], + cwd="/usr/local/searxng", env=env, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + healthy = False + for _ in range(60): + if process.poll() is not None: + break + try: + with opener.open("http://127.0.0.1:18888/healthz", timeout=1) as response: + healthy = response.status == 200 + except OSError: + pass + if healthy: + break + time.sleep(0.5) + if not healthy: + log.seek(0) + raise RuntimeError("SearXNG failed boot smoke: " + log.read().decode(errors="replace")[-6000:]) + print("SearXNG loopback healthcheck passed") + # Its root entrypoint regenerates system trust. Repeat the + # behavioral checks after that real startup path completes. + check_running(live=live, port=18888) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + + +def check_running(live=False, port=None): + """Verify an existing service without repairing it or starting another one.""" + port = int(os.environ.get("GRANIAN_PORT", "8080")) if port is None else port + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(f"http://127.0.0.1:{port}/healthz", timeout=5) as response: + if response.status != 200: + raise RuntimeError("SearXNG running-service healthcheck failed") + check_chains() + for path in Path(os.environ["SSL_CERT_DIR"]).iterdir(): + if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name): + check_bundle(path) + if live: + asyncio.run(check_https()) + with opener.open(f"http://127.0.0.1:{port}/search?q=python+documentation&format=json", timeout=90) as search: + payload = json.load(search) + results = payload.get("results", []) + if not results: + raise RuntimeError("live search returned no results") + print(f"SearXNG live JSON search passed: {len(results)} results") + + +async def check_https(): + # Exercise the actual pinned SearXNG client factory with its default CA + # selection, rather than a different HTTP library's trust configuration. + sys.path.insert(0, "/usr/local/searxng") + from curl_cffi.requests.exceptions import RequestException + from searx.network.client import new_client + + client = new_client(enable_http=False, verify=True, enable_http2=True, + enable_http3=False, max_connections=2, proxies={}, + local_address=None, max_redirects=3) + try: + response = await client.get("https://pypi.org/simple/certifi/", timeout=30) + if response.status_code != 200: + raise RuntimeError(f"ordinary HTTPS failed: {response.status_code}") + print("SearXNG actual curl client ordinary HTTPS passed with verification enabled") + incident = json.loads(Path(__file__).with_name("incident.json").read_text()) + for hostname in incident["chains"]: + try: + await client.get(f"https://{hostname}/", timeout=30, allow_redirects=False) + except RequestException as exc: + # libcurl code 60 is peer certificate verification failure. + # DNS failures, timeouts and handshake EOF do not prove distrust. + if exc.code != 60: + raise RuntimeError(f"inconclusive incident TLS failure: {hostname}: {exc}") from exc + else: + raise RuntimeError(f"incident HTTPS unexpectedly accepted: {hostname}") + print(f"SearXNG actual curl client rejects {len(incident['chains'])}/{len(incident['chains'])} incident hosts with certificate errors after startup") + finally: + await client.aclose() + + +if __name__ == "__main__": + live = "--live" in sys.argv + if "--running" in sys.argv: + check_running(live=live) + else: + check_chains() + check_boot(live=live) diff --git a/docs/chromadb-volume-migration.md b/docs/chromadb-volume-migration.md new file mode 100644 index 000000000..4ec7853ac --- /dev/null +++ b/docs/chromadb-volume-migration.md @@ -0,0 +1,408 @@ +# ChromaDB Volume Migration — Upgrading from pre-#6166 installs + +> [!IMPORTANT] +> This guide is only for Docker-based installs that were deployed before PR #6166 +> was merged. Native host installs are unaffected (they persist to `data/chroma/` +> on the host filesystem, which was always stored correctly). + +--- + +## 1. What changed and why + +Before PR #6166, the `chromadb-data` named volume was mounted at `/chroma/chroma` inside +the ChromaDB container. However, current `chromadb/chroma` images (version 1.0+) write their +database to `/data` — configured by the container image's internal `/config.yaml` (`persist_path: "/data"`). +Because the upstream ChromaDB image declares no `VOLUME` directive: + +* **Any vector data written to `/data` accumulated exclusively in the container's writable layer.** +* The mounted `chromadb-data` named volume at `/chroma/chroma` remained completely empty. +* Any recreation or removal of the container (`docker compose down`, `docker compose up --force-recreate`, `docker rm`) + permanently destroys that writable layer and all vector embeddings within it. + +PR #6166 corrects the mount target across all Compose configurations to `chromadb-data:/data`. + +Fresh installations will persist data directly to the named volume automatically. However, for +**existing running installations**, the vector store currently resides in the old container's +writable layer. + +This guide provides a zero-data-loss, fail-closed operational procedure to quiesce, preserve, +back up, verify, seed, and validate that data before performing cutover. + +--- + +## 2. Architecture and Safety Principles + +1. **No Live `docker cp`:** ChromaDB uses SQLite (`chroma.sqlite3`) and binary HNSW segment index + files (`data_level0.bin`, `header.bin`, `length.bin`, `link_lists.bin`). Taking a file copy while + the database engine is running risks capturing torn SQLite pages or out-of-sync HNSW graph segments. + Writers must be quiesced and ChromaDB gracefully stopped before copying files. +2. **Do Not Trust `docker rename` Alone:** Docker Compose tracks containers by internal metadata labels + (`com.docker.compose.project`, `com.docker.compose.service`, `com.docker.compose.container-number`), + **not** merely by container name. Renaming a container does not strip its labels; Compose commands + can still discover, restart, or recreate it. To guarantee preservation of the writable layer, the + stopped container must be committed to an immutable Docker image (`docker commit`). +3. **Never Overwrite Existing Volumes:** Named volumes must be positively verified as empty prior to seeding. +4. **Staging Validation Before Production Cutover:** The seeded named volume must be independently validated + using a disposable ChromaDB instance on an isolated port before touching the production Compose deployment. +5. **No Rollback to `/chroma/chroma`:** Rolling back to pre-#6166 Compose files reintroduces the broken + mount path where ChromaDB never looks. Rollback procedures must restore data into the corrected `/data` path + or redeploy from the immutable backup image. + +--- + +## 3. Step-by-Step Migration Procedure + +### Step 0: Identify your project and volume names + +Docker Compose prefixes container and volume names with the project name (usually the root directory name). +Identify yours before proceeding: + +```bash +# Identify running ChromaDB container +docker ps --filter "ancestor=chromadb/chroma" --format "table {{.ID}}\t{{.Status}}\t{{.Names}}" + +# Identify the named volume +docker volume ls --filter "name=chromadb" +``` + +Throughout this guide: +* `` represents your Compose project (e.g. `odysseus` or `odysseus-maintainer-preview`). +* `` represents the running ChromaDB container (e.g. `-chromadb-1`). +* `` represents the named volume (e.g. `_chromadb-data`). + +Set shell variables for convenience: +```bash +PROJECT="" +CONTAINER="${PROJECT}-chromadb-1" +VOLUME="${PROJECT}_chromadb-data" +``` + +--- + +### Step 1: Pre-Migration Collection Inventory (Baseline) + +Establish a baseline of all existing collections and their embedding counts through the live ChromaDB API: + +```bash +# List all collections and record counts +curl -s http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections \ + | python3 -c ' +import json, sys, urllib.request +data = json.load(sys.stdin) +print(f"Total collections found: {len(data)}") +for col in data: + c_id = col["id"] + name = col["name"] + try: + with urllib.request.urlopen(f"http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp: + count = resp.read().decode().strip() + except Exception as e: + count = f"error: {e}" + print(f"Collection: {name} (ID: {c_id}) -> Count: {count}") +' | tee pre_migration_inventory.txt +``` + +Save `pre_migration_inventory.txt` securely. This baseline is used in Step 7 and Step 9 to verify +zero data loss. + +--- + +### Step 2: Quiesce Writers and Gracefully Stop ChromaDB + +To ensure transactional and referential consistency across SQLite and HNSW segment files: + +1. Stop application writers (Odysseus) to prevent any in-flight requests: + ```bash + docker compose stop odysseus + ``` +2. Gracefully stop the ChromaDB service. This permits SQLite to checkpoint the Write-Ahead Log (WAL) + and flushes all in-memory vector index segments to disk: + ```bash + docker compose stop chromadb + ``` +3. Confirm that the container status is `Exited`: + ```bash + docker ps -a --filter "name=${CONTAINER}" --format "table {{.ID}}\t{{.Status}}\t{{.Names}}" + ``` + *Do not proceed if the container is still running.* + +--- + +### Step 3: Preserve the Original Container as an Immutable Image + +Because Docker Compose discovers containers via service labels, running `docker compose up` later +could recreate and destroy the original container's writable layer. + +Preserve the complete writable layer into an immutable Docker image: +```bash +BACKUP_IMAGE="${PROJECT}-chromadb-backup:pre-migration-$(date +%Y%m%d-%H%M%S)" +docker commit "${CONTAINER}" "${BACKUP_IMAGE}" +echo "Committed container writable layer to: ${BACKUP_IMAGE}" +``` + +Verify the image was created: +```bash +docker images "${BACKUP_IMAGE}" +``` + +This image is your ultimate insurance: even if all containers and volumes are deleted, your +data remains safely archived in Docker's image store. + +--- + +### Step 4: Take a Cold File Backup + +With the container stopped, copy the `/data` directory out to a dedicated host backup directory: + +```bash +BACKUP_DIR="./chromadb-backup-$(date +%Y%m%d-%H%M%S)" +mkdir -p "${BACKUP_DIR}" + +docker cp "${CONTAINER}:/data/." "${BACKUP_DIR}/" +echo "Cold backup created at: ${BACKUP_DIR}" +``` + +--- + +### Step 5: Verify Backup Integrity + +Never proceed to destructive cutover without verifying both SQLite and vector index structures. + +1. **Verify SQLite Database File:** + ```bash + test -s "${BACKUP_DIR}/chroma.sqlite3" || { echo "ERROR: chroma.sqlite3 is missing or 0 bytes"; exit 1; } + ``` +2. **Execute SQLite PRAGMA Integrity Check:** + ```bash + INTEGRITY=$(sqlite3 "${BACKUP_DIR}/chroma.sqlite3" "PRAGMA integrity_check;") + if [ "${INTEGRITY}" != "ok" ]; then + echo "ERROR: SQLite integrity check failed: ${INTEGRITY}" + exit 1 + fi + echo "SQLite integrity check: OK" + ``` +3. **Verify Collections Table Structure:** + ```bash + sqlite3 "${BACKUP_DIR}/chroma.sqlite3" "SELECT id, name FROM collections;" + ``` +4. **Verify HNSW Index Binary Files:** + Vector representations are not stored inside SQLite; they are kept in segment directories under `/data`: + ```bash + find "${BACKUP_DIR}" -type f -name "*.bin" + ``` + Ensure `.bin` files (`data_level0.bin`, `header.bin`, etc.) exist for indexed collections. + +--- + +### Step 6: Verify Destination Volume is Empty and Seed Safely + +The named volume `${VOLUME}` must be empty before seeding. Under no circumstances should +migration files be merged into a populated volume. + +1. **Positive Empty Check:** + ```bash + VOL_FILES=$(docker run --rm -v "${VOLUME}:/vol:ro" alpine ls -A /vol) + if [ -n "${VOL_FILES}" ]; then + echo "ERROR: Named volume ${VOLUME} already contains files: ${VOL_FILES}" + echo "Aborting migration to prevent overwriting existing volume data." + exit 1 + fi + echo "Volume ${VOLUME} is empty and ready for seeding." + ``` +2. **Seed the Volume:** + Use a disposable Alpine container to populate the volume with exact ownership and permissions: + ```bash + docker run --rm \ + -v "${VOLUME}:/target" \ + -v "$(realpath "${BACKUP_DIR}"):/source:ro" \ + alpine sh -c "cp -a /source/. /target/ && ls -la /target/" + ``` +3. **Verify Seeded Contents:** + ```bash + docker run --rm -v "${VOLUME}:/vol:ro" alpine ls -la /vol + ``` + +--- + +### Step 7: Staging Validation in an Isolated Disposable Container + +Before modifying your production Compose stack, validate that ChromaDB boots successfully from the +seeded volume and serves all collections. + +1. **Start Disposable Staging Container:** + Run a temporary ChromaDB instance bound to an isolated loopback port (e.g. `127.0.0.1:18199`): + ```bash + docker run -d --name "${PROJECT}-chromadb-staging" \ + -p 127.0.0.1:18199:8000 \ + -v "${VOLUME}:/data" \ + chromadb/chroma:latest + + # Wait for staging ChromaDB to boot + sleep 4 + curl -s http://127.0.0.1:18199/api/v2/heartbeat || { echo "Staging container failed to respond"; exit 1; } + ``` +2. **Compare Collection Counts with Baseline:** + ```bash + curl -s http://127.0.0.1:18199/api/v2/tenants/default_tenant/databases/default_database/collections \ + | python3 -c ' + import json, sys, urllib.request + data = json.load(sys.stdin) + print(f"Staging collections verified: {len(data)}") + for col in data: + c_id = col["id"] + name = col["name"] + with urllib.request.urlopen(f"http://127.0.0.1:18199/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp: + count = resp.read().decode().strip() + print(f" {name}: {count} records") + ' + ``` + *Confirm that the output matches `pre_migration_inventory.txt` from Step 1.* + +3. **Tear Down Staging Container:** + ```bash + docker rm -f "${PROJECT}-chromadb-staging" + ``` + +--- + +### Step 8: Apply Updated Compose Files and Perform Production Cutover + +Once the seeded volume is proven healthy: + +1. Update your repository to the revision containing PR #6166: + ```bash + git pull + ``` +2. Verify the mount line in your Compose file: + ```bash + grep -E 'chromadb-data:/data' docker-compose.yml + ``` +3. Bring up the updated ChromaDB container: + ```bash + docker compose up -d chromadb + ``` + Docker Compose will detect the updated mount configuration, replace the stopped container, + and attach `${VOLUME}` directly to `/data`. +4. Bring up the application stack: + ```bash + docker compose up -d odysseus + ``` + +--- + +### Step 9: Post-Migration Production Verification + +1. **Confirm Mount Point:** + ```bash + docker inspect "${CONTAINER}" --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}' + ``` + Expected output: + ``` + _chromadb-data -> /data + ``` +2. **Confirm Production Collection Counts:** + ```bash + curl -s http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections \ + | python3 -c ' + import json, sys, urllib.request + data = json.load(sys.stdin) + print(f"Production collections: {len(data)}") + for col in data: + c_id = col["id"] + name = col["name"] + with urllib.request.urlopen(f"http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp: + count = resp.read().decode().strip() + print(f" {name}: {count} records") + ' + ``` +3. **Application Verification:** + Log in to Odysseus, navigate to **Settings -> Memory**, and confirm that memory items, + tool embeddings, and RAG document queries operate normally. + +--- + +## 4. Rollback Procedures + +### Scenario A: Failure During Staging (Before Compose Cutover) + +If staging verification in Step 7 fails or reveals discrepancies: +1. Remove the staging container: + ```bash + docker rm -f "${PROJECT}-chromadb-staging" + ``` +2. Clear the failed destination volume: + ```bash + docker run --rm -v "${VOLUME}:/target" alpine sh -c "rm -rf /target/*" + ``` +3. Restart the original production container. Because the original container was only stopped (never recreated), + its writable layer remains completely intact: + ```bash + docker compose start chromadb + docker compose start odysseus + ``` +4. Verify production status on port 8100. + +--- + +### Scenario B: Failure After Compose Cutover + +If an issue occurs after `docker compose up -d`: + +> [!CAUTION] +> **Do not `git checkout` the pre-#6166 Compose file!** +> In the pre-#6166 Compose file, `chromadb-data` was mounted to `/chroma/chroma`. +> ChromaDB 1.0+ persists to `/data`, so checking out the old Compose file will cause +> ChromaDB to ignore the restored volume and boot with an empty database. + +To restore service: +1. **Option 1 — Restore from Verified Host Backup into the `/data` Volume:** + If the container failed due to data corruption during startup: + ```bash + docker compose stop chromadb + docker run --rm -v "${VOLUME}:/target" alpine sh -c "rm -rf /target/*" + docker run --rm \ + -v "${VOLUME}:/target" \ + -v "$(realpath "${BACKUP_DIR}"):/source:ro" \ + alpine cp -a /source/. /target/ + docker compose up -d chromadb + ``` +2. **Option 2 — Fallback to Committed Backup Image:** + If Compose cannot start ChromaDB with the volume: + ```bash + docker compose stop chromadb + docker run -d --name "${PROJECT}-chromadb-fallback" \ + -p 127.0.0.1:8100:8000 \ + "${BACKUP_IMAGE}" + ``` + This immediately restores the exact pre-migration container state. + +--- + +## 5. Post-Migration Cleanup (Explicit Authorization) + +Retain the host backup directory (`${BACKUP_DIR}`) and the backup Docker image (`${BACKUP_IMAGE}`) +for at least 7 days until routine backups have succeeded. + +Once you are completely confident in system stability, manually clean up: +```bash +# Remove host backup +rm -rf ./chromadb-backup-* + +# Remove backup Docker image +docker rmi "${BACKUP_IMAGE}" +``` + +--- + +## 6. Re-indexing (Disaster Recovery Only) + +If an installation has already destroyed its pre-#6166 container writable layer (e.g. prior to following +this guide), the vector embeddings cannot be restored from disk. In this scenario: + +* **RAG Documents:** Re-upload files under **Settings -> Memory -> RAG**. Odysseus automatically re-chunks + and re-indexes documents upon upload. +* **Semantic Memories:** Text memory records reside authoritatively in `data/app.db` (SQLite). Vector representations + are re-indexed automatically when Odysseus connects to a healthy ChromaDB instance. +* **Tool Index:** Rebuilt automatically by Odysseus upon first query. + +Re-indexing is a disaster recovery fallback, not a substitute for preserving existing database state. diff --git a/docs/frontend-architecture/ADR-001-source-and-deployment.md b/docs/frontend-architecture/ADR-001-source-and-deployment.md new file mode 100644 index 000000000..cc1eca04e --- /dev/null +++ b/docs/frontend-architecture/ADR-001-source-and-deployment.md @@ -0,0 +1,135 @@ +# ADR 001: Feature-first native ESM with controlled deployment output + +Date: 2026-10-13. Status: implemented F1 proof, proposed for maintainer adoption. +Baseline: official `upstream/dev` at `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +Scope: source ownership and distribution foundation. Notes implementation is deferred. + +## Decision + +Maintain migrated JavaScript in `frontend/src/`. Publish native ES modules to +explicitly registered paths in `static/js/`, and commit source and output together. +Use `python scripts/build_frontend.py` to copy bytes without rewriting imports, +bundling, minification, source maps, timestamps or generated headers. A checked-in +`frontend/modules.json` maps source paths to deployment paths. All other static +files remain authored legacy modules or deployment assets until a reviewed slice +transfers their ownership. Do not claim that the whole static tree is generated. + +The proof transfers maintenance of the stateless action-menu ordering helper to +`frontend/src/shared/actionMenuOrder.js`. Its output remains +`/static/js/actionMenuOrder.js`, with the same three exports and identical bytes. +All seven current consumers, HTML, service worker and runtime entry points retain +their existing URLs. There is no wrapper, extra request or second browser module +instance. The source copy is a development input, not a second runtime entry point. + +Use `frontend/src/app/` for composition, `features//index.js` for feature +public APIs, and responsibility directories only when implementation requires +them. The [handbook](handbook.md) defines dependency, ownership and lifecycle rules. +F1 creates only the shared proof, without empty application or feature scaffolding. + +## Evidence and alternatives + +Both options were executed against this helper, rather than assumed to work. +Measurements are local compatibility observations, not general performance claims. + +| Property | Controlled native ESM copy, selected | esbuild 0.25.12 controlled ESM build | +| --- | --- | --- | +| Proof output | 1,688 bytes, identical SHA-256 to existing runtime | 1,554 bytes with `--bundle --format=esm --target=es2022 --platform=browser` | +| Repeatability | Clean temporary publication and idempotent rerun tested | Two independent compiler invocations emitted identical bytes | +| Runtime contract | Existing exports, imports, cache entry and module identity unchanged | Export names, icon and ordering matched in an executed Node import | +| Tooling | Python standard library; no production dependency | Compiler and platform binary needed for artifact production | +| Local cost | Copy check: 0.03 s, 15,596 KB maximum RSS | Cached `npx` ESM transform: 0.58 s, 114,196 KB maximum RSS | +| Incremental migration | Explicit file ownership, preserved module boundaries | Feasible for the stateless leaf; stateful facade/chunk identity still needs a separate graph spike | +| Release implications | Commit output; install/startup paths consume existing static assets | Would also need committed or release-produced output plus compiler locking and facade/chunk manifest policy | + +The timing rows measure different tool paths, including `npx` overhead. The +compiler's single-leaf success does not establish stateful graph or lazy chunk +compatibility. The 134-byte bundle reduction does not justify that additional +contract in F1. A future compiler adoption requires a new ADR and graph-level +evidence. React, Vue, TypeScript, a global state library, hashed chunks and a +development server have no demonstrated necessity here. + +Acorn 8.19.0 is added only as an exact, lockfile-pinned development dependency for +architecture checks. A real JavaScript parser is needed to enforce boundaries +across multiline imports, re-exports and lazy thunks without treating comments +and string literals as executable imports. It is MIT licensed, about 568 KB +unpacked, and contributes no deployed JavaScript or runtime dependency. + +## Distribution contract + +1. Source and generated output are reviewed and committed together. CI checks + byte parity; it does not silently rebuild and accept drift. +2. Native Python startup continues to use `STATIC_DIR` and `/static`, without + invoking the publisher. Native installers already obtain tracked static files. +3. Docker's current `COPY . .` includes the output. `.dockerignore` excludes + `node_modules/`. The Docker runtime and entrypoint require no frontend build. +4. `Odysseus.spec` and `build-windows-portable.ps1` already include `static/`. + The macOS wrapper uses the Python checkout and its static assets. Neither + needs the source tree or frontend tooling to serve the proof. +5. A release producer runs the checks before packaging and distributes the + entire matching static graph. The publisher is not a transactional deployment + mechanism. Partial copying of a new graph into a live deployment is unsupported. +6. New output paths must declare LF checkout behavior in `.gitattributes`, as + this proof does, so Windows checkout conversion cannot create false byte drift. + Before checking parity or writing output, the publisher uses the manifest to + require effective `text: set` and `eol: lf` on every source/output path. It asks + Git in a temporary repository containing only the applicable repository-owned + `.gitattributes` files, including nested rules. Global/local Git overrides + cannot supply missing declarations. No blanket legacy-JS rule is introduced. + Git is a development/publication prerequisite, not a startup dependency. + +An isolated distribution probe executes the real FastAPI lifespan, shell and +static requests after omitting `frontend/` and `node_modules/` and removing Node +from the child process search path. It verifies bytes, MIME, CSP, revalidation, +source-tree 404 behavior and static path confinement. The same probe runs in an existing Docker image +with networking disabled. This is not a claim that a fresh full Docker image or +Windows/macOS executable was built; see [validation](validation.md). + +## Imports, identity and offline operation + +New maintained modules use one unversioned deployment URL. Existing legacy URLs, +including their version queries, stay unchanged. A copy mapping must preserve +the resolution of every literal relative import; the architecture checker rejects +a mapping that changes that resolution. Canonical absolute `/static/js/...` +imports are also supported. No bare packages or browser imports of `frontend/src` +are supported. Existing graph and identity guards continue to cover the runtime. + +Each registered output joins the exact `PRECACHE` or `PANEL_PRECACHE` array. The +new checker parses those arrays, rather than accepting a URL mentioned in a +comment. Installation fetches do not evaluate lazy modules. Import-time DOM work, +timers, state creation and network I/O must be characterized before changing an +entry point's load timing. New source dependency cycles are rejected; existing +legacy cycles are inventoried and remain outside the new rules. + +The proof already has its exact precache entry, so no SW cache-name or HTML edit +is needed. Its first evaluation in a fresh offline browser page passes, and +importing the panel registry does not evaluate the editor. The current worker's +`/static/` scope cannot control the SPA root. Open PR #6227 addresses that existing +limitation; F1 does not fix or mask it. Full root offline reload is not certified. + +## Security and compatibility + +Keep backend authorization and owner scope authoritative. No agent, database, +auth, CSP, static mount or API implementation changes belong to this decision. +The helper remains same-origin ESM without eval, compiler runtime or external +resources. No new source maps or vendored assets are emitted; existing SRI/CSP +policy and distribution notices remain unchanged. The publisher validates path +containment, symlinks, duplicate ownership and unmapped source before writing. +It never deletes unowned static assets. Deleting or retiring outputs is an +explicit reviewed migration operation, not automatic cleanup. + +## Rollout, rollback and limits + +Adopt F1 for controlled, reviewed slices after maintainer review. Leave Notes, +bootstrap, HTML and service worker unchanged here. Register new files explicitly; +update offline manifests only when new runtime dependencies are introduced. +F1 rollback can remove the source manifest, proof tooling, gates and documentation +without changing runtime bytes. Later rollback restores the complete prior +source/output/facade/precache set through an authorized follow-up change. + +Boundary gates cannot prove semantic purity, owner isolation or correct teardown. +Domain platform/clock checks are intentionally conservative syntax guards; +behavior tests still establish those contracts. Worker URLs, styles and vendored +libraries are outside the maintained-JS import gate and need explicit review if +a future lane changes them. Legacy exceptions require an exact specifier and a +module-scoped reason; there are none in F1. New source is not permission to +grandfather new dependency cycles or to replicate broadly shared mutable state. diff --git a/docs/frontend-architecture/assessment.md b/docs/frontend-architecture/assessment.md new file mode 100644 index 000000000..d5c746c7c --- /dev/null +++ b/docs/frontend-architecture/assessment.md @@ -0,0 +1,127 @@ +# F1 runtime and architecture assessment + +Assessment date: 2026-10-13. Repository: `odysseus-dev/odysseus`. +Worktree: `worktrees/architecture/frontend-foundation`. +Branch: `refactor/frontend-architecture-foundation`. +HEAD, local `upstream/dev`, and the live GitHub official `dev` commit were all +`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The initial worktree was clean. +That commit is the merged PR #6495, selected Notes/Todos agent context. +`origin` points to the maintainer fork; integration targets official `upstream/dev`. + +Inputs read completely: the 2026-10-13 frontend architecture RFC and coding brief +from Downloads, `specs/frontend.md`, repository instructions and existing tests. +The current user brief supersedes the coding brief's `origin/dev` wording and +defines the Notes state/workflow slice before reminders. The design is self-contained +in this directory and does not require access to Downloads. + +Input provenance: + +- RFC SHA-256: `11dedcbb9971f4d787f2721b9c27aa43299baba39ca91066d01507d2d69ac4f8`. +- Coding brief SHA-256: `e4882867617b9c732ce4f445e3bc2da0b4ec1315119f8226c1fd8c1acbacf29d`. + +## Module graph and bootstrap + +`node scripts/check_frontend.mjs --inventory` reproduces the parser-based inventory. +It excludes vendored `static/lib/` and committed editor build output. At this base +it scans 230 JS files, finds 707 static import/re-export edges and 108 literal +dynamic import edges, and finds no missing targets. No computed dynamic imports +were found in the original parser audit. The earlier existing-regex scan counted +814 edges; executable syntax parsing counts 815. Use parser evidence for design +and retain the existing regression guard unchanged. + +| Area | Current boundary and implication | +| --- | --- | +| HTML shell | `static/index.html`: nonce-bearing boot scripts, CSS/preloads, direct ESM script tags, then app/init/a11y. Multiple entry roots mean module identity matters. | +| App composition | `static/app.js`: feature imports, globals, route/sidebar composition, session hydration and auth-fetch wiring. It eagerly imports Notes, Calendar, Chat, Documents and Settings. | +| Startup | `static/js/startupShell.js`, `sessions.js`, `init.js`: first-paint scheduling, deferred hydration, user-switch storage cleanup and cosmetic permission gates. Preserve boot ordering. | +| Lazy loading | `static/js/panels.js`: only `editor` is registered. A memoized import thunk shares successful/in-flight promises and retries failed imports. Do not label all panels lazy. | +| Optional libraries | `markdown.js` lazily loads vendored KaTeX/Mermaid. KaTeX and fonts are precached; Mermaid becomes cached after first use. Pyodide has a CDN dependency. | +| Feature packages | Settings registry/navigation/finder/lifecycle, Calendar utilities/reminders, Email wrapper/package and editor tools already demonstrate partial decomposition. | + +Highest import fan-in: `ui.js` 77, `spinner.js` 55, `editor/state.js` 39, +`escMenuStack.js` 23, and storage/modal/z-order helpers 17 each. The measured +static strongly connected components are: + +- Theme and UI. +- Email Inbox, compatibility wrapper, package index, AI reply, attachments, + body rendering, menus, reader, settings page and unsubscribe. +- Cookbook, diagnosis, HW Fit, download, running and serve modules. + +These are existing compatibility constraints, not approved patterns for new +source. Dynamic edges connect more features but do not themselves establish +initialization cycles. No global legacy dependency rewrite is attempted in F1. + +## State, side effects and ownership + +| Owner | Evidence and architectural treatment | +| --- | --- | +| Shared configuration | `appConfig.js` owns invalidatable settings/tool request promises and consumes a sessionStorage prefetch once. Retain single identity and writer invalidation. | +| Browser persistence | `storage.js` centralizes keys and safe access; feature files also use direct localStorage. `init.js` cleans storage on user changes. Preferences are not an authorization store. | +| Feature state | Notes has module variables, selection sets, undo/drafts and reminder state. Email exports a broadly mutable package state object; editor state is imported by 39 modules. Keep ownership explicit; do not expand these into a global store. | +| Import-time effects | Notes reads storage/window and schedules reminders. Email state reads localStorage. Theme/UI initialize from DOM readiness; modal manager installs global listeners. Chip/mobile helpers bind at startup. Moving an import can change behavior before any exported function is called. | +| Compatibility globals | App exposes controllers on `window`; Notes exposes `window.notesModule`. They are compatibility bridges, not the new inter-feature API convention. | +| Async ownership | Session hydration, config invalidation, draft serialized saves and request-generation guards already prevent stale completion. New slices must preserve these rather than only move declarations. | + +Good patterns to retain: narrow zero-state leaves, explicit wrapper export lists, +public API tests, retryable memoized lazy imports, shared config invalidation, +request-generation checks and source/package-aware regression helpers. + +Patterns to avoid: blanket barrels exposing internals, feature imports from +generic helpers, cycles through coordinators, direct writes to another feature's +state or DOM, anonymous global listener ownership, import-triggered network/timer +initialization in new APIs, and helpers grouped by convenience instead of purpose. + +## Runtime, assets and distribution + +| Contract | Verified source | +| --- | --- | +| Static URL and revalidation | `app.py`: `register_static_mime_types`, `_RevalidatingStatic`, `/static` mount. JS MIME is `text/javascript`; JS/CSS/HTML have `Cache-Control: no-cache`. | +| App paths | `src/constants.py` and `src/runtime_paths.py`: checkout root in native mode, PyInstaller `_MEIPASS` in frozen mode; static assets follow app root, persisted data follows data-directory policy. | +| CSP | `core/middleware.py`: same-origin scripts, nonce and jsDelivr allowance; `src/app_helpers.py` injects HTML nonces. No CSP relaxation is needed for the proof. | +| Integrity | No `integrity=` attributes were found in the shell. Proof output is byte-identical and emits no source maps; no existing SRI or vendored artifacts change. | +| Runtime-generated assets | `/api/generated-image/...` uses a confined data path and owner handling; report/render endpoints, Blob manifests/icons and uploads are runtime concerns outside static source publication. | +| Docker | `Dockerfile` copies app code and existing static output; `.dockerignore` excludes node_modules. Node/npm in the image support Browser MCP, not frontend startup. | +| Native installers | `setup.py`, `start-macos.sh`, `launch-windows.ps1`, systemd service: Python dependencies/startup consume the checkout. No frontend build is invoked. | +| Windows desktop | `Odysseus.spec` and `build-windows-portable.ps1` include the whole static directory. Maintain output here rather than require a new bundle path or compiler. | +| macOS | `build-macos-app.sh` creates a launcher around the Python checkout. Static serving follows the existing checkout path. Installer execution was not performed on Linux. | + +## PWA limitations and test coverage + +`static/sw.js` matches exact URLs, including queries. Install fetches each +precache entry separately and tolerates failed fetches. It does not discover +transitive dependencies. HTML root and JS/CSS handlers are network-first; +other static assets are cache-first with background refresh. API and non-GET +requests bypass caching. Cache updates need explicit version/manifest review. +The older frontend spec's root stale-while-revalidate description is stale. + +Registration in `static/index.html` currently requests no wider scope, so the +worker controls `/static/`, not `/`. Root offline reload is therefore a known +pre-existing gap. PR #6227 proposes the scope/header correction. F1 tests actual +offline ESM loading under the current scope and leaves that separate fix alone. +Offline APIs, first-use Mermaid and optional CDN runtimes are not certified. + +Existing coverage includes runtime graph resolution and version parity; editor +offline graph coverage and lazy-loader retries; Email API/export/evaluation +coverage; Notes autosave, XSS, selection/listener and agent-context regressions; +MIME, nonce and runtime paths. Tests use pytest, Node's built-in runner and existing +Playwright Chromium. `tests/TESTING_STANDARD.md` requires behavior-first, isolated +tests. F1 adds real publication failures, architecture violations and an offline +browser proof. It does not replace existing source-sensitive tests. + +## Open PR overlap, checked live on 2026-10-13 + +The open-PR listing was fetched through all pages; selected PR file lists were +inspected. Refresh this inventory before any later slice. + +| PR | Overlap and disposition | +| --- | --- | +| [#6470](https://github.com/odysseus-dev/odysseus/pull/6470) | Theme/sidebar redesign touches HTML, SW, shared UI and CSS. F1 changes none of those assets. Future manifest/UI slices need collision review. | +| [#6695](https://github.com/odysseus-dev/odysseus/pull/6695) | OXS touches bootstrap, HTML, platform, middleware and other backend boundaries. F1 does not establish an extension API or modify that platform. | +| [#6227](https://github.com/odysseus-dev/odysseus/pull/6227) | Root SW scope/header fix. Independent follow-up dependency for broader offline certification. | +| [#6394](https://github.com/odysseus-dev/odysseus/pull/6394) | Older CSS extraction touches legacy style/SW assets; requires separate supersession review. | +| [#6627](https://github.com/odysseus-dev/odysseus/pull/6627) | npm development-dependency update overlaps package/lock files; reconcile the exact Acorn development entry on integration. | +| [#6585](https://github.com/odysseus-dev/odysseus/pull/6585), [#6647](https://github.com/odysseus-dev/odysseus/pull/6647), [#6652](https://github.com/odysseus-dev/odysseus/pull/6652) | Notes optional-field persistence and agent routing/dispatch. Future characterization must reflect their merged state; F1 changes no Notes or backend behavior. | +| [#6594](https://github.com/odysseus-dev/odysseus/pull/6594), [#6152](https://github.com/odysseus-dev/odysseus/pull/6152) | Desktop launcher/build and frozen-runtime packaging work. Existing paths stay stable; packaging still needs platform-owner validation. | + +PR titles alone do not establish compatibility. This report records the checked +file overlap and relevant dependencies, not approval of those implementations. diff --git a/docs/frontend-architecture/evidence/notes/implementation.sha256 b/docs/frontend-architecture/evidence/notes/implementation.sha256 new file mode 100644 index 000000000..023b5ca30 --- /dev/null +++ b/docs/frontend-architecture/evidence/notes/implementation.sha256 @@ -0,0 +1,112 @@ +53dfddd71f02a9a60df42e3fb045bd1a80919d50870c0b77afe18a853f485bea .gitattributes +867c6a4b025fefecb6ac4a91a38563a36107d9653599f64685e16a5be4fe7727 frontend/modules.json +76de35ac93d72edb5dcd295e85c704af9b3f08c2fe9fdc56e0bcbebd4b322fd8 frontend/src/features/notes/application/agentActions.js +37021f9643f6a3299d36b6021783a559e60ad84848ac2b12f0980b5034b6b9c4 frontend/src/features/notes/application/cardOperations.js +fe3033db6ef316c6b15c6b01c0f202ee76a61ea2c5b6952b20144660ff17eb54 frontend/src/features/notes/application/checklistOperations.js +85fc592724524bb5ff37d7e5e58a68fda9d7e645810b6c87a2d4dd3cb41f9dd5 frontend/src/features/notes/application/deletion.js +ce38e7976ee315b198baebffe45f9a1f4e63d87affd9898ed5cf4bde37b903e7 frontend/src/features/notes/application/drafts.js +6210915184c30f73911f144fe0d12a9935d6f2ce42cb2aaf6aeaa75614ca51a2 frontend/src/features/notes/application/queries.js +0da923790f4036d5fe354df4e529feb4b96e178495cb951f269093c0a8347e4e frontend/src/features/notes/application/reminders.js +8f95d086fc2e60beae32b9e9debba41b35448e1c5b4ac78e4989652ef2d44e35 frontend/src/features/notes/application/selection.js +4ceb424b3b6e30a5f507804f42051be02390d4ce1f03ef284094e48825cde5c3 frontend/src/features/notes/application/state.js +1c5d5af833aab91f71a34c11535885eb7bf9f751de5aa89f79defe0f696f20fb frontend/src/features/notes/application/submission.js +1d58ea2d495c8d301c550eb9ba7f627c515a8784bcd105558d941522965e77b0 frontend/src/features/notes/application/workflows.js +9ddbfb20e0a5564dd83365ad498286d68ef03fd60d632619ad4d9656e2f73c95 frontend/src/features/notes/domain/noteRules.js +946ef7a98b0a2dbe329e8259250af11a11702c74fd997e17fb3aff007fbe40c9 frontend/src/features/notes/domain/recurrence.js +25cf3a1cef5dbac0840aef513ffa010a9b08d206de96ce2527e6159a5454b555 frontend/src/features/notes/index.js +8b186bbfbb003b8d738c80713e1cbb5c6924ee14119929c5ea65707b46076b0c frontend/src/features/notes/infrastructure/http.js +c7552d1f4600ae721437892fc0a1e5a0332abe30082bd943cd13a379c9514fec frontend/src/features/notes/infrastructure/notifications.js +ff91a55c55fbdd876c69d9ec10e4e992104c95b6d12d752d9745193fccbc646a frontend/src/features/notes/infrastructure/storage.js +4ff6d69d04547a0325041f774c3bf0707d8b67982d59a221127e07dc9b01383c frontend/src/features/notes/ui/cardChecklist.js +dd87f1929ef8a5d57fdf1ddb12a0a6be53d95be07946be54f946c6fe31ef80bc frontend/src/features/notes/ui/cardInteractions.js +4ff661a65d88e810f346260a354070dba325d6beaa57d1c4e82e8bf09b894538 frontend/src/features/notes/ui/cardRendering.js +113d4457f25291d15afb7fb6775ea063f24c9dac51bb0d262787c9e40705235b frontend/src/features/notes/ui/cardReorder.js +eecafbd92076209fa41826820125fbe9ffa757d6d1bff018e9acc2ede2a18e72 frontend/src/features/notes/ui/checklist.js +155ea3d70e653f38a62ff50fc5be6aee019d42e86d28753da9edb258ac7c93e1 frontend/src/features/notes/ui/checklistReorder.js +5462fea63a2aa9d1fc07dca91b462f6ff24f352b05d99ea6feb437469c0e1f3e frontend/src/features/notes/ui/clipboard.js +2619e5f8b15eb3057330ef220abe6bc354a496effc0df3d89385cdfef9242c86 frontend/src/features/notes/ui/colors.js +11cf24e7e8e8954d764cb58d786fa6f6d71f84f1c6c5ba644d761e28cfdbaedf frontend/src/features/notes/ui/draftEditor.js +f55d4eff7f550e183ecf240a45002936063dbb4eae153e11834da4cb6bd13d35 frontend/src/features/notes/ui/drawing.js +f85c86e70b3a380449fe3c7ecc7a4e68946de320523f5edc8e33f8eab3f0a02a frontend/src/features/notes/ui/editor.js +3946b8324ad8f0212621e526479668308e48b799bf0dbb608e5cd4626f6c1f03 frontend/src/features/notes/ui/filters.js +a893d09e96fb98d13db8ae5c3fb8c5e7a040c62031dd961396a18b7ae2a82a38 frontend/src/features/notes/ui/layout.js +8d8c62a87d4df134d3b9dd97eaf59d02c52f1a38f6bc2e109e8426081d453a8d frontend/src/features/notes/ui/lifecycle.js +62ad9408cb139143168f2c5834ec56b83264c4daed3e84fa9b4b0a281712144c frontend/src/features/notes/ui/mobileEditor.js +2a3ccbad103dcc880e4baf2b3aa3ece4df7b73752bd30078185f03227585148f frontend/src/features/notes/ui/mobileReorder.js +856fe945cf697278f795b8753704119839fa12bc5e0e69e9dab57c4b4dd6d24e frontend/src/features/notes/ui/panel.js +8aa081cb7341d9a67ef46c74da49aebd729e9b53a73d618b81c89566c874b507 frontend/src/features/notes/ui/quickAdd.js +8a4f23a013f06876894e6544b49540b288007d0d7e4cddca955978588f47a64a frontend/src/features/notes/ui/reminderDisplay.js +cb043be801ab40cf8141af6ff1ad788e69fef57a87960ca992071ad9ad29ad90 frontend/src/features/notes/ui/reminderPicker.js +38f12ba7771589aa427f054f48919569dcee9abe0246e02b9767202adb854cdc frontend/src/features/notes/ui/selection.js +2634f252b2899e2e70fc9fec37144b0cc9b0ed00724da81bfef74b1acf7c05b6 frontend/src/features/notes/ui/text.js +d2655ef163825ce6c40d8ef46bc9dfc63a2aa19fb1a7e10b28002f79cb0db409 frontend/src/features/notes/ui/toolbar.js +a0aa203e87814d0eebebb6b049776c3efd576f4a5c6e17e486c8a2893c9c21df frontend/src/features/notes/ui/windowLayout.js +68f0cbe07c7b61fcaf92920cd47042c576ab27fd9553c971cff45c595c25d80a frontend/src/shared/ui/confetti.js +c16fcde607dce2f10a1afd1f8845403a7d0b97a9d5a67ec7aa329c367ba585e8 scripts/verify_frontend_runtime.py +c2e5de2b8ce168220bad4fcefed710e60eeb0dd3e992e76cd980f2c0ca52815a scripts/verify_notes_browser_runtime.py +fa31d673c40f973490e9fac52130b4a915b944a7abdd217a9bd8d57e66d57ca6 static/js/compare/vote.js +76de35ac93d72edb5dcd295e85c704af9b3f08c2fe9fdc56e0bcbebd4b322fd8 static/js/features/notes/application/agentActions.js +37021f9643f6a3299d36b6021783a559e60ad84848ac2b12f0980b5034b6b9c4 static/js/features/notes/application/cardOperations.js +fe3033db6ef316c6b15c6b01c0f202ee76a61ea2c5b6952b20144660ff17eb54 static/js/features/notes/application/checklistOperations.js +85fc592724524bb5ff37d7e5e58a68fda9d7e645810b6c87a2d4dd3cb41f9dd5 static/js/features/notes/application/deletion.js +ce38e7976ee315b198baebffe45f9a1f4e63d87affd9898ed5cf4bde37b903e7 static/js/features/notes/application/drafts.js +6210915184c30f73911f144fe0d12a9935d6f2ce42cb2aaf6aeaa75614ca51a2 static/js/features/notes/application/queries.js +0da923790f4036d5fe354df4e529feb4b96e178495cb951f269093c0a8347e4e static/js/features/notes/application/reminders.js +8f95d086fc2e60beae32b9e9debba41b35448e1c5b4ac78e4989652ef2d44e35 static/js/features/notes/application/selection.js +4ceb424b3b6e30a5f507804f42051be02390d4ce1f03ef284094e48825cde5c3 static/js/features/notes/application/state.js +1c5d5af833aab91f71a34c11535885eb7bf9f751de5aa89f79defe0f696f20fb static/js/features/notes/application/submission.js +1d58ea2d495c8d301c550eb9ba7f627c515a8784bcd105558d941522965e77b0 static/js/features/notes/application/workflows.js +9ddbfb20e0a5564dd83365ad498286d68ef03fd60d632619ad4d9656e2f73c95 static/js/features/notes/domain/noteRules.js +946ef7a98b0a2dbe329e8259250af11a11702c74fd997e17fb3aff007fbe40c9 static/js/features/notes/domain/recurrence.js +25cf3a1cef5dbac0840aef513ffa010a9b08d206de96ce2527e6159a5454b555 static/js/features/notes/index.js +8b186bbfbb003b8d738c80713e1cbb5c6924ee14119929c5ea65707b46076b0c static/js/features/notes/infrastructure/http.js +c7552d1f4600ae721437892fc0a1e5a0332abe30082bd943cd13a379c9514fec static/js/features/notes/infrastructure/notifications.js +ff91a55c55fbdd876c69d9ec10e4e992104c95b6d12d752d9745193fccbc646a static/js/features/notes/infrastructure/storage.js +4ff6d69d04547a0325041f774c3bf0707d8b67982d59a221127e07dc9b01383c static/js/features/notes/ui/cardChecklist.js +dd87f1929ef8a5d57fdf1ddb12a0a6be53d95be07946be54f946c6fe31ef80bc static/js/features/notes/ui/cardInteractions.js +4ff661a65d88e810f346260a354070dba325d6beaa57d1c4e82e8bf09b894538 static/js/features/notes/ui/cardRendering.js +113d4457f25291d15afb7fb6775ea063f24c9dac51bb0d262787c9e40705235b static/js/features/notes/ui/cardReorder.js +eecafbd92076209fa41826820125fbe9ffa757d6d1bff018e9acc2ede2a18e72 static/js/features/notes/ui/checklist.js +155ea3d70e653f38a62ff50fc5be6aee019d42e86d28753da9edb258ac7c93e1 static/js/features/notes/ui/checklistReorder.js +5462fea63a2aa9d1fc07dca91b462f6ff24f352b05d99ea6feb437469c0e1f3e static/js/features/notes/ui/clipboard.js +2619e5f8b15eb3057330ef220abe6bc354a496effc0df3d89385cdfef9242c86 static/js/features/notes/ui/colors.js +11cf24e7e8e8954d764cb58d786fa6f6d71f84f1c6c5ba644d761e28cfdbaedf static/js/features/notes/ui/draftEditor.js +f55d4eff7f550e183ecf240a45002936063dbb4eae153e11834da4cb6bd13d35 static/js/features/notes/ui/drawing.js +f85c86e70b3a380449fe3c7ecc7a4e68946de320523f5edc8e33f8eab3f0a02a static/js/features/notes/ui/editor.js +3946b8324ad8f0212621e526479668308e48b799bf0dbb608e5cd4626f6c1f03 static/js/features/notes/ui/filters.js +a893d09e96fb98d13db8ae5c3fb8c5e7a040c62031dd961396a18b7ae2a82a38 static/js/features/notes/ui/layout.js +8d8c62a87d4df134d3b9dd97eaf59d02c52f1a38f6bc2e109e8426081d453a8d static/js/features/notes/ui/lifecycle.js +62ad9408cb139143168f2c5834ec56b83264c4daed3e84fa9b4b0a281712144c static/js/features/notes/ui/mobileEditor.js +2a3ccbad103dcc880e4baf2b3aa3ece4df7b73752bd30078185f03227585148f static/js/features/notes/ui/mobileReorder.js +856fe945cf697278f795b8753704119839fa12bc5e0e69e9dab57c4b4dd6d24e static/js/features/notes/ui/panel.js +8aa081cb7341d9a67ef46c74da49aebd729e9b53a73d618b81c89566c874b507 static/js/features/notes/ui/quickAdd.js +8a4f23a013f06876894e6544b49540b288007d0d7e4cddca955978588f47a64a static/js/features/notes/ui/reminderDisplay.js +cb043be801ab40cf8141af6ff1ad788e69fef57a87960ca992071ad9ad29ad90 static/js/features/notes/ui/reminderPicker.js +38f12ba7771589aa427f054f48919569dcee9abe0246e02b9767202adb854cdc static/js/features/notes/ui/selection.js +2634f252b2899e2e70fc9fec37144b0cc9b0ed00724da81bfef74b1acf7c05b6 static/js/features/notes/ui/text.js +d2655ef163825ce6c40d8ef46bc9dfc63a2aa19fb1a7e10b28002f79cb0db409 static/js/features/notes/ui/toolbar.js +a0aa203e87814d0eebebb6b049776c3efd576f4a5c6e17e486c8a2893c9c21df static/js/features/notes/ui/windowLayout.js +7f1a7961d586767298672fad71a0d627389e185f0fb28792a04d0b987e1d5c5c static/js/notes.js +68f0cbe07c7b61fcaf92920cd47042c576ab27fd9553c971cff45c595c25d80a static/js/shared/ui/confetti.js +97fd43c17f3cb1928947e24159a3b59dc81ef7dca95862e8a47154e9cb732f88 static/sw.js +910cd5777564e9f6338b8a864a772beffea197dc476464171c180e7be96e572d tests/browser/notesNative.mjs +ef4bc40a710919c81c582d3b9fe80376d0d5ae66fb9be3c293871a83ee360f97 tests/browser/notesOffline.mjs +437ec2e9865b3e066bbec8d7595a0d947f1e503b96e7b4d819ec1bed86c9bb1e tests/browser/notesReference.mjs +9af973a5d2fb08a5eed550be0198bef501af56802c3844b5d0bb8e718cc685cf tests/fixtures/notesRecurrenceBaseline.mjs +859a5f442dc717f59993891c338cd6ae9d6e5e009392a2ee69ab0e3b063bb572 tests/helpers/notes_source.py +739ffc3f20de8e2bdf1d513b17bf485f85821e9906109cdf8ffea04b81b0f1dd tests/notesApplication.test.mjs +1946369be43177b19a44cd9ae4f90d64da6558d1a470f8e347b490a2188782be tests/notesCharacterization.test.mjs +99f0cd92a30cf01c0ee6814f6684edeb80ebdb9a5ab96583244092865bfb6212 tests/notesDraftAutosave.test.mjs +0417205809de16c7c7338ea80ba19ca844603b3e06655987226f5f83d962d18b tests/notesInfrastructure.test.mjs +dcf7c2d33de9bb457f96af070354ecc3ab69669002bdc9c7c943bef1b3d2e9bc tests/notesRecurrence.test.mjs +dd9a703465dde8f92625a3226ab52d20a02aa5d3415fbcc70609f69a372ccc12 tests/notesReminders.test.mjs +5a9112569f226525faaf6603a9867620b47902534f9cd882c08f71f2f96614b2 tests/notesWorkflows.test.mjs +35fb2bd1f02757b73188342ec00f1e97253b8ba8dfb577861675872a517bd949 tests/selectedNotesContext.test.mjs +c6c64c2b695b84d00f8a80a980be00766175c482548c86484f991ef671ecfbe9 tests/test_notes_document_mutual_minimize.py +02c2aeb856798d982e5debcb9c605172933b0216a039cf0d96cdd415b1e2ef83 tests/test_notes_dom_xss_helpers.py +5a511c7a2ef5d28463df34f3462db9ce59575d624cf8181577bab2cc371b4f9f tests/test_notes_mobile_checklist_toggle_static.py +1db10a0d344818459d64279da807ddefe9b866dfe560ac4b154b486dd1a5efc0 tests/test_notes_reference_browser.py +49f466b1a717098aa25439b6ff9ef3ae3d027d9dd976212b165b0b99a582b952 tests/test_notes_reference_contracts_js.py +c2d877a11eafcb99b24a1c89111e5df4453a62bc8cf7d26ae1118b25c4a5ace4 tests/test_notes_search_reset_on_reopen_js.py +6d95f073421427b5e149ef1e85c8b5749095d00337f05960134552985f73e372 tests/test_notes_select_esc_listener_js.py +b3118a4b40fcfcbb26ad9ccbcb332fc6ced873d8cad1160be38a286ad1e664a4 tests/test_notes_sidebar_new_note.py diff --git a/docs/frontend-architecture/evidence/notes/native-desktop.png b/docs/frontend-architecture/evidence/notes/native-desktop.png new file mode 100644 index 000000000..fd30ced2a Binary files /dev/null and b/docs/frontend-architecture/evidence/notes/native-desktop.png differ diff --git a/docs/frontend-architecture/evidence/notes/native-mobile.png b/docs/frontend-architecture/evidence/notes/native-mobile.png new file mode 100644 index 000000000..fdede2e84 Binary files /dev/null and b/docs/frontend-architecture/evidence/notes/native-mobile.png differ diff --git a/docs/frontend-architecture/handbook.md b/docs/frontend-architecture/handbook.md new file mode 100644 index 000000000..ade3bec14 --- /dev/null +++ b/docs/frontend-architecture/handbook.md @@ -0,0 +1,231 @@ +# Odysseus frontend architecture handbook + +Status: F1 merged in PR #6701. The complete Notes reference is implemented as an +unpublished review diff on `refactor/notes-reference-decomposition`. Read its +[case study](notes-case-study.md), [module inventory](notes-module-inventory.md) +and [validation receipts](notes-validation.md). Publication and the recipient's +post-integration reproduction remain pending. + +Read the [ADR](ADR-001-source-and-deployment.md), [assessment](assessment.md), +[validation report](validation.md) and [Notes roadmap](notes-roadmap.md). +The frontend program ends with the Notes reference decomposition and handoff. +Calendar, Documents, Email, Chat, Skills, Settings and other later decompositions +belong to the designated frontend maintainer; do not start them automatically. + +## Source, ownership and public APIs + +`frontend/src/app/` composes public feature APIs and platform ports. Each +`features//index.js` is an intentional API, with explicit named exports +and a compatibility default only when existing consumers require one. Internals +live under `domain/`, `application/`, `infrastructure/` or `ui/` as needed. Do not +create empty directories, boilerplate controllers or a framework by convention. +Feature names `app` and `shared` are reserved. + +Keep pure business rules in domain; transitions and workflows in application; +HTTP/storage/browser services in infrastructure; DOM rendering and lifecycle in +UI. The index composes adapters, state and views. Application receives adapters +as explicit arguments or constructor ports, rather than importing concrete +infrastructure. Views call application operations; infrastructure does not own +business decisions. A public API returns data/operations, not mutable stores or +private DOM nodes. Importing an API should define capabilities; explicit lifecycle +methods activate them. Legacy side effects remain characterized until a reviewed +slice safely changes their placement. + +Shared code has a named, proven cross-feature responsibility. `shared/domain/` +holds pure primitives, `shared/platform/` holds narrowly scoped platform adapters, +and `shared/ui/` holds common DOM capabilities. A named stateless root leaf such +as `shared/actionMenuOrder.js` is also allowed and receives domain restrictions. +Do not create a central `utils.js` collection or move feature business rules into +shared simply to satisfy an import rule. + +## Mechanically checked dependency contract + +| Importer | Permitted maintained-source targets | +| --- | --- | +| App composition | Other app code, shared code, feature `index.js` APIs | +| Feature index | Its own responsibility modules and shared code; other feature public indexes | +| Feature domain | Its own domain and shared domain/stateless leaves | +| Feature application | Its own application/domain, shared domain, other feature public indexes | +| Feature infrastructure | Its own infrastructure/domain, shared domain/platform | +| Feature UI | Its own UI/application/domain, shared domain/platform/UI | +| Shared domain leaf | Shared domain leaves only | +| Shared platform | Shared platform/domain only | +| Shared UI | Shared UI/platform/domain only | + +No feature imports app composition. No shared code imports a feature. No external +feature imports private responsibility modules. UI and infrastructure communicate +with another feature through a supplied application operation/port. Maintained +source dependency cycles, including literal lazy imports, fail the architecture +gate. If features need bidirectional coordination, app composition injects a +callback or adapter instead of creating mutual imports. Prefer this even when a +one-way public index import would pass the gate. + +Cross-feature events must document event name, payload, acknowledgement, owner, +subscription disposal and delivery timing. Keep them local and specific; F1 adds +no global event bus. Preserve `odysseus:notes-context-selected` and its synchronous +`accepted` acknowledgement in future Notes work. The server remains authoritative +for owner filtering and untrusted selected IDs. Frontend APIs cannot grant access. + +The checker parses executable ESM, forbids computed imports in new source, +requires exact precache entries, rejects unresolved/remapped relative imports, +and permits only canonical static JS URLs with one unversioned identity for +maintained modules. Domain code cannot mention known browser/HTTP/timer globals +or an ambient clock; use `now`/clock inputs. This is a conservative vocabulary +check, including shadowed names, not a semantic purity proof. Randomness, hidden +side effects, authorization and lifecycle behavior require behavioral tests. + +Legacy imports from new source default to forbidden. If an incremental bridge is +necessary, declare `legacyImports` on that source's manifest entry as a list of +`{ "specifier": "/static/js/example.js?v=existing", "reason": "contract, owner and removal slice" }`. +Only feature composition, infrastructure or UI boundaries may use it. Exact +specifier/reason and target existence are required; unused exceptions fail. +Keep the existing query identity. New domain/application modules receive no +legacy exception. F1 has zero exceptions. Do not use exceptions to invent new +shared platform dependencies on feature internals. + +## State and lifecycle + +Each feature has one authoritative mutable application-state owner. A transition +operation owns writes; UI receives a view/snapshot or subscription. Preserve +existing objects and initialization timing when extracting a stateful module. +Do not instantiate a new store in both the legacy facade and the feature API. +Compatibility facades only forward the same live implementation. + +The Notes reference uses internal read-only live views to preserve captured note +identity across existing asynchronous completions. Its state owner supplies all +mutations; the public index exposes operations without the store. Notes deliberately +retains last-completion fetch ordering. Do not infer request-generation guards from +its decomposition. Its page-owned reminders survive panel closure, and panel/editor +listeners and observers have separate disposal scopes. + +Document lifecycle scopes: page, authenticated user, session, mounted panel and +editor/draft. Name owners of subscriptions, DOM listeners, timers, observers, +pending requests and caches. Mounting must be idempotent; disposal releases the +resources it owns. Background reminders may outlive an open panel. Closing the +panel must not silently stop those reminders during extraction. + +Keep request-generation and selected-session checks around asynchronous +completions. Serialize writes where the current contract requires it. Dispose +subscriptions explicitly and prevent late updates to a replaced view. Feature +state changes on user/session switch must follow current cleanup policy. Browser +storage is for preferences/drafts/cache state, never a substitute for backend +authorization, credential storage or owner filtering. Define every storage key's +owner, retention/reset behavior and compatibility format before moving it. + +## Creating a module or introducing a feature + +1. Identify responsibility, owner, public consumers, side effects and behavioral + evidence before editing. Start with a pure leaf when introducing a feature. +2. Place only the required source file(s), for example + `features/example/domain/calculate.js`. Add an index only when there is a + real supported feature API to compose. +3. Register each source in `frontend/modules.json`. A typical new mapping is + `features/example/domain/calculate.js` to + `js/features/example/domain/calculate.js`. Preserve the source/deployment + relative graph. The proof's legacy URL mapping is an explicit special case. +4. For an extracted leaf, the legacy feature coordinator may import its output + as a temporary composition bridge. New maintained features use public indexes; + the legacy tree is not globally forced into the new boundary rules in F1. +5. Add the exact output URL to the appropriate explicit SW array, retain lazy + evaluation timing and bump the cache name when the graph changes. Declare + `text eol=lf` for both mapped paths in repository-owned `.gitattributes` + files; source-tree or scoped output patterns are allowed. The publisher checks + effective Git rules, including nested overrides, and rejects a missing LF rule + even if output bytes already match. Do not broaden unrelated legacy checkout + rules or automatically rewrite all existing query identities. +6. Run the publisher, inspect the source/output diff, then run architecture, + identity, graph, offline and focused behavior checks. Include both sides of + the mapping in review. Retiring output requires a separate reviewed deletion; + the publisher does not clean the static tree. + +```sh +python scripts/build_frontend.py +python scripts/build_frontend.py --check +npm ci --ignore-scripts --no-audit --no-fund +node scripts/check_frontend.mjs +node scripts/check_frontend.mjs --inventory +node --test tests/frontendArchitecture.test.mjs +./venv/bin/python -m pytest -q -rs tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py tests/test_panel_loader_js.py +./venv/bin/python scripts/verify_frontend_runtime.py +``` + +Use your actual repository virtualenv path; the F1 environment is recorded in +validation. Node 20+ follows current CI. Existing Playwright/browser prerequisites +are documented in `tests/README.md` and `tests/KNOWN_FAILURES.md`. Missing Node can +skip Python browser wrappers, which is a coverage gap, not a pass. The standalone +Node gates require locked development dependencies and fail when missing. + +The publisher validates all inputs before writing and only overwrites owned +outputs whose bytes differ. It is idempotent and working-directory independent. +Git must be available for publication and `--check`; neither runs at application +startup. Attribute evaluation uses a private temporary Git repository and ignores +global/local overrides, so checkout consistency is reproducible in CI. It adds +no runtime configuration or backend imports. CI checks LF attributes, parity and source +syntax in existing jobs; the Node job also runs architecture self-tests. Existing +runtime identity tests remain authoritative across legacy imports, HTML and SW. +The isolated runtime probe is included in the new pytest publication tests. + +## Review, release and rollback + +Review the source/output mapping and exact exported API, then the owner/dependency +contract and behavior tests. Check manifests, URL identities, MIME/CSP, load timing, +storage compatibility and disposal. Reject unexplained feature behavior changes. +Do not repair unrelated defects under an extraction title. + +Release from a validated revision with committed output. Native startup and +packagers consume static files without a compiler. Run parity before producing a +Docker or desktop artifact. Test the actual packaged application on its platform; +Linux contract probes cannot certify macOS/Windows executables. Deploy matching +shell, facades, graph and manifests as a unit. Keep an authorized path to the last +complete artifact. Rollback restores the whole matching set, not just an entry +file or cache version. Do not delete a facade until its public consumers have +migrated and compatibility tests make that decision reviewable. + +## Proposal and PR templates + +For a decomposition proposal, record: baseline; responsibility map; current public +API and consumers; state owner; I/O and lifecycle boundaries; planned slice; +allowed dependencies/temporary exceptions; preserved behavior; deterministic +characterization tests; deployment/offline changes; overlapping PRs; rollback; +explicit non-goals and remaining uncertainties. + +For each migration PR, lead with the responsibility moved and resulting boundary. +Record source/output paths, API compatibility, state/lifecycle ownership, +characterization results, exact validation commands, platform gaps, exception +removal plan, upstream dependencies and rollback. Attach a concrete before/after +example when needed to establish behavioral equivalence. Preserve the repository +PR template and never claim unexecuted checks. + +F1 is an entirely separate foundation-only PR. Deliver the complete Notes +decomposition, regression tests, compatibility validation and final reference +case study through one draft PR targeting official `dev`, using one Notes branch +and worktree after F1 integration. N0 through N6 and the N5 substages are internal +engineering checkpoints, not independent PRs or merges. Each checkpoint records +focused tests, review evidence, its validated revision or reproducible snapshot, +and rollback to the prior matching source/output/facade/precache set. Keep the +draft open while those checkpoints progress; mark it ready only after the entire +decomposition passes the [roadmap's acceptance gates](notes-roadmap.md). + +Structure the Notes PR description around the final integrated design and link +each checkpoint's evidence. The receiving maintainer may use smaller independent +PRs for future frontend features at their discretion; the one-PR Notes decision +does not impose that delivery size on later work. + +## Program backlog and final handoff + +Priority order: review/integrate F1; resolve root offline certification with the +owner of PR #6227; complete the Notes checkpoints and final reference case study +within one draft PR following the [roadmap](notes-roadmap.md); integrate the +complete Notes PR after its acceptance gates; finalize the recipient handoff. +Only then does the receiving maintainer prioritize Calendar, Documents, Email, +Chat, Skills and Settings against current behavior and risk. Existing partial +packages and high fan-in helpers need assessment, not automatic replacement. + +Final H acceptance requires a designated maintainer, the integrated Notes PR +revision and its internal checkpoint receipts, the actual public API/state/lifecycle +map and final reference case study included in that PR, validated deployment/offline +commands, known exceptions/debt, +outstanding PR dependencies, rollback evidence and a reproducible independent +setup by the recipient. F1 provides the handbook structure and contracts; it does +not claim Notes or the completed handoff have occurred. The initiating architect +then returns to separately authorized non-frontend architecture work. diff --git a/docs/frontend-architecture/notes-case-study.md b/docs/frontend-architecture/notes-case-study.md new file mode 100644 index 000000000..cc1a28d7e --- /dev/null +++ b/docs/frontend-architecture/notes-case-study.md @@ -0,0 +1,260 @@ +# Notes reference decomposition + +Implementation started: 2026-10-13. Final validation: 2026-10-14. +Foundation: [PR #6701](https://github.com/odysseus-dev/odysseus/pull/6701), +`1cc56cb9d78f3e2399d1d61155b71d771a17b85b`. Branch was safely updated via +non-destructive fast-forward to latest official `upstream/dev` at +`86b886c611338282af6d4a717246ea4e256f58df`, incorporating PRs #6704 and #6693. +Neither changed a file in the Notes diff. The independent AGY audit returned +CONDITIONAL PASS; all pre-publication corrective actions (issue template conformance, +PR template conformance, visual evidence paths, and em-dash removal) have been completed. + +The entire implementation belongs to one Notes PR. N0 through N6 are internal +checkpoints, not separate branches, commits or PRs. The [validation receipts](notes-validation.md), +[module inventory](notes-module-inventory.md) and [changed-file inventory](notes-changed-files.md) +accompany this case study. + +## Responsibility map and size + +The original `static/js/notes.js` contained 5,471 lines. It mixed mutable Notes +data, view preferences, selection, undo, CRUD, date calculations, reminders, +browser persistence, editor autosave, rendering, canvas tools and window management. +Its import evaluated storage preferences and scheduled reminder initialization. + +| Original responsibility | Maintained owner | +| --- | --- | +| Completion, tags, recurrence, date labels and serialization | `domain/recurrence.js`, `domain/noteRules.js` | +| Notes data and selection/filter/editing state | `application/state.js`, instantiated once in `index.js` | +| CRUD, undo and fetch completion ordering | `application/workflows.js`, `cardOperations.js`, `deletion.js` | +| Checklist mutations and rollback | `application/checklistOperations.js` | +| Editor optimistic submission and recovery | `application/submission.js` | +| Serialized autosave and synchronous backup | `application/drafts.js`; form collection in `ui/draftEditor.js` | +| Filtering, searching and ordering | `application/queries.js`; controls in `ui/filters.js` and `toolbar.js` | +| Selected-note attachment and bulk requests | `application/selection.js`; controls in `ui/selection.js` | +| Existing agent-solve workflow | `application/agentActions.js`, through supplied HTTP/session ports | +| HTTP, persistence and browser notifications | `infrastructure/http.js`, `storage.js`, `notifications.js` | +| Background scheduler and delivery decisions | `application/reminders.js` | +| Panel mounting, closing, keyboard handling and navigation | `ui/panel.js` | +| Docking, fullscreen bounds and stacking | `ui/windowLayout.js`, using existing shared capabilities | +| Cards, card gestures, quick add and masonry | `ui/cardRendering.js`, `cardInteractions.js`, `cardChecklist.js`, `cardReorder.js`, `quickAdd.js`, `layout.js` | +| Editor types, colors, reminders and checklists | `ui/editor.js`, `colors.js`, `reminderPicker.js`, `checklist.js` | +| Drawing, local drawing undo and PNG upload | `ui/drawing.js` | +| Mobile reader/editor and reorder gestures | `ui/mobileEditor.js`, `mobileReorder.js`, `checklistReorder.js` | +| Clipboard, safe rendering and reminder display | `ui/clipboard.js`, `text.js`, `reminderDisplay.js` | +| Owned document/window listener disposal | `ui/lifecycle.js`, with panel and observer disposal callbacks | +| Composition and supported exports | `index.js`; five-line legacy facade at `static/js/notes.js` | + +The feature has 41 maintained modules: two domain, eleven application, three +infrastructure, twenty-four UI and one index. Source is counted once, excluding +byte-identical deployment copies. Domain has 356 lines, application 826, +infrastructure 112, UI 4,628, and composition 518: 6,440 maintained feature lines. +The module inventory records final per-file counts. The legacy entry shrank +from 5,471 to five lines. Interfaces and explicit composition increase the total +source size; reducing total lines was not the architectural objective. + +One 28-line stateless confetti renderer also moved to `shared/ui/confetti.js`. +Notes and Compare both use it. Compare retains its existing `spawnConfetti` export, +which is the same function as the shared export. This removes Notes' import of +Compare's private voting implementation without migrating Compare itself. + +## Dependency graph and composition + +```mermaid +flowchart TD + Legacy[Legacy Notes URL and window bridge] --> Index[Notes index: one composition] + Index --> UI[Notes UI factories] + Index --> App[Application workflows and state owner] + Index --> Ports[HTTP, persistence and notification adapters] + UI --> Rules[Pure domain rules] + App --> Rules + Index --> Shared[Existing shared UI capabilities and maintained confetti] +``` + +Application modules receive adapters and feedback operations as arguments. They +do not import browser infrastructure or DOM views. Pure domain modules receive +the clock at the original read sites. UI consumes a read-only state view and +application operations. Composition supplies callbacks between views and workflows; +modules do not import the coordinator back. F1 rejects maintained-source cycles. + +There are nine exact legacy-import exceptions, all on the Notes index. They bridge +the existing UI, modal, color-picker, drag, tile, dock, stacking, Escape and tour-hint +capabilities at their existing URLs. Their removal condition is migration of the +corresponding shared responsibility to maintained source with its identity and +lifecycle contract verified. No new feature imports another feature's private internals. + +## State and asynchronous ownership + +`createNotesState` is called once. It owns the Notes collection, selection IDs, +editing ID, search, filters, archive/view flags, loading and dismissed-reminder time. +Neither the facade nor reminders create a second collection. The public feature API +does not expose this store. + +Internal views are read-only live views rather than detached snapshots. This choice +preserves captured note identity: an existing asynchronous rollback still acts on +the same captured note. Array/object writes and selection mutations must use the +state owner's operations. Workflow-local undo queues and serialized draft promises, +and UI-local canvas undo/gesture state, are not competing Notes stores. + +Fetches retain the original last-completion behavior. An earlier request that +finishes later can overwrite a later request's result. Tests pin this behavior; +generation guards would be a separate behavioral change. Submission also retains +the original first-temporary-note replacement rule. Multiple simultaneous creations +need a separately reviewed correlation fix, not a silent change during extraction. + +Archive, unarchive, pin, item edit/remove/add, delete and undo preserve their original +optimistic ordering and rollback. Animation callbacks remain view-owned. Drawing +save keeps the existing button guard around its asynchronous PNG upload. Draft writes +are serialized per editor; a completed older save cannot clear a newer local draft. + +Reminder scans still use the originally loaded Notes collection, not a second +all-active cache. An archived-only collection or a failure-cleared list can defer +active reminders until an active refresh/reopen. The timer remains alive when the +panel closes, but extraction does not redesign this inherited data-availability +limitation. Review such a change separately from scheduler/state ownership. + +## Public compatibility + +The import URL remains `/static/js/notes.js?v=20260911notesselectioncancel1`. +App, Chat stream updates, Chat note links and Documents continue using their existing +imports. Calendar and Documents retain their window bridge calls. HTML and app +bootstrap code are unchanged. The service worker retains the exact legacy entry. + +The legacy namespace still exports `openPanel`, `closePanel`, `togglePanel`, +`isPanelOpen`, `openNote`, `newNote`, `refreshDueBadge`, `dismissFiredReminderDot`, +`openNotes`, `closeNotes`, `isNotesOpen`, and `default`. +The default object's ten methods are unchanged; `dismissFiredReminderDot` remains +named-only. Aliases refer to the same functions, including those on the default +object. `openNote` and `refreshDueBadge` remain async; function arities are pinned. + +`window.notesModule` is exactly the default controller. The canonical unversioned +feature index and both tested legacy entry URLs share the same controller and +operations. The default object retains its existing mutability. A different legacy +URL has its own ESM namespace, as native ESM requires, but creates no second store +or scheduler because it forwards to the same canonical index. + +The canonical index additionally exports `startNotesBackground` for explicit +activation. The compatibility facade invokes it once through an idempotent start +operation. Existing consumers need no change. Remove the facade only after every +consumer, global bridge, bootstrap and offline reference has migrated together. + +## Protected selected Notes/Todos integration + +The sender dispatches `odysseus:notes-context-selected` on `window` with +`{ noteIds, accepted: false }`. Dispatch and acknowledgement remain synchronous. +Chat owns acceptance, its session/pending selection, the context indicator and +the `selected_note_ids` send payload. Notes exits selection only after acceptance. +Rejected selections stay selected and display the existing feedback. + +No Chat internals were imported or changed. Existing session lifecycle events still +transfer a pending selection on materialization, restore selections on chat switches, +and reset them for a new chat. Backend ID validation and owner filtering remain +authoritative. Unit/backend tests cover these contracts; the real authenticated +browser probe verifies rejection, pending-chat acceptance, the actual indicator and +new-chat reset. Live model execution is not part of that probe. + +## Persistence and HTTP contracts + +| Key | Preserved format | +| --- | --- | +| `odysseus-notes-view` | `list` or `grid` | +| `odysseus-notes-labels-collapsed` | `1` or `0` | +| `odysseus-notes-first-open-hint-v1` | Existing first-open marker | +| `odysseus-notes-reminder-fired` | JSON array of IDs | +| `odysseus-notes-reminder-glowed` | JSON array of IDs | +| `odysseus-notes-reminder-pending-highlight` | JSON array of IDs | +| `odysseus-notes-reminder-active-highlight` | JSON array of IDs | +| `odysseus-notes-reminder-dismissed-at` | Decimal millisecond timestamp | +| `odysseus-note-draft-` and `odysseus-note-draft-__new__` | Existing JSON draft fields and `_ts` | + +CRUD, reorder, upload, reminder firing, default-chat lookup, session creation and +chat-stream requests keep their URLs, methods, credentials, payloads and error +interpretation. Uploads retain `files` and optional `gallery_id` multipart fields. +The agent-solve stream still disables `manage_notes` and drains without switching +the user's chat. No backend implementation changed. + +Storage backup failures still warn once; failed network saves keep recoverable +drafts, including intentionally empty text. Draft JSON does not serialize a canvas +bitmap. A drawing remains ephemeral until its explicit PNG upload, as before. + +## Lifecycle scopes and explicit corrections + +| Scope | Owner and disposal | +| --- | --- | +| Browser page | One reminder startup timeout and interval; page termination ends them. The reminder factory also has explicit disposal. | +| Open panel | Capture/bubble keyboard handlers, reorder listeners and masonry observer; both close paths dispose them. | +| Editor/canvas | Local DOM handlers and draft workflow; drawing's window listener is released when its canvas detaches or the panel closes. | +| Mobile editor/gesture | Overlay and document touch handlers; owner replacement and panel disposal remove handlers and reset registration flags. | +| Menus/hints | Notes portals close with the panel; shared dismiss functions unregister their Escape/tour lifetimes. | +| File picker | Existing completion or 30-second cleanup owns the temporary input. | + +Two implementation corrections follow the user's explicit lifecycle requirements. +The original close paths canceled the supposedly background reminder interval. +The new page-owned interval survives closing/minimizing Notes, while retaining the +3-second startup delay, 30-second period and immediate first check. Closing a panel +also releases owned observers/listeners, pending hold timers, active Notes gestures +and portals; late panel fetch callbacks do +not recreate UI on a detached panel. Network completion/state ordering is preserved. + +Shared drag/window utilities continue owning their own active-gesture handlers. +They were not moved into Notes. Idle open/close and editor replacement are verified; +every possible interleaving with an active shared window drag was not tested. + +## Source and deployment + +Every maintained source is registered in `frontend/modules.json` and copied without +rewriting imports. Notes sources map to `static/js/features/notes/` at the same +relative paths. Shared confetti maps to `static/js/shared/ui/confetti.js`. +Repository-owned LF attributes and byte parity cover all 43 maintained modules, +including the unchanged F1 proof. The legacy facade is an authored compatibility +entry, not a second implementation. + +All new outputs join exact service-worker precache arrays. A real offline test also +found eight previously missing exact legacy URLs in the Notes graph; those entries +are now included. The eager Notes graph has 57 exact runtime URLs. Cache naming +follows the repository's `odysseus-v659-notes-reference` convention. Root offline +reload remains outside certification because the existing worker scope is `/static/`. + +Python startup and Docker serve the committed static graph without maintained +source or frontend tooling. The publisher is a development check/copy operation, +not a runtime compiler or an atomic deployment system. + +## Checkpoints, rollback and lessons + +[Checkpoint receipts](notes-validation.md#internal-checkpoints) record the actual +checks and local snapshot hashes. These intermediate snapshots are engineering +evidence, not standalone release artifacts. N5 includes bounded editor, cards, +checklist, drawing, mobile, toolbar and window reviews on the same branch. + +The N3 mechanical state rewrite initially confused canvas-local and feature undo +bindings. The N5 drawing test found it; the canvas binding was restored and the +same drawing/undo interaction was replayed against the original implementation. +Use scope-aware transformations and test each stateful UI responsibility before +accepting a broad rewrite. Source parsing alone does not prove semantic equivalence. + +For rollback, reconstruct the prior complete artifact from its revision/snapshot +in a fresh temporary directory, run parity/graph/behavior checks there, then publish +the matching static graph and service worker as a unit through the authorized +release process. Restore the whole facade/source/output/manifest set, not just +`notes.js`. No schema or storage-key migration needs reversal. Do not reset a dirty +worktree or delete unrelated outputs. The validation report gives a non-destructive +snapshot reproduction recipe. + +For the next maintainer: + +1. Start with the module inventory and the state/workflow contracts, then reproduce + the focused tests, offline test and isolated native browser probe. +2. Review the explicit timer/lifecycle corrections separately from unchanged + business rules. Inspect both maintained source and byte-identical deployment. +3. Resolve or track the reproduced upstream CI failures and the open PR overlaps + before authorizing publication. Create/link the dedicated issue, then publish + exactly one Notes PR targeting official `dev`. +4. Record the integrated revision and independent recipient reproduction to complete + H. The designated maintainer can then apply this method to future features. + +For Calendar, Documents, Email and later monoliths, characterize actual exports, +URL identities, state writers, clock reads, I/O and lifecycle first. Extract pure +rules, then adapters, then a single state owner/workflows. Use explicit callback +ports to break coordinator cycles. Split UI by interactions and resource lifetime, +not by arbitrary line ranges. Transfer source/output ownership and offline URLs +with each boundary, and preserve source-sensitive assertions at their new owner. +Those feature migrations are not part of this implementation. diff --git a/docs/frontend-architecture/notes-changed-files.md b/docs/frontend-architecture/notes-changed-files.md new file mode 100644 index 000000000..02f916e46 --- /dev/null +++ b/docs/frontend-architecture/notes-changed-files.md @@ -0,0 +1,165 @@ +# Notes complete changed-file inventory + +Changed files in PR #6713 (`refactor/notes-reference-decomposition`) against +`dev`. 121 files: 16 modified and 105 added. No files are deleted. Deployment copies +are byte-identical to their registered maintained sources. Image files are review +evidence, not production dependencies. + +The unchanged foundation worktree, backend, app bootstrap, HTML, CSS, dependency +lockfiles, Dockerfile and CI workflow are outside this diff. The only Compare +change delegates its existing stateless confetti export to maintained shared UI. + +## Maintained source (42) + +| Status | File | +| --- | --- | +| A | [frontend/src/features/notes/application/agentActions.js](../../frontend/src/features/notes/application/agentActions.js) | +| A | [frontend/src/features/notes/application/cardOperations.js](../../frontend/src/features/notes/application/cardOperations.js) | +| A | [frontend/src/features/notes/application/checklistOperations.js](../../frontend/src/features/notes/application/checklistOperations.js) | +| A | [frontend/src/features/notes/application/deletion.js](../../frontend/src/features/notes/application/deletion.js) | +| A | [frontend/src/features/notes/application/drafts.js](../../frontend/src/features/notes/application/drafts.js) | +| A | [frontend/src/features/notes/application/queries.js](../../frontend/src/features/notes/application/queries.js) | +| A | [frontend/src/features/notes/application/reminders.js](../../frontend/src/features/notes/application/reminders.js) | +| A | [frontend/src/features/notes/application/selection.js](../../frontend/src/features/notes/application/selection.js) | +| A | [frontend/src/features/notes/application/state.js](../../frontend/src/features/notes/application/state.js) | +| A | [frontend/src/features/notes/application/submission.js](../../frontend/src/features/notes/application/submission.js) | +| A | [frontend/src/features/notes/application/workflows.js](../../frontend/src/features/notes/application/workflows.js) | +| A | [frontend/src/features/notes/domain/noteRules.js](../../frontend/src/features/notes/domain/noteRules.js) | +| A | [frontend/src/features/notes/domain/recurrence.js](../../frontend/src/features/notes/domain/recurrence.js) | +| A | [frontend/src/features/notes/index.js](../../frontend/src/features/notes/index.js) | +| A | [frontend/src/features/notes/infrastructure/http.js](../../frontend/src/features/notes/infrastructure/http.js) | +| A | [frontend/src/features/notes/infrastructure/notifications.js](../../frontend/src/features/notes/infrastructure/notifications.js) | +| A | [frontend/src/features/notes/infrastructure/storage.js](../../frontend/src/features/notes/infrastructure/storage.js) | +| A | [frontend/src/features/notes/ui/cardChecklist.js](../../frontend/src/features/notes/ui/cardChecklist.js) | +| A | [frontend/src/features/notes/ui/cardInteractions.js](../../frontend/src/features/notes/ui/cardInteractions.js) | +| A | [frontend/src/features/notes/ui/cardRendering.js](../../frontend/src/features/notes/ui/cardRendering.js) | +| A | [frontend/src/features/notes/ui/cardReorder.js](../../frontend/src/features/notes/ui/cardReorder.js) | +| A | [frontend/src/features/notes/ui/checklist.js](../../frontend/src/features/notes/ui/checklist.js) | +| A | [frontend/src/features/notes/ui/checklistReorder.js](../../frontend/src/features/notes/ui/checklistReorder.js) | +| A | [frontend/src/features/notes/ui/clipboard.js](../../frontend/src/features/notes/ui/clipboard.js) | +| A | [frontend/src/features/notes/ui/colors.js](../../frontend/src/features/notes/ui/colors.js) | +| A | [frontend/src/features/notes/ui/draftEditor.js](../../frontend/src/features/notes/ui/draftEditor.js) | +| A | [frontend/src/features/notes/ui/drawing.js](../../frontend/src/features/notes/ui/drawing.js) | +| A | [frontend/src/features/notes/ui/editor.js](../../frontend/src/features/notes/ui/editor.js) | +| A | [frontend/src/features/notes/ui/filters.js](../../frontend/src/features/notes/ui/filters.js) | +| A | [frontend/src/features/notes/ui/layout.js](../../frontend/src/features/notes/ui/layout.js) | +| A | [frontend/src/features/notes/ui/lifecycle.js](../../frontend/src/features/notes/ui/lifecycle.js) | +| A | [frontend/src/features/notes/ui/mobileEditor.js](../../frontend/src/features/notes/ui/mobileEditor.js) | +| A | [frontend/src/features/notes/ui/mobileReorder.js](../../frontend/src/features/notes/ui/mobileReorder.js) | +| A | [frontend/src/features/notes/ui/panel.js](../../frontend/src/features/notes/ui/panel.js) | +| A | [frontend/src/features/notes/ui/quickAdd.js](../../frontend/src/features/notes/ui/quickAdd.js) | +| A | [frontend/src/features/notes/ui/reminderDisplay.js](../../frontend/src/features/notes/ui/reminderDisplay.js) | +| A | [frontend/src/features/notes/ui/reminderPicker.js](../../frontend/src/features/notes/ui/reminderPicker.js) | +| A | [frontend/src/features/notes/ui/selection.js](../../frontend/src/features/notes/ui/selection.js) | +| A | [frontend/src/features/notes/ui/text.js](../../frontend/src/features/notes/ui/text.js) | +| A | [frontend/src/features/notes/ui/toolbar.js](../../frontend/src/features/notes/ui/toolbar.js) | +| A | [frontend/src/features/notes/ui/windowLayout.js](../../frontend/src/features/notes/ui/windowLayout.js) | +| A | [frontend/src/shared/ui/confetti.js](../../frontend/src/shared/ui/confetti.js) | + +## Deployment copies (42) + +| Status | File | +| --- | --- | +| A | [static/js/features/notes/application/agentActions.js](../../static/js/features/notes/application/agentActions.js) | +| A | [static/js/features/notes/application/cardOperations.js](../../static/js/features/notes/application/cardOperations.js) | +| A | [static/js/features/notes/application/checklistOperations.js](../../static/js/features/notes/application/checklistOperations.js) | +| A | [static/js/features/notes/application/deletion.js](../../static/js/features/notes/application/deletion.js) | +| A | [static/js/features/notes/application/drafts.js](../../static/js/features/notes/application/drafts.js) | +| A | [static/js/features/notes/application/queries.js](../../static/js/features/notes/application/queries.js) | +| A | [static/js/features/notes/application/reminders.js](../../static/js/features/notes/application/reminders.js) | +| A | [static/js/features/notes/application/selection.js](../../static/js/features/notes/application/selection.js) | +| A | [static/js/features/notes/application/state.js](../../static/js/features/notes/application/state.js) | +| A | [static/js/features/notes/application/submission.js](../../static/js/features/notes/application/submission.js) | +| A | [static/js/features/notes/application/workflows.js](../../static/js/features/notes/application/workflows.js) | +| A | [static/js/features/notes/domain/noteRules.js](../../static/js/features/notes/domain/noteRules.js) | +| A | [static/js/features/notes/domain/recurrence.js](../../static/js/features/notes/domain/recurrence.js) | +| A | [static/js/features/notes/index.js](../../static/js/features/notes/index.js) | +| A | [static/js/features/notes/infrastructure/http.js](../../static/js/features/notes/infrastructure/http.js) | +| A | [static/js/features/notes/infrastructure/notifications.js](../../static/js/features/notes/infrastructure/notifications.js) | +| A | [static/js/features/notes/infrastructure/storage.js](../../static/js/features/notes/infrastructure/storage.js) | +| A | [static/js/features/notes/ui/cardChecklist.js](../../static/js/features/notes/ui/cardChecklist.js) | +| A | [static/js/features/notes/ui/cardInteractions.js](../../static/js/features/notes/ui/cardInteractions.js) | +| A | [static/js/features/notes/ui/cardRendering.js](../../static/js/features/notes/ui/cardRendering.js) | +| A | [static/js/features/notes/ui/cardReorder.js](../../static/js/features/notes/ui/cardReorder.js) | +| A | [static/js/features/notes/ui/checklist.js](../../static/js/features/notes/ui/checklist.js) | +| A | [static/js/features/notes/ui/checklistReorder.js](../../static/js/features/notes/ui/checklistReorder.js) | +| A | [static/js/features/notes/ui/clipboard.js](../../static/js/features/notes/ui/clipboard.js) | +| A | [static/js/features/notes/ui/colors.js](../../static/js/features/notes/ui/colors.js) | +| A | [static/js/features/notes/ui/draftEditor.js](../../static/js/features/notes/ui/draftEditor.js) | +| A | [static/js/features/notes/ui/drawing.js](../../static/js/features/notes/ui/drawing.js) | +| A | [static/js/features/notes/ui/editor.js](../../static/js/features/notes/ui/editor.js) | +| A | [static/js/features/notes/ui/filters.js](../../static/js/features/notes/ui/filters.js) | +| A | [static/js/features/notes/ui/layout.js](../../static/js/features/notes/ui/layout.js) | +| A | [static/js/features/notes/ui/lifecycle.js](../../static/js/features/notes/ui/lifecycle.js) | +| A | [static/js/features/notes/ui/mobileEditor.js](../../static/js/features/notes/ui/mobileEditor.js) | +| A | [static/js/features/notes/ui/mobileReorder.js](../../static/js/features/notes/ui/mobileReorder.js) | +| A | [static/js/features/notes/ui/panel.js](../../static/js/features/notes/ui/panel.js) | +| A | [static/js/features/notes/ui/quickAdd.js](../../static/js/features/notes/ui/quickAdd.js) | +| A | [static/js/features/notes/ui/reminderDisplay.js](../../static/js/features/notes/ui/reminderDisplay.js) | +| A | [static/js/features/notes/ui/reminderPicker.js](../../static/js/features/notes/ui/reminderPicker.js) | +| A | [static/js/features/notes/ui/selection.js](../../static/js/features/notes/ui/selection.js) | +| A | [static/js/features/notes/ui/text.js](../../static/js/features/notes/ui/text.js) | +| A | [static/js/features/notes/ui/toolbar.js](../../static/js/features/notes/ui/toolbar.js) | +| A | [static/js/features/notes/ui/windowLayout.js](../../static/js/features/notes/ui/windowLayout.js) | +| A | [static/js/shared/ui/confetti.js](../../static/js/shared/ui/confetti.js) | + +## Compatibility and deployment configuration (5) + +| Status | File | +| --- | --- | +| M | [.gitattributes](../../.gitattributes) | +| M | [frontend/modules.json](../../frontend/modules.json) | +| M | [static/js/compare/vote.js](../../static/js/compare/vote.js) | +| M | [static/js/notes.js](../../static/js/notes.js) | +| M | [static/sw.js](../../static/sw.js) | + +## Tests (21) + +| Status | File | +| --- | --- | +| A | [tests/browser/notesNative.mjs](../../tests/browser/notesNative.mjs) | +| A | [tests/browser/notesOffline.mjs](../../tests/browser/notesOffline.mjs) | +| A | [tests/browser/notesReference.mjs](../../tests/browser/notesReference.mjs) | +| A | [tests/fixtures/notesRecurrenceBaseline.mjs](../../tests/fixtures/notesRecurrenceBaseline.mjs) | +| A | [tests/helpers/notes_source.py](../../tests/helpers/notes_source.py) | +| A | [tests/notesApplication.test.mjs](../../tests/notesApplication.test.mjs) | +| A | [tests/notesCharacterization.test.mjs](../../tests/notesCharacterization.test.mjs) | +| M | [tests/notesDraftAutosave.test.mjs](../../tests/notesDraftAutosave.test.mjs) | +| A | [tests/notesInfrastructure.test.mjs](../../tests/notesInfrastructure.test.mjs) | +| A | [tests/notesRecurrence.test.mjs](../../tests/notesRecurrence.test.mjs) | +| A | [tests/notesReminders.test.mjs](../../tests/notesReminders.test.mjs) | +| A | [tests/notesWorkflows.test.mjs](../../tests/notesWorkflows.test.mjs) | +| M | [tests/selectedNotesContext.test.mjs](../../tests/selectedNotesContext.test.mjs) | +| M | [tests/test_notes_document_mutual_minimize.py](../../tests/test_notes_document_mutual_minimize.py) | +| M | [tests/test_notes_dom_xss_helpers.py](../../tests/test_notes_dom_xss_helpers.py) | +| M | [tests/test_notes_mobile_checklist_toggle_static.py](../../tests/test_notes_mobile_checklist_toggle_static.py) | +| A | [tests/test_notes_reference_browser.py](../../tests/test_notes_reference_browser.py) | +| A | [tests/test_notes_reference_contracts_js.py](../../tests/test_notes_reference_contracts_js.py) | +| M | [tests/test_notes_search_reset_on_reopen_js.py](../../tests/test_notes_search_reset_on_reopen_js.py) | +| M | [tests/test_notes_select_esc_listener_js.py](../../tests/test_notes_select_esc_listener_js.py) | +| M | [tests/test_notes_sidebar_new_note.py](../../tests/test_notes_sidebar_new_note.py) | + +## Runtime validation (2) + +| Status | File | +| --- | --- | +| M | [scripts/verify_frontend_runtime.py](../../scripts/verify_frontend_runtime.py) | +| A | [scripts/verify_notes_browser_runtime.py](../../scripts/verify_notes_browser_runtime.py) | + +## Documentation and execution receipts (9) + +| Status | File | +| --- | --- | +| A | [docs/frontend-architecture/evidence/notes/implementation.sha256](../../docs/frontend-architecture/evidence/notes/implementation.sha256) | +| A | [docs/frontend-architecture/evidence/notes/native-desktop.png](../../docs/frontend-architecture/evidence/notes/native-desktop.png) | +| A | [docs/frontend-architecture/evidence/notes/native-mobile.png](../../docs/frontend-architecture/evidence/notes/native-mobile.png) | +| M | [docs/frontend-architecture/handbook.md](../../docs/frontend-architecture/handbook.md) | +| A | [docs/frontend-architecture/notes-case-study.md](../../docs/frontend-architecture/notes-case-study.md) | +| A | [docs/frontend-architecture/notes-changed-files.md](../../docs/frontend-architecture/notes-changed-files.md) | +| A | [docs/frontend-architecture/notes-module-inventory.md](../../docs/frontend-architecture/notes-module-inventory.md) | +| M | [docs/frontend-architecture/notes-roadmap.md](../../docs/frontend-architecture/notes-roadmap.md) | +| A | [docs/frontend-architecture/notes-validation.md](../../docs/frontend-architecture/notes-validation.md) | + +See [the case study](notes-case-study.md) for ownership and handoff, +[the module inventory](notes-module-inventory.md) for line counts, and +[the validation report](notes-validation.md) for exact executed checks. diff --git a/docs/frontend-architecture/notes-module-inventory.md b/docs/frontend-architecture/notes-module-inventory.md new file mode 100644 index 000000000..5d8184858 --- /dev/null +++ b/docs/frontend-architecture/notes-module-inventory.md @@ -0,0 +1,56 @@ +# Notes source and deployment inventory + +The feature has 41 maintained modules. The unchanged F1 action-menu proof and +shared confetti bring the full manifest to 43. Outputs are copied byte-for-byte; +count maintained source once. The legacy entry is a five-line compatibility facade. + +| Source under frontend/src | Deployment under static | Lines | Layer | +| --- | --- | ---: | --- | +| [features/notes/application/agentActions.js](../../frontend/src/features/notes/application/agentActions.js) | [js/features/notes/application/agentActions.js](../../static/js/features/notes/application/agentActions.js) | 67 | application | +| [features/notes/application/cardOperations.js](../../frontend/src/features/notes/application/cardOperations.js) | [js/features/notes/application/cardOperations.js](../../static/js/features/notes/application/cardOperations.js) | 79 | application | +| [features/notes/application/checklistOperations.js](../../frontend/src/features/notes/application/checklistOperations.js) | [js/features/notes/application/checklistOperations.js](../../static/js/features/notes/application/checklistOperations.js) | 68 | application | +| [features/notes/application/deletion.js](../../frontend/src/features/notes/application/deletion.js) | [js/features/notes/application/deletion.js](../../static/js/features/notes/application/deletion.js) | 51 | application | +| [features/notes/application/drafts.js](../../frontend/src/features/notes/application/drafts.js) | [js/features/notes/application/drafts.js](../../static/js/features/notes/application/drafts.js) | 55 | application | +| [features/notes/application/queries.js](../../frontend/src/features/notes/application/queries.js) | [js/features/notes/application/queries.js](../../static/js/features/notes/application/queries.js) | 66 | application | +| [features/notes/application/reminders.js](../../frontend/src/features/notes/application/reminders.js) | [js/features/notes/application/reminders.js](../../static/js/features/notes/application/reminders.js) | 182 | application | +| [features/notes/application/selection.js](../../frontend/src/features/notes/application/selection.js) | [js/features/notes/application/selection.js](../../static/js/features/notes/application/selection.js) | 39 | application | +| [features/notes/application/state.js](../../frontend/src/features/notes/application/state.js) | [js/features/notes/application/state.js](../../static/js/features/notes/application/state.js) | 72 | application | +| [features/notes/application/submission.js](../../frontend/src/features/notes/application/submission.js) | [js/features/notes/application/submission.js](../../static/js/features/notes/application/submission.js) | 69 | application | +| [features/notes/application/workflows.js](../../frontend/src/features/notes/application/workflows.js) | [js/features/notes/application/workflows.js](../../static/js/features/notes/application/workflows.js) | 78 | application | +| [features/notes/domain/noteRules.js](../../frontend/src/features/notes/domain/noteRules.js) | [js/features/notes/domain/noteRules.js](../../static/js/features/notes/domain/noteRules.js) | 87 | domain | +| [features/notes/domain/recurrence.js](../../frontend/src/features/notes/domain/recurrence.js) | [js/features/notes/domain/recurrence.js](../../static/js/features/notes/domain/recurrence.js) | 269 | domain | +| [features/notes/index.js](../../frontend/src/features/notes/index.js) | [js/features/notes/index.js](../../static/js/features/notes/index.js) | 518 | composition | +| [features/notes/infrastructure/http.js](../../frontend/src/features/notes/infrastructure/http.js) | [js/features/notes/infrastructure/http.js](../../static/js/features/notes/infrastructure/http.js) | 29 | infrastructure | +| [features/notes/infrastructure/notifications.js](../../frontend/src/features/notes/infrastructure/notifications.js) | [js/features/notes/infrastructure/notifications.js](../../static/js/features/notes/infrastructure/notifications.js) | 20 | infrastructure | +| [features/notes/infrastructure/storage.js](../../frontend/src/features/notes/infrastructure/storage.js) | [js/features/notes/infrastructure/storage.js](../../static/js/features/notes/infrastructure/storage.js) | 63 | infrastructure | +| [features/notes/ui/cardChecklist.js](../../frontend/src/features/notes/ui/cardChecklist.js) | [js/features/notes/ui/cardChecklist.js](../../static/js/features/notes/ui/cardChecklist.js) | 148 | ui | +| [features/notes/ui/cardInteractions.js](../../frontend/src/features/notes/ui/cardInteractions.js) | [js/features/notes/ui/cardInteractions.js](../../static/js/features/notes/ui/cardInteractions.js) | 337 | ui | +| [features/notes/ui/cardRendering.js](../../frontend/src/features/notes/ui/cardRendering.js) | [js/features/notes/ui/cardRendering.js](../../static/js/features/notes/ui/cardRendering.js) | 244 | ui | +| [features/notes/ui/cardReorder.js](../../frontend/src/features/notes/ui/cardReorder.js) | [js/features/notes/ui/cardReorder.js](../../static/js/features/notes/ui/cardReorder.js) | 181 | ui | +| [features/notes/ui/checklist.js](../../frontend/src/features/notes/ui/checklist.js) | [js/features/notes/ui/checklist.js](../../static/js/features/notes/ui/checklist.js) | 190 | ui | +| [features/notes/ui/checklistReorder.js](../../frontend/src/features/notes/ui/checklistReorder.js) | [js/features/notes/ui/checklistReorder.js](../../static/js/features/notes/ui/checklistReorder.js) | 111 | ui | +| [features/notes/ui/clipboard.js](../../frontend/src/features/notes/ui/clipboard.js) | [js/features/notes/ui/clipboard.js](../../static/js/features/notes/ui/clipboard.js) | 82 | ui | +| [features/notes/ui/colors.js](../../frontend/src/features/notes/ui/colors.js) | [js/features/notes/ui/colors.js](../../static/js/features/notes/ui/colors.js) | 111 | ui | +| [features/notes/ui/draftEditor.js](../../frontend/src/features/notes/ui/draftEditor.js) | [js/features/notes/ui/draftEditor.js](../../static/js/features/notes/ui/draftEditor.js) | 67 | ui | +| [features/notes/ui/drawing.js](../../frontend/src/features/notes/ui/drawing.js) | [js/features/notes/ui/drawing.js](../../static/js/features/notes/ui/drawing.js) | 442 | ui | +| [features/notes/ui/editor.js](../../frontend/src/features/notes/ui/editor.js) | [js/features/notes/ui/editor.js](../../static/js/features/notes/ui/editor.js) | 518 | ui | +| [features/notes/ui/filters.js](../../frontend/src/features/notes/ui/filters.js) | [js/features/notes/ui/filters.js](../../static/js/features/notes/ui/filters.js) | 139 | ui | +| [features/notes/ui/layout.js](../../frontend/src/features/notes/ui/layout.js) | [js/features/notes/ui/layout.js](../../static/js/features/notes/ui/layout.js) | 120 | ui | +| [features/notes/ui/lifecycle.js](../../frontend/src/features/notes/ui/lifecycle.js) | [js/features/notes/ui/lifecycle.js](../../static/js/features/notes/ui/lifecycle.js) | 36 | ui | +| [features/notes/ui/mobileEditor.js](../../frontend/src/features/notes/ui/mobileEditor.js) | [js/features/notes/ui/mobileEditor.js](../../static/js/features/notes/ui/mobileEditor.js) | 257 | ui | +| [features/notes/ui/mobileReorder.js](../../frontend/src/features/notes/ui/mobileReorder.js) | [js/features/notes/ui/mobileReorder.js](../../static/js/features/notes/ui/mobileReorder.js) | 214 | ui | +| [features/notes/ui/panel.js](../../frontend/src/features/notes/ui/panel.js) | [js/features/notes/ui/panel.js](../../static/js/features/notes/ui/panel.js) | 506 | ui | +| [features/notes/ui/quickAdd.js](../../frontend/src/features/notes/ui/quickAdd.js) | [js/features/notes/ui/quickAdd.js](../../static/js/features/notes/ui/quickAdd.js) | 170 | ui | +| [features/notes/ui/reminderDisplay.js](../../frontend/src/features/notes/ui/reminderDisplay.js) | [js/features/notes/ui/reminderDisplay.js](../../static/js/features/notes/ui/reminderDisplay.js) | 129 | ui | +| [features/notes/ui/reminderPicker.js](../../frontend/src/features/notes/ui/reminderPicker.js) | [js/features/notes/ui/reminderPicker.js](../../static/js/features/notes/ui/reminderPicker.js) | 334 | ui | +| [features/notes/ui/selection.js](../../frontend/src/features/notes/ui/selection.js) | [js/features/notes/ui/selection.js](../../static/js/features/notes/ui/selection.js) | 61 | ui | +| [features/notes/ui/text.js](../../frontend/src/features/notes/ui/text.js) | [js/features/notes/ui/text.js](../../static/js/features/notes/ui/text.js) | 39 | ui | +| [features/notes/ui/toolbar.js](../../frontend/src/features/notes/ui/toolbar.js) | [js/features/notes/ui/toolbar.js](../../static/js/features/notes/ui/toolbar.js) | 78 | ui | +| [features/notes/ui/windowLayout.js](../../frontend/src/features/notes/ui/windowLayout.js) | [js/features/notes/ui/windowLayout.js](../../static/js/features/notes/ui/windowLayout.js) | 114 | ui | +| [shared/ui/confetti.js](../../frontend/src/shared/ui/confetti.js) | [js/shared/ui/confetti.js](../../static/js/shared/ui/confetti.js) | 28 | shared UI | + +Maintained Notes feature total: 6440 lines. Original entry: 5,471 lines. +Legacy facade after decomposition: five lines. Shared confetti: 28 lines. + +See [the case study](notes-case-study.md) for responsibilities and public/state/lifecycle +contracts, and [the validation report](notes-validation.md) for executed evidence. diff --git a/docs/frontend-architecture/notes-roadmap.md b/docs/frontend-architecture/notes-roadmap.md new file mode 100644 index 000000000..522923dc2 --- /dev/null +++ b/docs/frontend-architecture/notes-roadmap.md @@ -0,0 +1,178 @@ +# Notes reference decomposition roadmap + +Status: N0 through N6 implemented as an unpublished Notes review diff after F1 +merged in PR #6701. Actual decisions, interfaces, module ownership, checkpoint +receipts and execution limits are in the [case study](notes-case-study.md) and +[validation report](notes-validation.md). The proposal below retains the original +responsibility map for comparison. Notes integration and the recipient's independent +H reproduction remain pending. +F1 remains an entirely separate foundation-only PR. The complete Notes +decomposition, regression tests, compatibility validation and final reference +case study belong to one comprehensive draft PR targeting official `dev`. + +## Verified current responsibility and compatibility map + +Historical assessment baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +The implementation starts from merged F1 at +`1cc56cb9d78f3e2399d1d61155b71d771a17b85b`. +The module is `static/js/notes.js`, about 5,470 lines, with browser identity +`/static/js/notes.js?v=20260911notesselectioncancel1`. Preserve that identity across +the app import, HTML, SW and lazy consumers until a separately reviewed migration +changes the contract everywhere. + +| Responsibility | Current evidence | Intended owner after its reviewed slice | +| --- | --- | --- | +| State and CRUD | `_notes`, selection/filter/editing/loading variables, `_undoStack`, `_fetchNotes`, `_saveNote`, `_patchNote`, `_archiveNoteById` | One application state owner and workflow operations; no second reminder/UI copy | +| Dates and recurrence | `_normalizeRepeat`, `_advanceRecurring`, `_nthWeekdayOfMonth`, `_lastWeekdayOfMonth`, `_toLocalDatetimeStr`, repeat formatting helpers | Pure domain calculations with explicit clock inputs | +| Network and persistence | Notes CRUD/reorder, upload, reminder firing, draft and reminder localStorage keys | Infrastructure adapters preserving request payloads, credentials and error contracts | +| Reminders | `_checkReminders`, `_startReminderLoop`, `_initReminders`, notification permission/display and due/fired badge calculations | Application reminder workflow plus injected clock/storage/notification/API ports | +| Draft autosave | `_draftKey`, `_collectFormDraft`, `_wireDraftAutosave`, `_applyDraftToNote`, `_commitOpenInPlaceEditor` | Draft workflow with editor view/persistence ports and serialized saves | +| UI | `openPanel`, `closePanel`, `_renderNotes`, forms/cards/checklists, masonry, color/photo/drawing, mobile swipe/reorder | Scoped UI modules with explicit mount/dispose and application operations | +| Agent context | `#notes-send-agent` handler, selected IDs, `odysseus:notes-context-selected` | Preserve event payload and synchronous acknowledgement; Chat owns attachment/session state | +| Agent solve action | `_agentSolveNote`: default-chat lookup, session creation and chat stream | Separate existing workflow; do not conflate with selected-note attachment | +| Compatibility coordinator | Default controller, named aliases, `window.notesModule`, delayed reminder startup | Stable legacy facade/composition bridge until consumers safely migrate | + +The default API currently includes `openPanel`, `closePanel`, `togglePanel`, +`isPanelOpen`, `openNote`, `newNote`, `openNotes`, `closeNotes`, `isNotesOpen` and +`refreshDueBadge`. Named exports also include `dismissFiredReminderDot` and the +panel/open aliases. Characterization must pin the complete actual namespace and +default object separately, including identity of aliases and the window bridge. + +Consumers include `static/app.js` sidebar/routes/badge polling, +`chatStream.js` note tool updates, `chatRenderer.js` note links and +`document.js` coordination. `static/index.html` and `static/sw.js` are entry/cache +contracts. The shared UI, confetti, modal, color picker, drag, tiling, docking, +z-order and Escape helpers are direct dependencies. Those legacy dependencies +remain in the coordinator until their ownership is reviewed, rather than moving +all UI coupling into domain modules. + +Import-time behavior includes window origin/storage reads and +`setTimeout(_initReminders, 3000)`. The successful bootstrap loads Notes and starts +a 30-second reminder interval. App composition separately refreshes the badge +every five minutes. Panel/editing listeners, draft timers, highlight animations, +ResizeObserver and drawing/touch handlers have shorter scopes. Record them before +extraction; do not stop background reminders merely because a panel closes. +Listener cleanup concerns require characterization, not an unrelated fix hidden +inside an extraction. + +Persistence includes `odysseus-notes-view`, label collapse and hint preferences, +fired/glowed/pending/active reminder markers, dismissed timestamp, and +`odysseus-note-draft-` including `__new__`. Current direct APIs include Notes +CRUD/reorder/fire-reminder and upload, plus default-chat/session/chat-stream for +the existing solve action. Backend owner isolation and permission handling stay +authoritative and are outside this frontend decomposition's implementation scope. + +## PR #6495 contract that every relevant slice preserves + +Notes sends `odysseus:notes-context-selected` with `{ noteIds, accepted: false }`. +Chat's synchronous listener accepts only a valid active or pending chat, updates +session/pending selection, sets `accepted`, and updates the context indicator. +Notes clears select mode only after acceptance. Session lifecycle events +`odysseus:session-changed` carry session/materialization information. Chat appends +selected IDs as `selected_note_ids` when sending; server parsing validates IDs, +filters by owner and treats the resulting prompt context as untrusted. + +Keep selection transfer to a materialized chat, reset on a new chat, rejection +when no chat/model can accept it, aliases and indicator behavior. Do not broaden +frontend selections into permissions or bypass the backend. See +`tests/selectedNotesContext.test.mjs`, `tests/test_selected_notes_context.py`, +`static/js/chat.js` selected-context listeners and send payload, and +`static/js/sessions.js` lifecycle event producers. + +## One Notes branch, worktree and draft PR + +After F1 integration and authorization to begin Notes, create one worktree and +branch, suggested name `refactor/notes-reference-decomposition`, from the latest +official `upstream/dev`. N0 through N6 are sequential internal engineering +checkpoints on that same branch. No independent GitHub PR is required for a +checkpoint or a UI substage. Maintain one draft PR targeting official `dev`, with +checkpoint evidence linked from its description. Keep it a draft until the +complete decomposition passes all acceptance gates below. + +| Checkpoint | Responsibility | Deliverable and exit evidence | +| --- | --- | --- | +| N0 | Characterization | Public API/consumer, responsibility, state, lifecycle and network map; deterministic behavior tests; PR-overlap refresh; no extraction | +| N1 | Recurrence domain | Only pure date/recurrence rules to `features/notes/domain/recurrence.js`; preserve old wrapper call contracts, time-read position and returned strings; new offline dependency registered | +| N2 | Infrastructure | HTTP and storage adapters with exact request/error/key contracts; retain state ownership and backend authorization | +| N3 | Application | Single state owner and CRUD/optimistic/undo workflows; UI/reminders call operations; no competing arrays or stores | +| N4 | Reminders | Timer/notification/reminder orchestration through explicit ports; characterize startup, catch-up, deduplication, badge and background behavior | +| N5 | UI substages | Bounded card/panel, editor/draft, checklist, drawing and mobile reviews within the same PR; explicit listener/observer disposal and the same application operations | +| N6 | Compatibility and reference case study | Reduce coordinator; full API/consumer/lifecycle/offline comparison; retain legacy facade unless removal is proven safe; complete the final reference case study in the same PR | + +Do not create the Notes worktree, branch or PR during F1. N5 remains several +bounded internal reviews rather than a second monolith move. Every checkpoint +and UI substage must have its own focused tests, review evidence, lessons, +exceptions and a rollback plan. Record the validated revision or reproducible +patch/snapshot, prior known-good checkpoint, exact test commands/results and +how to restore its matching source/output/facade/precache set while preserving +unrelated work. Checkpoint commits require the applicable maintainer authority; +no checkpoint or commit is created by this planning document. + +Advance only after the current checkpoint's required checks and review findings +are resolved. A failed checkpoint returns to its prior validated state or receives +a bounded correction on the same branch; it does not require a separate PR. +If upstream changes require updating the baseline, update the same Notes branch, +record that revision and rerun affected checkpoint and compatibility gates. + +## Complete Notes PR acceptance gates + +Before marking the single draft PR ready for review: + +- Complete N0 through N6, including all N5 substages, with their tests, review + receipts and reproducible checkpoint/rollback evidence. +- Pass the required full Notes regression and compatibility checks on the final + combined revision, including PR #6495 selection/context behavior, public APIs, + module identities, single state ownership, storage formats, timers and teardown. +- Pass source/output parity, LF attribute, architecture, import graph and exact + offline manifest gates; verify current bootstrap/lazy behavior and relevant + native/packaging contracts. Record platform limits and distinguish preserved + offline module behavior from separately certified root offline reload. +- Resolve required gate failures and review findings; document unrelated existing + failures or coverage gaps explicitly rather than bypassing required checks. +- Include the final reference case study in this PR: actual before/after APIs, + dependency/state/lifecycle maps, stage decisions and lessons, validation recipes, + temporary exceptions, known debt and tested rollback/compatibility procedures. + +These are readiness gates for the complete PR, not separate stage merges. +The receiving maintainer may choose smaller independent PRs for future frontend +features; this one-PR delivery decision applies to the Notes reference program. + +## N0 and N1 characterization requirements + +Pin recurrence normalization for legacy bare repeat values and explicit values; +none/unknown/malformed input; daily/weekly/monthly/yearly advancement; month ends, +leap years, nth/last weekdays including absent fifth occurrences; overdue catch-up +and the 5,000-step guard; future anchors; local serialization and preserved hours; +DST gaps/overlaps under explicit time zones. `_advanceRecurring` currently reads +`Date.now()` after its first step. Inject a clock without changing that ordering. +Record the existing behavior of invalid dates rather than inventing improvements. + +Characterize request error handling, optimistic rollback/undo, archive/delete, +selection/filter/reset, ordering, uploads and notification permission errors. +For drafts, preserve synchronous local backup before the 500 ms persistence +timer, intentional empty text, checklist type fallback, serialized writes, +failure recovery and refusal to clear a newer draft when an older save finishes. + +Use stub clocks/ports and temporary storage for unit behavior; browser tests for +public namespace evaluation, DOM lifecycle, selected context and actual offline +loading. Run real modules through their output paths. Do not rewrite source-range +tests wholesale; migrate each only with its responsible slice and equivalent +behavioral assertions. Existing source-sensitive Notes autosave tests currently +read `notes.js`, so extraction has an explicit test-maintenance dependency. + +Before a slice, refresh #6585/#6647/#6652 backend behavior and #6470/#6695 shared +UI/platform overlaps from the [assessment](assessment.md). Existing fixes may +change characterization inputs, but this program does not merge or silently +reimplement those PRs. The independent root-SW fix #6227 is required before a +claim of full SPA offline reload readiness. + +## H completion and stopping rule + +The final reference case study is completed inside the Notes PR before it becomes +ready for review. After that PR is integrated, finalize H with the integrated +revision and checkpoint-to-integration receipts, outstanding dependencies and +the recipient's independent reproduction. Have the receiving maintainer +reproduce setup and validation independently and record that receipt. Transfer +future feature ownership explicitly. Do not automatically begin Calendar, +Documents, Email, Chat, Skills or Settings; the initiating architect continues +with separately approved non-frontend monolith work. diff --git a/docs/frontend-architecture/notes-validation.md b/docs/frontend-architecture/notes-validation.md new file mode 100644 index 000000000..e4e701c6c --- /dev/null +++ b/docs/frontend-architecture/notes-validation.md @@ -0,0 +1,261 @@ +# Notes implementation validation and checkpoint receipts + +Implementation baseline: `1cc56cb9d78f3e2399d1d61155b71d771a17b85b`, updated via +non-destructive fast-forward to latest official `upstream/dev` at +`86b886c611338282af6d4a717246ea4e256f58df` (incorporating PRs #6704 and #6693). +Independent AGY audit returned CONDITIONAL PASS. Pre-publication corrective +pass verified issue template, PR template, visual evidence paths, and em-dash removal. +The official integration branch is `dev`. Tracking issue #6712; PR #6713. + +## Final executed evidence + +| Check | Result | +| --- | --- | +| Notes Python tests, discovered by filename, plus F1 publication/graph/offline/loader tests | 172 passed, one release-smoke skip without `APP_PORT` | +| Node architecture, Notes oracle, domain, infrastructure, application, reminders, drafts, selection and workflow tests | 46 passed | +| Native Python lifespan/static distribution probe | Passed for every mapped output, with source/Node absent | +| Existing Docker image distribution probe | Passed with networking disabled and candidate files mounted read-only | +| Real authenticated native app, desktop and touch-mobile | Passed CRUD persistence, owner identity, actual Chat selected-context indicator/reset and the original release smoke scenario | +| Real Chromium first evaluation of Notes offline | Passed; every maintained output cached, facade/canonical API identity shared | +| Source/output parity and F1 boundaries | Passed, 43 maintained modules and nine composition-only legacy exceptions | +| Foundation-based full pytest execution | 13 failed, 13,909 passed, 60 skipped, six subtests passed, 192 warnings, 517.04 seconds | +| Targeted unchanged F1 baseline reproduction | Same 13 failures, 77 passed, six warnings | +| Targeted untouched latest `dev` reproduction | Three inherited failures, 87 passed, six warnings | +| Final indexed latest `dev` plus Notes overlay, full pytest | Three same-environment baseline failures, 13,923 passed, 60 skipped, six subtests passed, 192 warnings, 512.09 seconds | +| Untouched latest `dev` readiness checks, without the prewarm override | Five passed | + +The foundation-based full repository result is not green. Its failures reproduce against +the unchanged foundation in a disposable archive, with the same interpreter and +closed Chroma port. None was disabled, relabeled or fixed in this Notes change. +The focused Notes suite and required source/deployment gates are green. The final +code/test overlay is fingerprinted in [implementation.sha256](evidence/notes/implementation.sha256). +The only post-full-suite test-source edit removes a terminal +blank line from the browser wrapper; its content was compared ignoring terminal +whitespace and all three focused contract/browser checks passed again. Raw command +outputs are kept out of the repository; the counts above are the record. + +The first full run reported 19 failures. Six were introduced validation issues: +the SW cache name did not follow the existing numeric convention; a temporary +probe environment input was not registered by the configuration-reference checker; +and a Notes source assertion still expected the original default object spelling. +The cache name now follows the convention, the probe passes its output list as a +command argument, and the assertion verifies the new public binding. All 58 tests +in those affected files passed after correction. The foundation-based full run has only the +13 reproduced baseline failures below. + +### Reproduced baseline failures + +- `tests/test_aux_llm_owner_scope.py::test_auto_compaction_utility_endpoint_keeps_chat_owner` +- `tests/test_chatgpt_reasoning_and_grouping.py::test_history_and_chat_routes_support_reasoning_effort` +- `tests/test_containment_enforcement.py::test_namespace_handshake_closes_model_stdin` +- `tests/test_containment_enforcement.py::test_execution_environment_cannot_replace_probed_bwrap[False]` +- `tests/test_context_compactor.py::TestMaybeCompactFourthMessage::test_does_not_crash_on_none_content_turn` +- `tests/test_context_compactor.py::TestMaybeCompactFourthMessage::test_handles_multimodal_list_content` +- `tests/test_css_computed_style_snapshot.py::test_computed_styles_match_the_committed_baseline` +- `tests/test_history_compact_tool_calls.py::test_manual_compact_tolerates_chatmessage_with_none_content` +- `tests/test_history_compact_tool_calls.py::test_manual_compact_tolerates_dict_message_with_none_content` +- `tests/test_history_compact_tool_calls.py::test_registered_manual_compact_route_tolerates_none_content` +- `tests/test_history_compact_tool_calls.py::test_registered_manual_compact_route_uses_session_owner` +- `tests/test_model_helper_owner_scope.py::test_history_compact_resolves_with_owner_scope` +- `tests/test_readiness.py::test_tool_index_is_reported_but_not_critical_by_default` + +These cover existing compaction fixtures/source assertions, namespace containment, +settings-row CSS baseline drift and readiness expectations. The baseline reproduction used +`git archive` into `/tmp/odysseus-notes-baseline-OitxsU`, not another branch/worktree. + +## Reproducible commands + +### Latest integration target + +Final upstream inspection found `86b886c611338282af6d4a717246ea4e256f58df`. +PR #6704 repairs ten of the foundation's reproduced failures; PR #6693 adds +remote-only skill gating. Neither changes a file owned by the Notes diff. +The branch remains on the requested foundation, with no merge or rebase. +An archive of that latest target plus the exact final Notes code/test overlay is +validated in `/tmp/odysseus-notes-latest-VmZrgC` using the same full-suite command. +The fixture has a local Git index, initialized from the latest target and updated +with the review files, but no commit. This enables the tracked-publication checks. +The first archive-only attempt had one Git-metadata failure and seven checkout-only +skips. Both affected files subsequently passed all 19 tests with metadata present; +the complete suite was then rerun in that indexed fixture. +An untouched archive at `/tmp/odysseus-notes-latest-baseline-mfE4ss` reproduced +the remaining two containment failures and the readiness failure: three failed, +87 passed. + +These are reproduced local-environment failures, not a claim that official CI is +currently failing. The readiness assertion expects prewarming enabled, whereas +the full-run safety command explicitly sets `ODYSSEUS_TOOL_INDEX_PREWARM=0`. +With that override absent, all five readiness tests pass on untouched latest `dev`. +The two namespace tests receive extra `Failed to create stream fd` stderr in this +host's contained process output. That same output occurs without the Notes overlay; +no backend containment code was changed or a failing assertion weakened. +Reproduce those two checks on the supported CI runner before claiming a green +full-suite certification. + +In the final indexed full-suite run the only failures are `test_namespace_handshake_closes_model_stdin`, +`test_execution_environment_cannot_replace_probed_bwrap[False]`, and +`test_tool_index_is_reported_but_not_critical_by_default`. The missing-Git-metadata +publication failure and the seven checkout-only skips are absent from this run. +No Notes regression was reported. Feature code matched N6 at publication. Later +commits on the PR changed `ui/reminderPicker.js` (date input value assigned through +the DOM property) and, after review, `index.js`, `ui/layout.js` and +`application/state.js`; `implementation.sha256` covers the current files. + +To reproduce the integration rehearsal, extract the latest target with `git archive` +into a fresh fixture and copy only files in the changed-file inventory on top. +Do not overlay foundation files that are unchanged in this PR. Install the locked +development dependencies in the fixture, then run the commands below there. + +While this review diff is uncommitted, this prepares an equivalent Linux fixture +without changing either task worktree: + +```sh +notes_fixture=$(mktemp -d /tmp/ody-notes-integration-XXXXXX) +git archive 86b886c611338282af6d4a717246ea4e256f58df | tar -x -C "$notes_fixture" +{ git diff --name-only -z; git ls-files --others --exclude-standard -z; } | + tar --null -T - -cf - | tar -xf - -C "$notes_fixture" +git -C "$notes_fixture" init -q +git -C "$notes_fixture" fetch --no-write-fetch-head "$PWD" 86b886c611338282af6d4a717246ea4e256f58df +git -C "$notes_fixture" read-tree 86b886c611338282af6d4a717246ea4e256f58df +(cd "$notes_fixture" && npm ci --ignore-scripts --no-audit --no-fund && git add --all) +# Run the validation commands below from notes_fixture, using an isolated Python environment. +``` + +Use a real dependency directory for this recipe; a symlink named `node_modules` +can bypass the repository's directory-only ignore pattern. No checkpoint or +fixture commit is needed. After integration, validate the actual integrated checkout +directly, rather than relying on the uncommitted-diff recipe. + +### Commands + +Use the actual tested interpreter, or install the repository requirements in your +own isolated virtualenv. Node development dependencies were installed with the +existing lockfile and `npm ci --ignore-scripts --no-audit --no-fund`. + +```sh +python3 scripts/build_frontend.py +python3 scripts/build_frontend.py --check +node scripts/check_frontend.mjs +node scripts/check_frontend.mjs --inventory + +ENDPOINT_URL=http://127.0.0.1:1 node --test \ + tests/frontendArchitecture.test.mjs tests/noteTestOracle.test.mjs \ + tests/notesDraftAutosave.test.mjs tests/selectedNotesContext.test.mjs \ + tests/notesCharacterization.test.mjs tests/notesRecurrence.test.mjs \ + tests/notesInfrastructure.test.mjs tests/notesApplication.test.mjs \ + tests/notesReminders.test.mjs tests/notesWorkflows.test.mjs + +CHROMADB_HOST=127.0.0.1 CHROMADB_PORT=1 \ + python -m pytest -q -rs \ + $(rg --files tests -g 'test_*note*.py') \ + tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \ + tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \ + tests/test_panel_loader_js.py + +python scripts/verify_frontend_runtime.py +python scripts/verify_notes_browser_runtime.py + +docker run --rm --network none \ + --mount type=bind,src="$PWD",dst=/candidate,readonly \ + --workdir /candidate --entrypoint python \ + scripts/verify_frontend_runtime.py + +CHROMADB_HOST=127.0.0.1 CHROMADB_PORT=1 \ + ODYSSEUS_STARTUP_WARMUPS=0 ODYSSEUS_TOOL_INDEX_PREWARM=0 \ + python -m pytest -q -rfs + +git diff --check +``` + +`ENDPOINT_URL` satisfies the existing oracle's import configuration; its endpoint- +driving main does not run in these unit tests. Port 1 is the closed loopback target +used for pytest; the two runtime scripts reserve their own non-listening Chroma port. +The native browser script creates a private distribution/database and a temporary +authenticated user, then terminates the server and removes that fixture. It uses +the real application and Notes API rather than mocked browser routes. + +The deterministic browser harness uses stub API responses and the real Notes ESM, +DOM and stylesheet graph. It verifies namespace/default methods and arities, +aliases, remount/search reset, rejected/accepted attachments, draft persistence, +drawing/undo and listener/portal cleanup. It also checks larger-touch reorder after +re-render, active-drag close cleanup and cancellation of a mobile selection hold. +It includes the mobile viewport meta tag, +tap-to-read/edit behavior and the existing selection jiggle. The original monolith +can be replayed with `NOTES_BASELINE=1`; it is read from the frozen F1 revision. + +## Browser and packaging evidence + +Actual app screenshots: [desktop](evidence/notes/native-desktop.png) and +[mobile](evidence/notes/native-mobile.png). These are Chromium screenshots of the +disposable authenticated app at 1440 x 1000 and 390 x 844, not rendered mockups. +The deterministic before/after fixture had an identical mobile PNG and only 14 +changed desktop pixels in the header region; no layout/style change was observed. +CSS, app bootstrap and HTML have no implementation diff. + +The native and Docker distribution probe serves all manifest outputs without +`frontend/`, `node_modules/` or Node on the server path. It checks response bytes, +JS MIME, revalidation, CSP, shell startup, static confinement and source-tree 404. +Docker used existing image +`sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`. +This is not a fresh Dockerfile build or deployment. + +Tested locally: Linux, Python 3.14.4, Node 22.23.1 and Playwright/Chromium 1.62.1. +CI's exact Python 3.11/Node 20 pair, Firefox/WebKit, macOS/Windows executable builds, +live model/email notification delivery and full root offline reload were not run. +The Docker runtime uses its own existing dependency versions. Shared active-window +gesture interleavings are not exhaustively covered. No broader platform certification +is claimed. + +The full-run safety overrides differ from official CI's plain pytest command. +For the readiness default-contract check, use +`env -u ODYSSEUS_TOOL_INDEX_PREWARM python -m pytest -q tests/test_readiness.py`. +Keep explicit warmup/storage isolation on the disposable live-app probes. + +## Internal checkpoints + +Checkpoint snapshots are kept with the maintainer handoff, not in the repository. +The hashes below identify them. They contain scoped source/output/facade/manifest/test +overlays, not secrets or user data. Each starts from the same frozen baseline; they +are not separate PRs or release candidates. + +| Stage | Boundary and executed exit evidence | Snapshot SHA-256 | +| --- | --- | --- | +| N0 | Public/consumer/state/lifecycle map; 95 baseline Python and 11 Node passes; four new characterization tests; desktop/touch harness; 33 F1 publication/graph checks | `db0a2e6e68fec3442ce350ffb0acf47bcdf2a5266f7c64c2bf910a1c41c4fb62` | +| N1 | Pure recurrence/date rules; eight Node and nine focused Python passes; differential oracle in four time zones; parity/boundaries | `fd0638470744706fbbd5787cb47bd2078a93c9b854139f89f8fdc169c1f353c6` | +| N2 | HTTP/storage adapters; fourteen Node and seven focused Python passes; exact requests, keys and fallback contracts | `30826fb7972d1b9e3a790f97ee81f66dcb3dc58eb7d23e24d4f46e6d841b7c73` | +| N3 | Single state owner and CRUD/undo boundary; ten Node and seven focused Python passes; read-only state and archive rollback | `4bc5293519b8438d0126a61887bea64195dcdb7d59bcfaaba8ed4ae8c38be439` | +| N4 | Page-owned reminder workflow; four reminder contract tests and seven focused Python passes; singleton interval, catch-up, markers and fallback | `febb835c4958c8758e4b7c23ce89f3ec8d103c2cc28f89817d258b3791a9fcdf` | +| N5 | Complete UI/application integration; 44 Node passes, targeted source guards and actual browser checks; drawing binding correction, read-only state and disposal | `e147f645bdb2161a4b1dc6cf10c36ba6d0c214d8e12fccb478835043eaaf3e94` | +| N6 | Final facade, identity, exact offline graph, native/Docker checks, final editor-deletion/reorder ownership review, touch cleanup regression checks and maintainer review package | `c450e91f9fbfe5f0cd805e596b92a76383f22a74e9515ca3e3f6154aebf5f61f` | + +The N6 overlay contained the final code, tests, manifest and attributes at +publication, not the evidence directory itself. From the repository root, run +`sha256sum -c docs/frontend-architecture/evidence/notes/implementation.sha256` +to verify the current implementation files. + +No checkpoint commits were made. To reproduce an overlay, create a fresh temporary +directory, extract `git archive 1cc56cb9...` there, then extract the desired +snapshot from the handoff on top. Install the locked test dependencies +in that fixture and run its relevant commands. Begin from the baseline rather than +overlaying snapshots onto a later dirty tree. For live rollback, release a complete +matching artifact through the authorized release workflow. + +Temporary mechanical extraction scripts are not part of the supported deliverable. +They were archived locally, then removed. The first generated patch accidentally targeted +the separate foundation worktree; only those edits were reversed, absolute paths +were adopted, and that worktree's clean state was verified afterward. + +## Open PR overlaps and handoff limits + +The following remained open at the read-only GitHub refresh: + +- [#6585](https://github.com/odysseus-dev/odysseus/pull/6585): optional Notes field removal in the backend. Existing null-field behavior is retained here. +- [#6647](https://github.com/odysseus-dev/odysseus/pull/6647): read-only Notes intent routing. +- [#6652](https://github.com/odysseus-dev/odysseus/pull/6652): `manage_notes` registry dispatch. +- [#6470](https://github.com/odysseus-dev/odysseus/pull/6470): shared theme, typography, sidebar and modal changes. +- [#6695](https://github.com/odysseus-dev/odysseus/pull/6695): extension system/platform integration. + +Their changes were not merged or reimplemented. Shared UI exceptions, root-SW scope, +the original last-completion fetch race, temporary-note correlation and canvas-draft +limits are documented in the case study. Backend owner checks and PR #6495 contracts +remain authoritative. diff --git a/docs/frontend-architecture/validation.md b/docs/frontend-architecture/validation.md new file mode 100644 index 000000000..1e85db081 --- /dev/null +++ b/docs/frontend-architecture/validation.md @@ -0,0 +1,453 @@ +# F1 implementation and review report + +Date: 2026-10-13. Recommendation: ready for controlled incremental adoption after +maintainer review, with the platform/offline limits below. The foundation proof +is implemented and tested; Notes decomposition and the completed H handoff are +planned only. No commit, push, merge, PR or issue operation was performed. + +## Notes delivery documentation correction + +The maintainer's final delivery decision is one comprehensive Notes draft PR +targeting official `dev`, after the separate foundation-only F1 PR is integrated. +N0 through N6 are internal engineering checkpoints in one Notes worktree and +branch. The complete decomposition, regression tests, compatibility validation +and final reference case study belong to that single PR; it stays a draft until +the complete acceptance gates pass. Each stage retains its own test results, +review receipts and reproducible rollback/checkpoint strategy, without requiring +an independent GitHub PR. The receiving maintainer may choose smaller independent +PRs for future frontend features. + +This correction updates only `notes-roadmap.md`, `handbook.md` and this report. +Implementation and test files retain their pre-correction bytes. All five +foundation documents passed link, fence, whitespace, no-em-dash and delivery +wording checks. `git diff --check` and the preserved-runtime diff check passed. +A before/after snapshot confirmed exactly these three changed documents and +15 unchanged existing F1 files. The implementation test results below remain +the previous executed evidence; implementation tests were not rerun in this +documentation-only pass. + +## Independent review correction + +The maintainer reports that the independent F1 review found no critical +architectural defects. This pass preserves the reviewed native ESM architecture +and scope. Its correction is to turn the documented LF checkout convention into +a manifest-driven publisher/CI requirement. The full independent review text +was not supplied; this report records the supplied finding and executed evidence. + +The correction changes only `scripts/build_frontend.py`, +`tests/test_frontend_build.py`, the ADR, the handbook and this report. The +`.gitattributes` rules, architecture checker, CI workflow, manifest, dependencies +and stateless source remain as reviewed. The full F1 inventory below still has +18 files, including `.gitattributes`. + +The publisher now evaluates both paths of every manifest entry through Git's +actual attribute engine before parity evaluation or any deployment write. It +requires `text: set` and `eol: lf` from applicable repository-owned attribute files, +including nested overrides. A private temporary repository and isolated Git +configuration prevent global attributes, local `.git/info/attributes`, templates +or alternate Git directory settings from masking an omitted declaration. Paths +are sent through NUL-delimited stdin, preserving spaces and using no shell/path +quoting assumptions. The working repository's attributes, index and output are +not changed by this check. Git is a publication/development prerequisite; the +application does not invoke it to build or check frontend assets at startup. + +The production integration diff is the new `check_lf_attributes(root, paths)` +helper, four standard-library imports, this call, and updated CLI help: + +```diff +@@ synchronize + if unlisted: + raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}") ++ check_lf_attributes(root, sources | outputs) + stale = [] +``` + +The tests add fixture LF declarations and 14 cases. Ten parameterized cases +reject missing LF, CRLF, auto-text and binary-text rules for source/output paths +in both publish and check modes, even when bytes already match. The other four +cover a future manifest pair failing before any output is published and passing +after its LF declaration; nested rules and a spaced path; global/local settings +failing to mask an omitted repository rule; and clear failure without development +Git. Existing publication, ownership, confinement, parity and runtime tests remain. + +## Exact attributes diff and checkout evidence + +`git diff -- .gitattributes` is unchanged from the reviewed F1 diff: + +```diff +diff --git a/.gitattributes b/.gitattributes +index 8681aee1..dd49c4a0 100644 +--- a/.gitattributes ++++ b/.gitattributes +@@ -10,6 +10,10 @@ + entrypoint.sh text eol=lf + docker/entrypoint.sh text eol=lf + ++# Maintained frontend modules and this lane's byte-identical deployment output. ++frontend/src/** text eol=lf ++static/js/actionMenuOrder.js text eol=lf ++ + # Windows-native scripts stay CRLF. + *.ps1 text eol=crlf + *.cmd text eol=crlf +``` + +Exact command and results: + +```text +$ git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +frontend/src/shared/actionMenuOrder.js: text: set +frontend/src/shared/actionMenuOrder.js: eol: lf +static/js/actionMenuOrder.js: text: set +static/js/actionMenuOrder.js: eol: lf +``` + +The sampled legacy contracts were also inspected: + +| Path | text | eol | +| --- | --- | --- | +| `static/js/notes.js` | auto | unspecified | +| `static/index.html` | auto | unspecified | +| `launch-windows.ps1` | set | crlf | +| `start-macos.sh` | set | lf | + +Beyond samples, all 2,479 unrelated tracked paths were compared against HEAD for +both attributes and matched. The current Git 2.53.0 supports the diagnostic +`--source=HEAD` option; the publisher uses only the longstanding plain +`check-attr --stdin` interface, so it does not depend on that newer option. +The read-only whole-tree comparison command was: + +```sh +python3 - <<'PY' +import subprocess +paths = subprocess.check_output(['git', 'ls-files', '-z']) +def attributes(source): + result = subprocess.run( + ['git', 'check-attr', *source, '-z', '--stdin', 'text', 'eol'], + input=paths, capture_output=True, check=True, + ) + fields = result.stdout.decode().split('\0')[:-1] + return {(fields[i], fields[i + 1]): fields[i + 2] + for i in range(0, len(fields), 3)} +before, after = attributes(['--source=HEAD']), attributes([]) +owned = lambda p: p.startswith('frontend/src/') or p == 'static/js/actionMenuOrder.js' +changes = [(p, a, before[(p, a)], value) for (p, a), value in after.items() + if not owned(p) and before[(p, a)] != value] +assert not changes, changes +print(len({p for p, a in after if not owned(p)}), 'unrelated tracked paths unchanged') +PY +``` + +## Ownership enforcement after correction + +| Contract | Enforcement and CI receipt | +| --- | --- | +| Canonical, contained source/output paths and unique ownership | Publisher validates the whole manifest before writing; `python-syntax` runs its `--check` | +| Every maintained JS source is registered | Publisher rejects unlisted source; duplicate sources/outputs also fail | +| Reproducible LF checkout of both mapped paths | Publisher queries isolated repository-owned attributes, even for already matching bytes; the same CI `--check` now enforces it | +| Committed output equals maintained source | Publisher checks byte parity without rebuilding in CI; normal publication writes only stale owned outputs | +| Feature API/layer direction, no new cycles, stable relative resolution and maintained identities | Existing architecture checker and Node self-tests in `node-syntax`; no checker changes needed | +| Explicit exact-URL offline coverage | Architecture checker parses the existing SW arrays; real offline browser proof remains green | +| Legacy runtime graph/URL identity and behavior | Existing focused pytest guards and the full CI pytest jobs remain in place | + +These gates enforce the documented maintained-JS contract together. They do not +retroactively impose source ownership on all legacy static files, clean retired +outputs or prove semantic state/lifecycle correctness. Those existing limits and +the independent packaging/offline limitations below remain unchanged. + +## Final correction validation + +The full focused pytest command printed below was rerun with the same interpreter +and all listed files: **193 passed, 1 skipped, 1 existing SQLAlchemy warning in +12.33 s**. It includes all 27 publisher tests and the isolated native startup, +static traversal and no-Node distribution probe. The new 14 tests account for +the increase from the initial F1 run's 179 passes. No test failed in this pass. + +| Correction check | Actual result | +| --- | --- | +| Combined focused pytest command below | 193 passed, 1 unavailable-image skip | +| `node --test tests/frontendArchitecture.test.mjs` | 10 passed | +| Notes oracle/autosave/selected-context command below | 11 passed | +| `python3 scripts/build_frontend.py` | Passed, zero output changes, attributes enforced | +| `python3 scripts/build_frontend.py --check` | Passed, attributes and byte parity enforced | +| `node scripts/check_frontend.mjs` | Passed, one maintained module | +| Helper SHA-256 and `git check-attr` | Identical source/output hash; both text=set, eol=lf | +| All unrelated tracked attributes versus HEAD | 2,479 paths unchanged | +| `git diff --check` and preserved-runtime command below | Passed, no runtime/backend/packaging diff | +| `python -m compileall -q scripts/build_frontend.py tests/test_frontend_build.py` | Passed using the recorded virtualenv interpreter | +| Isolated Docker runtime command below | Passed, networking disabled; no source tree, node_modules or Node on PATH | +| Complete inventory, whitespace and documentation links | All 18 changed files matched the inventory; checks passed | + +The one skip remains the existing test requiring `odysseus-odysseus:latest`. +No new skip or waiver was introduced. Windows/macOS executable builds, a fresh +full Dockerfile build, the full pytest suite, Firefox/WebKit and CI's exact +Python 3.11/Node 20 versions remain unexecuted locally. Native Git semantics, +nested overrides, paths with spaces and ignored `core.autocrlf`/global attribute +settings are covered by executed Linux tests; actual Windows execution remains a +platform-owner check. The existing `/static/` worker scope still prevents a claim +of full root offline reload readiness. No service-worker scope change is included. + +The earlier local ChromaDB probe incident remains recorded under failures below. +All runtime checks in this correction pass used the isolated distribution probe +with reserved non-listening Chroma port and disabled warmups; Docker additionally +had networking disabled. This pass performed no new unisolated ChromaDB probe and +no attempt to restore or alter the existing local service's tool index. + +## Reviewer-ready PR summary + +Suggested title: `Establish frontend source ownership and native ESM deployment contracts`. + +Maintain a stateless helper under `frontend/src/` while publishing identical bytes +at its existing static URL. Add manifest-owned publication, repository-owned LF +attribute and byte-parity checks, parsed dependency/API/identity/offline gates, +and a self-contained architecture handbook with the Notes decomposition roadmap. +Startup and existing feature behavior retain their current runtime contracts. + +Validation: 193 focused Python tests passed with one existing Docker-image skip; +10 architecture and 11 Notes compatibility tests passed; source/output parity, +LF gates, preserved-runtime diff and isolated native/Docker runtime proofs passed. +Desktop executable builds and full root offline reload remain explicit limits. +All 18 files are inventoried below. The working diff is ready for review and is +uncommitted; commit, push and PR creation require separate authorization. + +## Verified repository state + +Repository: `odysseus-dev/odysseus`. Branch: +`refactor/frontend-architecture-foundation`. Worktree: +`/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation`. +Initial worktree was clean. HEAD and local official `upstream/dev` are +`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The live official GitHub `dev` SHA +matched at both initial and final verification. This is PR #6495's merge commit. +The `origin` remote is the maintainer fork, not the integration target. + +Final changes are uncommitted F1 files only. Runtime JavaScript, Notes, HTML, +SW, backend, Dockerfile and desktop startup/packaging implementations have no +diff. The stateless source and existing deployed helper are both 1,688 bytes, +SHA-256 `e17baec5d6ac381ce6234ab6f028a8208487280dd07d6950a3469f8488809684`. +The final publisher run reported zero changed output modules. + +## Decisions and scope + +The [ADR](ADR-001-source-and-deployment.md) compares two executed approaches: +controlled native ESM copying and esbuild 0.25.12 ESM compilation/bundling. +Native byte copying wins on existing URL/module/cache compatibility and absence +of runtime tooling. Commit explicit output beside maintained source, publish +only manifest-owned modules and enforce parity in CI. Python startup never builds. + +The [handbook](handbook.md) defines feature public indexes, responsibility layers, +state/lifecycle ownership, cross-feature ports/events, shared-code limits, legacy +coexistence, module creation, validation, rollback and handoff contracts. +The [assessment](assessment.md) records graph/bootstrap/state/storage/security, +packaging assumptions and current open-PR overlaps. The +[Notes roadmap](notes-roadmap.md) defines N0 through N6 as internal checkpoints +for one comprehensive Notes draft PR, with the final reference case study in +that PR and H recipient handoff after integration. None is implemented in F1. +There is one maintained stateless leaf and zero legacy-import +exceptions, with no empty feature scaffolding. + +Acorn 8.19.0 is the sole new, exact-pinned development dependency, used to parse +architecture edges accurately. It is not loaded or distributed as runtime JS. +esbuild was a pinned one-off spike and is not a project dependency. Existing +frameworks, application entry points and backend contracts are unchanged. + +## Exact changed files + +| File | Change | +| --- | --- | +| `.gitattributes` | LF checkout for maintained source and the registered deployed proof | +| `.github/workflows/ci.yml` | Parity check; locked parser install; source syntax and architecture self-tests | +| `package.json` | Exact Acorn development dependency | +| `package-lock.json` | Acorn lock/integrity entry | +| `frontend/modules.json` | Explicit source/deployment ownership mapping | +| `frontend/src/package.json` | Native ESM interpretation for source tests, no dependencies | +| `frontend/src/shared/actionMenuOrder.js` | Authoritative stateless source, copied verbatim from the existing helper | +| `scripts/build_frontend.py` | Validated source publisher with manifest-driven, repository-owned LF enforcement and non-mutating parity check | +| `scripts/check_frontend.mjs` | Parsed import/layer/API/cycle/identity/precache guards and graph inventory | +| `scripts/verify_frontend_runtime.py` | Isolated current-code distribution/runtime compatibility probe | +| `tests/frontendArchitecture.test.mjs` | Behavioral API equivalence and positive/negative architecture gates | +| `tests/test_frontend_build.py` | Publication/ownership/drift/runtime coverage plus positive and negative Git attribute enforcement tests | +| `tests/test_frontend_foundation_offline_js.py` | Real SW precache, offline first evaluation, canonical namespace identity and lazy registry proof | +| `docs/frontend-architecture/ADR-001-source-and-deployment.md` | Decision, alternatives and distribution/rollback contracts | +| `docs/frontend-architecture/assessment.md` | Evidence-backed architecture and PR-overlap inventory | +| `docs/frontend-architecture/handbook.md` | Permanent foundation and maintainer continuation methodology | +| `docs/frontend-architecture/notes-roadmap.md` | Reference decomposition and final handoff plan | +| `docs/frontend-architecture/validation.md` | This implementation/review/validation report | + +## Initial F1 validation before the review correction + +Environment: Linux; native Python 3.14.4; pytest 9.1.1; FastAPI 0.143.0; +Starlette 1.8.0; Node 22.23.1; Playwright 1.62.1 with installed Chromium. +CI remains Python 3.11 and Node 20; that exact interpreter pair was not executed +locally. Use the repository virtualenv, not the system Python missing dependencies. +The interpreter used here was +`/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python`. + +The historical counts below are coordinator-executed checks in this worktree, excluding +delegated claims and earlier repeated subsets. Delegation was discontinued on +user instruction; no delegated implementation was integrated. + +| Check | Actual result | +| --- | --- | +| Read-only baseline: graph, identity, helper, loader, MIME, nonce, runtime paths | 54 passed, one existing SQLAlchemy deprecation warning | +| Final combined focused pytest command below | 179 passed, 1 skipped, one existing SQLAlchemy warning, 12.62 s | +| Final `pytest -q -rs tests/test_frontend_build.py` review rerun | 13 passed, including native startup and encoded static traversal rejection | +| `node --test tests/frontendArchitecture.test.mjs` | 10 passed, no skips or failures; rerun after final gate review | +| Notes oracle/autosave/selected-context Node command below | 11 passed, no skips or failures | +| `python3 scripts/build_frontend.py` and `--check` | Zero output changes; byte parity passed | +| `node scripts/check_frontend.mjs` | Passed, one maintained module | +| `node scripts/check_frontend.mjs --inventory` | 230 files, 707 static edges, 108 lazy literal edges, no missing targets, three existing static cyclic components | +| `npm ci --ignore-scripts --no-audit --no-fund` | Locked development dependency installation succeeded | +| Native `scripts/verify_frontend_runtime.py` | Real FastAPI lifespan and shell/static assertions passed without maintained source, node_modules or Node on PATH | +| Docker runtime probe below | Same current-code assertions passed with networking disabled and source mount read-only | +| esbuild spike | Two identical 1,554-byte bundle outputs; executed exports/icon/ordering equivalence passed | +| JS syntax checks and Python compileall below | Passed | +| `git diff --check`, preserved-scope diff check, hash/LF checks | Passed; no runtime/backend/packaging source diff | +| Foundation documentation links, trailing whitespace and em-dash checks | All five files passed | + +The skipped test is +`tests/test_docker_devops_hardening.py:187`, which requires the unavailable +`odysseus-odysseus:latest` image. It was not relabeled or bypassed. F1's separate +Docker runtime probe used the available `odysseus-maintainer-preview-odysseus:latest`, +image ID `sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`, +with Python 3.14.7, FastAPI 0.141.1 and Starlette 1.6.0. This proves compatibility +of current candidate code/assets with that image's runtime dependencies, not a +fresh build of the full repository Dockerfile or a deployment. + +Reproduce the final focused command with the actual tested interpreter: + +```sh +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m pytest -q -rs \ + tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \ + tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \ + tests/test_action_menu_order.py tests/test_panel_loader_js.py \ + tests/test_app_static_mime.py tests/test_serve_html_with_nonce.py \ + tests/test_runtime_paths.py tests/test_ci_authoritative_validation.py \ + tests/test_selected_notes_context.py tests/test_email_library_module_graph_js.py \ + tests/test_settings_module_import_paths.py tests/test_app_config_shared_fetch_js.py \ + tests/test_markdown_lazy_lib_loading_js.py tests/test_startup_shell_js.py \ + tests/test_static_stylesheet_manifest.py tests/test_notes_dom_xss_helpers.py \ + tests/test_notes_reminder_temporal_redos.py tests/test_notes_sidebar_new_note.py \ + tests/test_notes_mobile_checklist_toggle_static.py \ + tests/test_notes_document_mutual_minimize.py tests/test_docker_devops_hardening.py + +node --test tests/frontendArchitecture.test.mjs +ENDPOINT_URL=http://127.0.0.1:1 node --test \ + tests/noteTestOracle.test.mjs tests/notesDraftAutosave.test.mjs \ + tests/selectedNotesContext.test.mjs + +python3 scripts/build_frontend.py --check +node scripts/check_frontend.mjs +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python scripts/verify_frontend_runtime.py + +docker run --rm --network none \ + --mount type=bind,src=/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation,dst=/candidate,readonly \ + --workdir /candidate --entrypoint python \ + odysseus-maintainer-preview-odysseus:latest scripts/verify_frontend_runtime.py +``` + +`ENDPOINT_URL` satisfies the existing oracle's import-time configuration requirement. +Its unit-test imports exercise pure exported functions; its network-driving main +is guarded by the executable-module check and does not run. The closed loopback +address is not a live model endpoint. No oracle implementation was changed. + +Syntax commands executed: + +```sh +bash -c 'shopt -s globstar nullglob; for f in static/app.js static/js/**/*.js frontend/src/**/*.js scripts/check_frontend.mjs tests/frontendArchitecture.test.mjs; do node --check "$f" || exit; done' +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m compileall -q app.py core routes src services scripts tests +git diff --check +git diff --exit-code -- static app.py Dockerfile .dockerignore Odysseus.spec build-macos-app.sh build-windows-portable.ps1 launcher.py core routes src services +git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +sha256sum frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +``` + +Spike compiler invocation, executed twice with equality and Node import assertions: + +```sh +npx --yes esbuild@0.25.12 frontend/src/shared/actionMenuOrder.js \ + --bundle --format=esm --target=es2022 --platform=browser --log-level=error +``` + +The clean publication proof uses temporary roots in `test_frontend_build.py`, +including an initially absent output tree. Real-checkout publication is idempotent. +No emitted JS bytes, URLs, boot scripts or cache manifests changed in this lane. + +## Failures encountered and their resolution + +- An early architecture self-test expected an identity diagnostic but got an + unresolved source path for a query-bearing relative import. The checker now + rejects alternate identity before resolving that source path. All final tests + pass; no test requirement was weakened. +- The initial Notes oracle command failed during import because `ENDPOINT_URL` + was missing. Inspection established that the unit-test path never runs its + endpoint-driving main. The explicit closed-address configuration above passed + all 11 tests. This prerequisite is recorded for N0 rather than hidden. +- The first isolated distribution fixture omitted the existing `companion/` + package, so both probes failed with `ModuleNotFoundError`. The fixture now + includes that required runtime directory; neither application nor packaging + implementation was modified to make the tests pass. +- The first exploratory native startup check connected to the existing local + ChromaDB and refreshed the built-in tool index. That was unintended. Subsequent + probes use a private distribution/data directory, a reserved non-listening + Chroma port, disabled existing warmup options and no Node on PATH. Docker also + disables networking. No attempt was made to overwrite or restore that service's + data; the earlier unisolated probe is not the reproducible validation recipe. + +## Remaining risks and limits + +Root offline reload is not certified: existing SW scope is `/static/`, and open +[#6227](https://github.com/odysseus-dev/odysseus/pull/6227) proposes the independent +scope/header correction. F1 preserves and tests actual offline module behavior +at the current scope. No first-use offline guarantee is claimed for optional +CDN libraries, Mermaid or server APIs. + +Windows/macOS packaged executables, a fresh full Dockerfile build, the full pytest +suite, Firefox/WebKit offline behavior and CI's exact Python 3.11/Node 20 versions +were not executed here. Existing packaging paths are inspected and unchanged; +platform owners should run release validation before broader certification. +The unavailable-image test remains a coverage gap. Legacy source still has +cross-feature coupling, global side effects and static cycles. New boundaries +are enforced on maintained source, not retrospectively on the entire legacy tree. + +The publisher intentionally does not clean retired outputs or provide atomic +multi-file deployment. Review full source/output/facade/manifest changes and +ship a coherent artifact. Domain syntax checks cannot prove all semantic purity +or disposal; behavioral characterization remains required. Reconcile the package +lock with #6627 and refresh all PR overlaps before integration. + +## Reviewer checklist + +- Verify the base contains #6495 and the official integration target is dev. +- Confirm the deployed helper hash and runtime-scope diff are unchanged. +- Review the layer matrix, public API rules, single-state ownership and zero + current legacy exceptions; verify negative tests actually reject violations. +- Review the manifest mapping, byte-parity/LF policy, exact offline precache and + source/output relative-resolution guard; verify missing or overridden LF + declarations fail publication and CI even when output bytes already match. +- Reproduce the focused tests and inspect the skipped-image prerequisite. +- Confirm no frontend compiler/runtime dependency is required at startup and + inspect the isolated Python/Docker probe's limits. +- Review overlap with #6470/#6695/#6227/#6627 and the Notes backend follow-ups. +- Treat the ADR as awaiting adoption review, and the Notes/H documents as plans. +- Authorize any commit/push/PR action separately. Do not begin Notes on this branch. + +## Required next reviews and continuation + +1. Review this F1 working diff. On explicit authorization, commit and submit only + the foundation-only F1 PR to official dev using the normal maintainer workflow. +2. Coordinate the existing #6227 offline fix and platform release validation as + separate work. Do not bundle that behavioral change into F1. +3. After F1 integration and Notes implementation authorization, create one Notes + worktree and branch from the latest official dev, preserving any subsequently + merged Notes changes. Use one comprehensive draft PR targeting official dev. +4. Complete N0 through N6 from the [Notes roadmap](notes-roadmap.md) on that same + branch, recording tests, review and rollback evidence at each checkpoint. Include + the final reference case study in the same PR and keep it a draft until the full + decomposition and regression/compatibility acceptance gates pass. Recurrence + extraction follows characterization as an internal stage, not another PR. +5. Complete H only after Notes integration and recipient reproduction. Transfer + future frontend work to the designated maintainer and stop this program. + +Another maintainer can start at the handbook, run the documented source/parity, +architecture, runtime and behavior checks, inspect this report and select the +next authorized slice without relying on unwritten architecture decisions. diff --git a/docs/maintainers/README.md b/docs/maintainers/README.md new file mode 100644 index 000000000..bd5e93f24 --- /dev/null +++ b/docs/maintainers/README.md @@ -0,0 +1,38 @@ +# Odysseus maintainer handbook + +Odysseus maintainers are trusted to act, not to wait for permission. The default is individual responsibility; additional review is required when the **risk** warrants it. + +This handbook records the project's maintainer governance and gives reviewers a reusable process. It complements [CONTRIBUTING.md](../../CONTRIBUTING.md) and [SECURITY.md](../../SECURITY.md); it does not replace the PR template, repository protections, or product decisions. + +## At a glance + +| Area | Working rule | +|---|---| +| Product direction | Pewds is the Product Owner | +| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks | +| External contribution | Maintainer review required | +| High-risk change | At least one **independent, qualified** maintainer review | +| Access | Need-to-have, least privilege, separately granted per system | +| Release | A designated Release Maintainer owns preparation and publish | +| Inactivity | Access review after 2 months without activity, notice, or response | +| Documentation | Repository Markdown is canonical | + +These are governance rules, not guarantees that a GitHub permission or branch-protection setting permits a particular action. Respect the stronger applicable automated gate. + +## Start here + +- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity. +- [Product and feature proposals](product-and-features.md): product approval versus technical ownership. +- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections. +- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence. +- [Triage](triage.md): turning incoming reports into actionable work. +- [Testing and releases](testing-and-releases.md): evidence, platform validation, promotion to main. +- [Documentation](documentation.md): canonical sources, updates, and traceability. + +## Accountability + +Maintainers own the changes they take on, including validation, regression prevention, follow-up, and rollback when needed. Roles describe responsibilities, not a hierarchy of technical approval. + +Important product-direction decisions go through the Product Owner. Maintainers retain responsibility for feasibility, implementation, maintainability, and security. + +AI coding or review agents can help collect evidence, test hypotheses, and prepare changes, but they are not the qualified human reviewer required for high-risk changes. A maintainer must inspect and accept the evidence. diff --git a/docs/maintainers/changes-and-review.md b/docs/maintainers/changes-and-review.md new file mode 100644 index 000000000..664b2596a --- /dev/null +++ b/docs/maintainers/changes-and-review.md @@ -0,0 +1,62 @@ +# Changes and review + +Review is **risk-based**. Trusted maintainers should be able to ship ordinary work without creating a queue of ceremonial approvals. + +## Required review + +| Change | Minimum review rule | +|---|---| +| Ordinary, reversible maintainer change | Independent approval optional; self-merge permitted after appropriate validation | +| External contribution | Maintainer review required | +| High-risk change | At least one independent, qualified maintainer review required | + +Repository branch protection and required checks also apply. Never treat this document as permission to bypass a live protection rule. + +## Ordinary maintainer changes + +1. Open a PR against `dev`, scoped to one coherent change. +2. Explain intent, expected behavior, validation, and rollback if relevant. +3. Run suitable tests and inspect the actual result. +4. Ensure applicable required checks pass, address feedback, and merge when ready. + +Self-merge is allowed for ordinary reversible work. The author remains responsible for follow-up, correction, and rollback. + +## External contributions and small corrections + +External contributions receive maintainer review. + +A maintainer may directly fix a **small, obvious** problem in a contributor PR when the intended behavior is clear and the repair is faster than a separate round trip. Document what changed, preserve contributor attribution and history, avoid force-pushing, and re-run affected checks. + +Substantive or ambiguous corrections require discussion with the contributor or a separately scoped correction. A maintainer must not silently turn a contributor's PR into unrelated work. Any corrective commit changes the SHA being reviewed; update validation and audit evidence accordingly. + +## High-risk changes + +High-risk work requires **at least one independent qualified review**. The author cannot be the only approver. Examples: + +- Authentication, authorization, tenancy, sandboxing, agent tool permissions, or secrets. +- Outbound network and SSRF defenses, sensitive integrations, and deployment access. +- Destructive operations, deletion, filesystem ownership, and data migrations. +- Persistence and concurrency invariants with possible data loss. +- Major architectural contracts, release infrastructure, or changes hard to roll back. + +A high-risk review should verify the threat or failure model, exact final commit, reproducer or regression evidence, failure and rollback behavior, and the relevant CI/security findings. Use the [review playbook](review-playbook.md). + +## Cross-area changes and CODEOWNERS + +Touching multiple areas does **not** automatically require multiple approvals. Consult domain expertise when needed. `.github/CODEOWNERS` currently contains no ownership rules; the per-area map is under discussion in [issue #593](https://github.com/odysseus-dev/odysseus/issues/593). Once populated, it helps identify reviewers and accountable contacts; it is not a blanket approval requirement by itself. + +## Validation and UI evidence + +Automated tests count as runtime evidence when they truly exercise the changed behavior. Use manual validation when coverage is insufficient. + +For any change to visible UI behavior, follow the current [contributor UI checklist](../../CONTRIBUTING.md#style-and-visual-changes) and the repository PR template. Screenshots or clips are expected when they materially demonstrate the rendered result; screenshots alone do not replace behavioral tests. + +## Disagreement and escalation + +The maintainer doing the work owns ordinary implementation decisions within approved product direction and existing technical contracts. For substantive disagreements: + +1. Involve a qualified maintainer with relevant expertise. +2. Escalate unresolved cross-cutting technical issues to trusted project-wide maintainers. +3. Refer product-direction disagreements to the Product Owner. + +Security findings should be handled via [SECURITY.md](../../SECURITY.md), without disclosing exploit details in public issues. Repository protection rules should support these risk-based policies and should not accidentally force independent approval for every ordinary maintainer PR. diff --git a/docs/maintainers/documentation.md b/docs/maintainers/documentation.md new file mode 100644 index 000000000..36c88d81d --- /dev/null +++ b/docs/maintainers/documentation.md @@ -0,0 +1,32 @@ +# Documentation + +Repository Markdown is the canonical source for project documentation. A public docs site may be generated from it; avoid maintaining an independent wiki copy unless there is a deliberate future decision to do so. + +## Where information belongs + +| Location | Purpose | +|---|---| +| `/design` | Product intent, proposals, and accepted decisions (when those paths are used) | +| `/specs` | Technical contracts and invariants | +| `/website` | User, setup, configuration, and deployment guidance (published as the docs site) | +| `/docs` | Developer, architecture, security, and maintainer guidance | +| Root Markdown | Discoverable entry points and policies | + +Keep root documents easy to find: + +- `README.md` +- `CONTRIBUTING.md` +- `SECURITY.md` +- `THREAT_MODEL.md` + +Not every named directory exists today; do not invent a parallel documentation store just to match this map. + +## Updating documentation + +When behavior, architecture, a workflow, or its verification requirements change, update the canonical documentation in the same PR when appropriate. + +Separate **policy** from **examples**: examples may evolve with tooling; changes to role authority, review requirements, or product ownership require an explicit governance decision. Do not introduce a new mandatory approval rule through a checklist alone. + +Documentation PRs should link the relevant issue, record what was reviewed (links, commands, or affected features), and avoid publishing private channels, account names, local paths, secrets, or confidential incident details. + +For practical examples of review evidence, see the [review playbook](review-playbook.md). For contributor setup and PR requirements, use [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/docs/maintainers/product-and-features.md b/docs/maintainers/product-and-features.md new file mode 100644 index 000000000..d41055a7f --- /dev/null +++ b/docs/maintainers/product-and-features.md @@ -0,0 +1,48 @@ +# Product and feature proposals + +Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety. + +## Which changes require product approval? + +**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse. + +**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction. + +Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery. + +### Urgent security and reliability fixes + +Urgent fixes or containment may proceed without prior product approval, even if behavior temporarily changes. Keep the change to what stops the immediate problem, notify Pewds promptly, and record the reason and rollback or recovery plan. Agree on any lasting product-direction change with Pewds afterwards. Normal high-risk review and repository checks still apply. + +## Product Owner initiated + +```text +Product Owner proposal or implementation + -> technical feasibility + -> plan and implementation + -> risk-based review and validation +``` + +A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review. + +## Community or maintainer initiated + +Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas). + +```text +Feature proposal + -> Product Owner accepts / rejects, where approval is required + -> technical feasibility + -> plan and implementation + -> risk-based review and validation +``` + +Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision. + +## Decision boundaries + +The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how approved work is built safely and maintainably**. + +A rejected or out-of-scope feature can be maintained independently in a contributor's fork. + +If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above. diff --git a/docs/maintainers/review-playbook.md b/docs/maintainers/review-playbook.md new file mode 100644 index 000000000..e9f0e6f99 --- /dev/null +++ b/docs/maintainers/review-playbook.md @@ -0,0 +1,129 @@ +# Practical PR review playbook + +Use this as a **repeatable review method**, not as an extra organizational approval ladder. The binding minimums remain in [changes and review](changes-and-review.md): ordinary maintainer changes may be self-merged, external PRs require maintainer review, and high-risk work requires at least one independent qualified maintainer review. + +No single checklist replaces engineering judgment. Increase depth with risk. + +## 1. Understand the PR and establish its review gates + +- Establish the problem, what the PR fundamentally proposes, and why the approach makes sense. +- Check product direction, architecture fit, and scope before detailed review. +- Write down the questions the review must answer, the evidence needed, and what would block further review or approval. +- Confirm repository, source fork, target branch (`dev`), exact **HEAD SHA**, and current base. +- Review the full effective diff, commit history, issue, prior feedback, and related upstream changes. +- Confirm the branch is mergeable against the **current** `dev`, not an older local snapshot. +- Identify the user-facing contract, affected data and users, security boundaries, and rollback plan. +- Separate pre-existing upstream problems from regressions introduced or made reachable by the PR. + +Example read-only commands: + +```bash +REPO="odysseus-dev/odysseus" +PR=1234 +gh pr view "$PR" --repo "$REPO" --json title,state,isDraft,headRefOid,baseRefName,mergeable +gh pr diff "$PR" --repo "$REPO" +gh pr checks "$PR" --repo "$REPO" +``` + +A green check on an old commit does not verify the revised branch. + +## 2. Review the failure and threat model + +For the relevant change, ask what happens with malformed input, missing auth state, wrong owner, concurrent requests, stale caches, partial failure, retries, cancelled jobs, unavailable dependencies, and restart/recovery. + +Prioritize boundaries that can lose data or authority: identity, delegated API tokens, filesystem confinement, email deletion, persistence versioning, network/SSRF destinations, secrets, and agent tool execution. + +For security claims, demonstrate the source-to-sink path and the *actual* guard. Tests must represent production middleware or transport where a unit mock could conceal a bypass. + +## 3. Correct without obscuring contributor work + +For small, unambiguous issues, maintainers may add focused corrections directly to a contributor PR under [the contribution review policy](changes-and-review.md#external-contributions-and-small-corrections). Preserve provenance, explain the change, prefer normal commits and fast-forward pushes, and avoid unrelated rewrites. + +For substantive or ambiguous work, ask the contributor or prepare a clearly separated patch. Any code change means validating **the new SHA**, not approving based solely on the previous audit. + +## 4. Build an evidence record + +Run contributor code under the [runtime isolation rules](testing-and-releases.md#runtime-isolation). + +Useful evidence includes: + +- Pre-fix reproduction, expected outcome, and actual outcome. +- Focused regression tests for the fix and broad suites relevant to cross-cutting effects. +- Actual runtime checks (browser/UI, backend, Docker, supported OS) where necessary. +- Exact executed commands, result counts, skipped cases, and environmental limitations. +- GitHub required checks and conflict-free state on the exact final SHA. +- Relevant operational consequences (data migration, rollback, compatibility). + +Keep audits reproducible. A screenshot alone is not a functional test, and a passing mocked test is not proof that a production integration path is safe. + +## 5. Independent review for high-risk changes + +A separate **qualified maintainer** must inspect high-risk changes; the implementing author cannot be the sole reviewer. Independent coding agents may be useful for adversarial tests and cross-checks, but **an AI agent is not itself a GitHub approver** and does not replace human accountability. + +To avoid confirmation bias, give the independent reviewer the current SHA, changed files, acceptance criteria, known reproduction, and explicit permission to challenge the proposed solution. Preserve unresolved findings and require a new audit when meaningful fixes change the diff. + +## CodeQL and security scanning + +A successful CodeQL workflow means analysis ran, not that the security dashboard is empty. Likewise, resolving a PR discussion does not necessarily resolve its code-scanning alert. + +For each relevant alert: + +1. Record the alert number, rule, severity, file/line, state, and source-to-sink trace if applicable. +2. Compare with the current `dev` baseline; decide whether the PR introduced, exposed, or did not affect it. +3. Reproduce or reason through the actual production behavior, including validation and authorization. +4. **Fix** credible vulnerabilities, preferably with a regression test. Do not hide them by broad suppressions. +5. **Recommend dismissal** only for a demonstrable false positive or a stale/invalid finding, with a precise technical explanation; only authorized maintainers should dismiss alerts. +6. Document genuine but intentionally accepted risk separately. A true positive is not a false positive merely because it is deliberate. +7. Rerun or inspect updated analysis on the final commit and confirm no blocking issues remain. + +Never publish exploit payloads, secrets, or credentials in public review comments. See [SECURITY.md](../../SECURITY.md). + +## 6. Decide and integrate + +Before approval or self-merge, verify: + +- Current branch and reviewed SHA are still the ones to be merged. +- All applicable required checks are complete and successful. +- High-risk independent review is present, where required. +- No unresolved, credible P0/P1/P2 security or data-integrity defects remain in the change. Resolve or explicitly document P3 observations with a tracked owner and priority. +- Review discussions, compatibility risks, and operational handoffs have been addressed. +- The correct target is `dev`; `main` is curated for releases. + +This P0-P3 classification is [triage guidance](triage.md#practical-priority-language), not a replacement for branch protection or an excuse to self-approve high-risk work. Maintainers may apply a stricter bar where warranted. + +For a final manual merge, GitHub CLI supports an exact-head guard: + +```bash +gh pr merge 1234 --repo odysseus-dev/odysseus --squash \ + --match-head-commit +``` + +Use `--delete-branch` only when removing the source branch is appropriate and does not disrupt another workstream. Do not force a merge past review or security requirements. + +## 7. Post-merge follow-up + +Verify the PR actually merged and reference its integration commit. Record remaining operational work, such as schema migrations, backup/restore verification, service restarts, or compatibility documentation. A code change to a container mount does **not** by itself migrate existing runtime data. + +Do not silently mark a deferred data migration complete; track its owner, evidence, rollback, and execution separately. + +## Reusable reviewer handoff + +```text +PR / target: +Reviewed source HEAD SHA: +Current target branch SHA: +Scope and changed files: +Impact and risk classification: +Security / correctness invariants challenged: +Pre-fix reproducer and post-fix evidence: +Tests run, skipped, and environment: +CodeQL / scanning alert dispositions: +Required GitHub checks (exact SHA): +Mergeability and remaining conflicts: +Unresolved P0 / P1 / P2 / P3 findings: +Rollback / operational follow-ups: +Independent qualified review required? Completed? +Verdict: APPROVED / CHANGES REQUIRED / BLOCKED +``` + +State what was actually verified. Do not write "all checks green" if only a subset of workflows was inspected. diff --git a/docs/maintainers/roles-and-access.md b/docs/maintainers/roles-and-access.md new file mode 100644 index 000000000..a98fd3258 --- /dev/null +++ b/docs/maintainers/roles-and-access.md @@ -0,0 +1,40 @@ +# Roles and access + +Roles clarify who is accountable. They do not create a management chain or automatically grant privileges. + +## Responsibility and typical access + +| Responsibility | Typical GitHub access | Purpose | +|---|---|---| +| Product Owner | No automatic repository access implied | Product direction | +| Organization Owner | Organization Owner | Organization administration and continuity | +| Repository Admin | Admin | Repository settings, permissions, and protection rules | +| Maintainer | Write by default | Implement, review, merge, and maintain | +| Triage | Triage | Organize intake, de-duplicate, and prepare work | +| Contributor / Platform Tester | Public / Read as appropriate | Contribute code or provide validation | + +Maintain access may be granted when repository-management duties require it. + +## Maintainer focus + +- **Core Maintainer:** project-wide judgment, difficult technical decisions, governance support. +- **Technical Maintainer:** implementation, architecture, validation, technical health. +- **Project Maintainer:** review, contributor coordination, documentation, releases, organization. + +These are responsibility labels. No label automatically changes GitHub permissions or overrides the [review rules](changes-and-review.md). + +## Functional responsibilities + +Maintainers may coordinate Release, Security, Infrastructure and CI, Community, or Test and Validation. These are duties, not ranks. For each release, designate one Release Maintainer and record the handoff if ownership changes. + +## Least privilege and continuity + +Grant access because it is necessary for current work, not because of a title. GitHub, Matrix, Plane, infrastructure, SSH, deployment systems, secrets, and external services are separate access surfaces. + +For elevated or sensitive access, document a responsible owner and recovery route in an appropriate **private** administrative system. Do not publish tokens, SSH details, personal addresses, or credential locations in this handbook. Remove access promptly when it is no longer needed. + +## Inactivity + +Tell an admin when you expect to be unavailable; a communicated absence is fine. + +If there is no meaningful maintainer or triage activity for **two months**, no prior notice, and no response to reasonable follow-up, elevated access may be reviewed and removed. This is access hygiene, not punishment. Returning members may regain access appropriate to their active responsibilities. diff --git a/docs/maintainers/testing-and-releases.md b/docs/maintainers/testing-and-releases.md new file mode 100644 index 000000000..23ab33853 --- /dev/null +++ b/docs/maintainers/testing-and-releases.md @@ -0,0 +1,52 @@ +# Testing and releases + +Validation must match the actual change, and evidence must state what was **run**, **observed**, and **not tested**. + +`dev` is the integration and stabilization branch. `main` is the curated release destination. Changes land in `dev`; a release promotes a verified `dev` revision to `main`. + +## Runtime isolation + +Run contributor-controlled tests, builds, installs, and repros in an isolated, secretless environment. Use disposable containers or microVMs where appropriate, with no credentials, sensitive host files, production data, or host/container-management sockets exposed. If safe isolation is unavailable, record the check as not run. + +Agents may use a skill for the maintainer's preferred environment and handling; the same isolation requirements apply. + +## Change validation + +- Run focused tests that cover the modified behavior and failure cases. +- Run broader regressions where risk crosses module boundaries. +- For authentication, data loss, or similar high-risk work, include adversarial and rollback tests. +- Reproduce the issue on the pre-fix version when safe and practical, then prove the correction. +- For visual changes, run the application and attach the screenshots or clips required by [CONTRIBUTING.md](../../CONTRIBUTING.md). +- Record exact commands, environment, result, skipped tests, and blockers. Never claim execution from a command you did not run. + +GitHub Actions success means a workflow finished successfully; it does **not** mean CodeQL found no alerts. Use [review playbook](review-playbook.md#codeql-and-security-scanning) to assess findings separately. A review must tie required CI and the final diff to the **same commit SHA**. + +## Supported-environment evidence + +Platform Testers may validate without maintainer permissions. Useful coverage includes: + +- Windows native and WSL. +- macOS Apple Silicon. +- macOS Intel while supported. +- Linux native on the reference distribution. +- Docker on supported hosts. + +Record the environment and configuration for each result. Do not imply that a platform was tested when it was not. + +## Release ownership + +A designated **Release Maintainer** owns preparation and the publish decision once the release criteria are met. Other maintainers and testers supply the evidence needed for the change set. If the Release Maintainer becomes unavailable, another maintainer explicitly takes over. + +## Release checklist + +Before promoting `dev` to `main`: + +1. Identify and freeze the release candidate SHA; check the actual content that will ship. +2. Verify required checks and relevant supported-environment validation. +3. Confirm high-risk and data-migration findings are resolved or addressed through an explicit approved plan. +4. Document skipped coverage, known issues, compatibility and upgrade implications. +5. Prepare the version, tag, artifacts, and release notes. +6. Confirm rollback/recovery, including any manual persistence or data migration steps. +7. Publish and record what was promoted, the verification evidence, and operational follow-ups. + +Publishing does not require ceremonial Core or domain sign-off when the criteria are met. Exceptional or disputed releases follow the normal high-risk escalation path. diff --git a/docs/maintainers/triage.md b/docs/maintainers/triage.md new file mode 100644 index 000000000..d2f47f8ab --- /dev/null +++ b/docs/maintainers/triage.md @@ -0,0 +1,48 @@ +# Triage + +Triage turns reports into evidence-backed work that a maintainer can pick up without guessing. + +## Intake flow + +```text +Report + -> reproduce / clarify + -> identify duplicates and affected versions + -> classify impact, urgency, and risk + -> prepare actionable work item + -> maintainer takes ownership + -> PR, verification, and follow-up +``` + +## A useful work item + +Record what is known and distinguish it from what remains unverified: + +- Original report and reproducible steps; installation method, OS, version/commit, and relevant environment. +- Expected versus actual behavior, including user-visible impact. +- Evidence: sanitized logs, screenshots, failing tests, or controlled reproduction. +- Related issues, PRs, duplicates, or a known prior regression. +- Scope, acceptance criteria, and non-goals. +- Priority, ownership, and relevant domain expertise. +- Remaining unknowns, risks, and safe recovery or workaround when available. + +Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md). + +## Practical priority language + +Use P0-P3 as **triage shorthand**, not as a substitute for verified exploitability or the project's actual GitHub labels: + +| Priority | Typical impact | Expected response | +|---|---|---| +| P0 | Active exploitation, broad irreversible loss, catastrophic outage | Immediate containment and escalation | +| P1 | Credible severe security or data-integrity vulnerability | Urgent owner assignment and validated fix | +| P2 | Significant correctness, security, or operational regression | Prioritize and resolve before approving affected high-risk changes | +| P3 | Limited impact, maintainability, or low-risk polish | Fix or explicitly track with rationale | + +Do not call a concern harmless merely because an automated check is green. Escalate privately when reproduction would disclose a vulnerability. + +## Where decisions live + +Matrix is for coordination; durable decisions belong in GitHub, Plane, a specification, or the relevant PR. Keep confidential security and access details in appropriate private channels. Do not leave important project decisions only in chat history. + +See the [review playbook](review-playbook.md) for converting triage into review evidence and handling contested or uncertain findings. diff --git a/docs/searxng-trust-image.md b/docs/searxng-trust-image.md new file mode 100644 index 000000000..e0f406e82 --- /dev/null +++ b/docs/searxng-trust-image.md @@ -0,0 +1,39 @@ +# Upstream SearXNG trust validation + +SearXNG owns independent system and Python certificate stores. Updating Odysseus's Python environment does not update this sidecar. The base and both standalone GPU Compose files now select the official `docker.io/searxng/searxng:2026.10.24-ef0ec03da` image at immutable multiarch digest `sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8`. Its system and certifi stores remove the affected eMudhra trust. The [upstream certifi update](https://github.com/searxng/searxng/pull/6881) is included in this release. + +Deploy the upstream image directly. The validation Dockerfile adds public fixtures and diagnostics only; no repair layer, custom runtime image or project registry publisher is required. `scripts/check_ca_trust.py` owns the known-certificate inventory for both the application and SearXNG checks. + +## Validate a pin update + +Run from the repository root on a disposable Docker host. The helper uses a fresh configuration volume, omits published ports and does not load the operator's `.env`. It retains the base Compose settings wrapper, healthcheck and capabilities. Outbound network access and working search engines are required for the live checks. + +```sh +image=$(docker compose --env-file /dev/null -f docker-compose.yml config --format json | + python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["searxng"]["image"])') + +# Offline build-step checks, including actual entrypoint and loopback health. +# Pulling the selected base still requires registry access. +docker build --network=none --target validation \ + --build-arg "SEARXNG_IMAGE=$image" -f docker/searxng/Dockerfile . + +# Exercise the exact unmodified deployment image after an anonymous pull. +docker pull "$image" +bash .github/scripts/validate-searxng-image.sh "$image" +``` + +The offline checks require all fourteen incident chains to fail certificate verification through the system PEM bundle, hashed CA directory and certifi. The affected positive-control roots must accept those same chains. Each fixture's DER fingerprint is verified first, and a fixed verification timestamp prevents expiry from hiding a broken mitigation. Checks run again after the real upstream root entrypoint regenerates trust. + +The live checks use SearXNG's actual curl client factory. Ordinary verified HTTPS must succeed, all fourteen incident hosts must fail with curl certificate error 60, and a JSON search must return results. DNS failures, timeouts and generic handshake failures are inconclusive and fail validation. The checks inspect the already-running service without changing its trust stores. + +The `ci / SearXNG trust` workflow runs these offline and Compose/live checks on native amd64 and arm64 runners. It selects the same immutable image as base Compose, pulls without registry credentials, and has read-only repository permissions. It neither publishes a replacement image nor requires package-write credentials. Update all three deployment pins together; the alignment regression guards the standalone GPU files. + +## Rollout and scope + +Changing a Compose pin does not replace running containers. After updating the checkout, run `docker compose pull searxng` and `docker compose up -d --no-deps searxng`, then inspect health, retained settings and actual Odysseus search integration. Use the appropriate standalone GPU Compose file when that is the deployment being managed. + +Operator-mounted trust, custom engines, persisted packages and independently managed clients need separate effective-client checks. An amd64 result does not establish arm64 behavior. The fixture-based check detects known certificate fingerprints; it does not model every native revocation mechanism or arbitrary reissued certificates sharing a compromised key. See [outbound TLS trust](tls-trust.md) for the application, bootstrap, Chromium and other runtime boundaries. + +## Public fixture provenance + +The public CA inventory comes from the incident's [roots](https://emudhra.we-have-a-problem.com/roots.json) and [intermediates](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). The additional cross-signed issuer and fourteen chain fixtures come from [chains.zip](https://emudhra.we-have-a-problem.com/chains.zip), SHA-256 `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`. Fixtures contain public certificates only. See [the fixture notes](../tests/fixtures/ca-trust/README.md). diff --git a/docs/skills-audit-architecture.md b/docs/skills-audit-architecture.md new file mode 100644 index 000000000..78227a763 --- /dev/null +++ b/docs/skills-audit-architecture.md @@ -0,0 +1,379 @@ +# Skills evaluation and audit architecture + +The Skills backend separates HTTP transport, model evaluation, agent execution, +audit orchestration and publication policy. `services/skills/` coordinates those +responsibilities; `services/memory/skills.py` remains the canonical persistence +and lifecycle owner. The extraction preserves the existing HTTP and Python +contracts. [PR #6711](https://github.com/odysseus-dev/odysseus/pull/6711) tracks +this boundary change under [issue #6710](https://github.com/odysseus-dev/odysseus/issues/6710). + +## Responsibilities and dependency boundaries + +| Module | Responsibility | +| --- | --- | +| `routes/skills_routes.py` | FastAPI request models, endpoint declarations, authentication, ownership, HTTP errors, exact approval validation and Python compatibility bindings | +| `services/skills/job_coordinator.py` | Transient job creation, continuation after approval validation, candidate selection, public polling projections and cancellation requests | +| `services/skills/agent_runner.py` | Manual execution, audit arms, agent loop integration, transcripts, authority propagation, approval pauses and same-task stream closure | +| `services/skills/audit_workflow.py` | Per-skill audit sequencing, self-edit retries, teacher escalation, sequential batches and scheduled execution | +| `services/skills/evaluation.py` | Model grading, verdict parsing, necessity and retrieval checks, efficiency fields and markdown improvement generation | +| `services/skills/publication.py` | Confidence and publishing policy, duplicate rules, batch finalization and markdown updates through SkillsManager | +| `services/skills/model_selection.py` | Owner-scoped worker/teacher resolution, utility fallback, endpoint selection and credential lookup | +| `services/skills/prompts.py` | Shared task construction and explicitly untrusted skill context | +| `services/skills/dependencies.py` | Frozen `AuditDependencies` containing callable bindings and transient registry references | +| `services/memory/skills.py` | Canonical skill identity, owner isolation, durable storage, indexing, retrieval and lifecycle state | + +The package initializer identifies the domain without adding a service facade. +Evaluation does not schedule jobs or persist skills. Publication computes policy +and applies updates through the supplied SkillsManager. Services do not import +`routes.skills_routes`. The existing preferences adapter in `publication.py` +still imports `routes.prefs_routes`; moving that adapter is a separate change. + +```mermaid +flowchart TD + HTTP[Authenticated HTTP requests] --> Routes[skills_routes] + Builtins[builtin_actions callers] --> Routes + Routes --> Jobs[job_coordinator] + Routes --> Models[model_selection] + Routes --> Evaluation[evaluation] + Routes --> Runner[agent_runner] + Routes --> Workflow[audit_workflow] + Jobs -->|injected calls| Runner + Jobs -->|injected calls| Workflow + Workflow -->|injected calls| Runner + Workflow -->|injected calls| Evaluation + Workflow -->|injected calls| Publication[publication] + Runner -->|injected calls| Evaluation + Runner --> Runtime[Agent runtime and authority enforcement] + Runner --> Prompts[prompts and untrusted context] + Jobs --> Prompts + Workflow --> Prompts + Routes --> Manager[Canonical SkillsManager] + Runner --> Manager + Workflow --> Manager + Publication --> Manager + Publication --> Preferences[Existing preferences adapter] + Models --> Resolver[Endpoint resolver and owner-scoped credentials] +``` + +Arrows include injected calls, rather than only Python imports. The workflow +imports `SkillAuditUnavailable` from the runner. Route wrappers construct +`AuditDependencies` on entry from the current route bindings, so existing +callers and monkeypatches reach the same collaborators without rebinding shared +service globals. + +The manual test registry belongs to `agent_runner.py`; the audit registry belongs +to `audit_workflow.py`. Routes expose aliases to those dictionaries. Registries +hold process-local job state and are not durable skill stores. All durable +metadata, confidence, markdown and publication changes use SkillsManager. +Published-only retrieval and local exclusion of `remote-only` skills stay in +the manager/index boundary. Service loggers use their module names; the route +module retains its own logger. Root handlers supply the existing log output. + +## Execution and publication lifecycle + +1. The HTTP handler authenticates the caller, verifies ownership and validates + the request. Manual model choices retain registered-endpoint validation and + owner-scoped credential lookup. +2. Job coordination creates the owner-keyed transient record and launches the + execution coroutine. Replacing a manual test retires its pending approval. + Polling exposes only the existing public fields, excluding credentials, + private transcripts, continuation state and asyncio tasks. +3. The runner uses the originating request authority or the consumed sealed + approval authority. It captures the transcript and closes the async stream + with `aclosing` inside the task that opened it, including early approval + pauses, cancellation and exceptions. +4. Manual approval pauses return to the HTTP validation boundary. Continuation + reconstructs context from the original untrusted skill and accumulated + untrusted transcript. Manual completion records audit metadata and adjusts + confidence (pass .95, needs_work .6, fail .4) without publishing. +5. An automated audit checks deterministic duplicates, advisory necessity and, + when indicated, retrieval precision. It runs skill and no-skill arms on the + same task, grades the result, then may self-edit and retry. An optional + distinct teacher rewrites the skill; the worker performs the retry. +6. Publication applies owner preferences, confidence thresholds, necessity, + utility and duplicate rules through SkillsManager. A normal sequential batch + finalizes its processed results. Cancellation or infrastructure unavailability + skips batch finalization and clears task/current state. + +A clean audit pass sets confidence to .95, a self-edit pass to .85 and a teacher +rewrite pass to .8. Continued failure becomes draft at .35. Unknown and +inconclusive verdicts preserve publication state. Automatic publishing requires +an enabled owner policy, a passing verdict at or above the configured threshold, +and no necessity, worse-baseline or duplicate blocker. The default threshold is +.85; owner values are clamped to [0, 1]. Markdown edits pin the existing skill name +so usage and audit sidecars remain attached to the same identity. + +Per-skill duplicate blocking and batch duplicate finalization have distinct +selection rules and must not be consolidated without a behavior proposal. +Batch finalization excludes builtins and binned records, changes only processed +skills, protects approval-required results, and bins duplicate losers. Duplicate +similarity uses the existing .38 threshold and name-suffix grouping. + +Batches run sequentially per owner, retain current/done/results and timestamps, +and cap progress logs at 1,000 entries. Ordinary exceptions record inconclusive +skill audits. Infrastructure failures stop the batch without inventing a skill +verdict. Scheduled execution skips an active owner job, selects existing lifecycle +candidates by oldest audit, caps the batch (default eight), and marks model phases +as background work. These services retain the existing scheduling and +cancellation semantics. + +## Security and approval invariants + +HTTP authentication and owner verification remain at the transport boundary. +Manager reads and writes receive the owner. Ownerless builtins retain their +existing visibility; an authenticated user cannot access an ownerless ordinary +skill. Existing ownerless deployment behavior is preserved. + +The `/test-approval` handler validates the pending job, approval ID, decision, +owner and empty session binding, then destructively consumes the sealed approval +with `allow_continuation=False` before calling the coordinator. Stale, mismatched +or reused approvals fail with 409. The consumed grant authorizes only its exact +action. Services do not manufacture approval authority or relax process identity, +workspace confinement or request-authority enforcement in the agent runtime. + +Skill markdown and resumed transcripts remain explicitly untrusted context, +never trusted system instructions. Audit arms suppress saved skill injection. +Unattended runs deny encountered approval records instead of leaving reusable +grants. An initial approval-required audit result preserves the skill's publication +and confidence and directs the user to manual approval. Retry handling has an +inherited gap: an approval pause after a rewrite can still lead to demotion. +Same-task stream closure preserves the originating ContextVar cleanup, but manual +cancellation can retain private continuation state. These are separate hardening +items, not guarantees supplied by the extraction. + +## Model selection and evaluation + +Audit resolution uses an explicit endpoint/model pair when supplied, looking up +credentials among enabled owner-visible endpoints. A model-only choice uses the +existing owner-scoped model resolver. Otherwise it resolves the utility model, +including the resolver's default behavior, then takes the first configured +utility fallback if the primary result is incomplete. A missing worker raises +`ValueError`. Served-model normalization keeps an exact match, then a basename +match, then the first served ID; discovery exceptions keep the resolved choice. + +An enabled teacher resolves separately with the same owner. Its credentials are +not borrowed from the worker. Teacher resolution failure logs the existing warning +and leaves the teacher absent. Manual tests retain their HTTP endpoint validation +and registered fallback credentials. + +Owner reasoning effort is resolved for each evaluation or rewrite call. Workload +flags, credentials, retries, prompts and permissive parsing remain unchanged: + +| Phase | Token budget | Timeout in seconds | +| --- | --- | --- | +| Run review, up to two attempts | 32768 | 180 | +| Necessity | 8192 | 120 | +| Retrieval precision | 4096 | 90 | +| Markdown improvement | 16384 | 180 | + +Review parsing retains think-block stripping, trailing-comma recovery, last +usable verdict selection, prose fallback, bounded fields and unknown outcomes. +These model outputs are advisory and do not grant execution authority. + +## Public HTTP contract + +All paths below have prefix `/api/skills`. Successful status defaults to 200. +Request models and endpoint declaration order remain fixed. A frozen +OpenAPI and declaration-order fixture records +the baseline contract independently of the extracted implementation. + +| Method and suffix | Successful response | Authorization and errors | +| --- | --- | --- | +| GET empty | `skills,count` | Owner-filtered load | +| GET `/index` | `index,count` | Owner-scoped eligible index | +| GET `/slash-catalog` | `skills,count` | Owner-scoped index; sorted slash entries | +| GET `/builtin` | `builtin,count` and optional `error` | Existing shared capability text | +| GET `/builtin/{name}` | `name,text,default,is_overridden` | 404 missing tool; 500 import failure | +| PUT `/builtin/{name}` | `ok,name,is_overridden` | Admin; 404 unknown, 400 missing text | +| DELETE `/builtin/{name}` | `ok,name,is_overridden` | Admin | +| POST `/import-from-url` | `ok,skill,files` | Admin and current owner; 400 import, 502 HTTP, 500 other failure | +| POST `/add` | `ok,deduped,skill` | Current owner passed to manager; unchanged old/new request fields | +| POST `/{skill_id}/invoke` | `ok,type,name,command,message` | Eligible owner index only; 404 unavailable/source missing | +| GET `/{skill_id}` | Full skill dictionary | Owner load; 404 missing | +| GET `/{skill_id}/markdown` | `name,markdown` | Owner verification; 404 missing/source missing | +| POST `/{skill_id}/test` | `ok,status,skill,model` | Owner verification, HTTP request authority, registered fallback credentials; 400 missing/invalid model | +| POST `/{skill_id}/test-approval` | `ok,status,decision` | User approval request, owner verification, pending ID/owner/empty session match; 400 invalid body/decision, 409 stale/mismatch/consumed | +| GET `/{skill_id}/test-status` | `status` or `status,task,model,log,verdict,approval` | Owner-keyed lookup; server continuation/credentials omitted | +| POST `/audit-all` | `ok,status,total` plus running `done,model` or empty `results,log` | Owner-keyed singleton; 400 model resolution | +| GET `/audit-all/status` | `status` or job progress/result/log/timestamps | Owner-keyed lookup; task and credentials omitted | +| POST `/audit-all/cancel` | `ok,status` | Owner-keyed lookup and cancellation | +| POST `/{skill_id}/markdown` | `ok,name` | Owner verification; 400 missing/parse, 404 missing, 500 update | +| PUT `/{skill_id}` | `ok` | Owner verification and scoped manager update; 404 missing | +| DELETE `/{skill_id}` | `ok` | Owner verification and scoped manager deletion; 404 missing | +| POST `/search` | `skills,query,count` | Owner-filtered input; 400 empty query | + +Strict owner verification allows an ownerless builtin but denies an ownerless +ordinary skill for an authenticated user. The existing ownerless deployment +behavior remains. Global auth middleware is unchanged. + +## Python compatibility and frozen contracts + +`app.py` imports `setup_skills_routes`. `src/builtin_actions.py` uses the existing +route helpers and audit registry. Route aliases and wrappers preserve the original +28 inspectable interfaces, `SkillAuditUnavailable`, registry identity and patchable +collaborators. Lazy model/agent imports and route-level effort injection keep +existing offline test seams effective. + +The task and message callbacks are bound through `AuditDependencies` at service +entry. Patches to route-level test tasks and test/baseline messages reach manual +execution, approval continuation, both audit arms, worker retries and scheduled +audits. The `_skill_baseline_task` compatibility alias delegates to the current +route test-task builder. Execution continues to reuse one task for both arms, +as in the original implementation; it does not independently derive a baseline +task through that alias. + +`tests/fixtures/skills_api_baseline.json` records declaration order and structural +signatures captured from pre-extraction revision +`86b886c611338282af6d4a717246ea4e256f58df`. Signature records retain parameter +names, order, kinds, defaults, default factories and annotations, including +Pydantic constraint metadata. They compare typing origins and arguments instead +of Python-version-dependent annotation representations. The expanded records also +pin field validation aliases, alias priority, discriminators, `validate_default`, +rebuilt field annotations and model configuration. Field order remains visible +through the constructor parameter sequence. Only the known builtin factories +`list`, `tuple`, `dict`, `set` and `frozenset` are supported. Custom factories, +including closures and factories taking validated data, fail closed without being +executed. Unsupported metadata/configuration values also raise rather than using +unstable representations. This is a structural contract for supported interfaces, +not proof of behavioral equivalence for arbitrary validators or Python code. + +The expanded fixture was derived again from the original revision above in a +detached scratch worktree, with the updated comparator copied in. Python 3.11 +and 3.12 produced byte-identical snapshots. The previous parameter/return records, +all 28 interface names, all 22 endpoint declarations and the original OpenAPI +digest were checked unchanged before accepting the new field records. + +`tests/fixtures/skills_openapi_baseline.json` is the same baseline's complete +Skills-router OpenAPI document, generated with a standalone FastAPI application. +Sorted keys, ASCII escaping, two-space indentation and a final newline make the +fixture deterministic. Tests require exact canonical equality and print a unified +diff on mismatch. The redundant digest field and assertions have been removed; +they provided no independent compatibility guarantee. The fixture pins model +schemas and constraints. CI reads committed fixtures and needs no Git history +for these tests. Baseline updates require an intentional compatibility decision, rather +than regenerating fixtures from a failing implementation. + +The narrow `.gitleaks.toml` exception remains necessary for the historical public +digest in earlier fixture and documentation revisions. It applies to the exact digest, +the `generic-api-key` rule and the two named paths. Removing a current copy cannot +remove findings from published history. Keep the exception unless the pinned +full-history scan proves it unnecessary; do not rewrite history to remove it. +The checksum-verified 8.30.1 scan without this exception still reports both +historical copies in commit `a547c4d8`; retaining the exception passes the full +PR/dev history scan. +Reconsider it after squash merge only after verifying the resulting reachable +history with the pinned full-history scan. + +The ReDoS source guard scans `services/skills/evaluation.py`, the canonical +evaluation parser module, as well as the retained route and memory-extractor +locations. Verdict regex runtime tests import the canonical module directly. +The source guard must reject reintroducing the lazy think-block expression; +the ordinary-input and adversarial-flood runtime tests remain in place. An isolated +source mutation reproduced the old guard's false green for the evaluation module. +The corrected guard rejected the vulnerable expression in each of the three +locations and passed again after restoring the temporary copies. The mutated +regex was never executed, and production parser behavior was not changed. + +## Reproducible validation + +From the repository root, use a virtual environment with `requirements-dev.txt` +installed and the repository's frontend dependencies available. The examples use +`.venv/bin/python`; repeat the contract tests with a Python 3.12 environment as +well as Python 3.11. Record interpreter/dependency versions with results. + +```sh +.venv/bin/python -m pytest -q \ + tests/test_skills_audit_characterization.py tests/test_skills_audit_services.py +.venv/bin/python -m compileall -q app.py core routes src services scripts tests +.venv/bin/python -c 'import routes.skills_routes; import services.skills.agent_runner; import services.skills.audit_workflow' +.venv/bin/python scripts/build_frontend.py --check +node scripts/check_frontend.mjs +node --test tests/skillsApproval.test.mjs tests/frontendArchitecture.test.mjs +git diff --check +``` + +The focused Skills, authority and approval selection is reproducible with: + +```sh +.venv/bin/python - <<'PYTHON' +from pathlib import Path +import subprocess +import sys + +files = set() +for path in Path("tests").rglob("test_*.py"): + text = path.read_text() + if ("skill" in path.name or "approval" in path.name + or "utility_reasoning" in path.name or "skills_routes" in text + or "review_endpoint_credentials" in path.name): + files.add(str(path)) +files.update([ + "tests/test_request_authority.py", + "tests/test_endpoint_registered_authority.py", + "tests/test_redos_verdict_continuation.py", + "tests/test_redos_residual_python.py", +]) +subprocess.run([sys.executable, "-m", "pytest", "-q", "-rs", + "--durations=15", *sorted(files)], check=True) +PYTHON +``` + +The original selection contained 729 cases. Additional characterization cases +increase that count without removing the original coverage. For the full suite, +remove a development `APP_PORT` and put the environment's tools on PATH: + +```sh +env -u APP_PORT PATH="$PWD/.venv/bin:$PATH" \ + PLAYWRIGHT_BROWSERS_PATH="$PWD/.cache/ms-playwright" \ + .venv/bin/python -m pytest -q -rs --durations=25 +``` + +Run the checksum-verified Gitleaks binary pinned in +`.github/workflows/secret-scan.yml` on full reachable history, including the PR +head and fetched official `dev`. The current workflow pins 8.30.1. Its CI command +is `gitleaks git --no-banner --redact --verbose .` with a full-history checkout. +A local checkout can restrict refs to the relevant histories with +`--log-opts='HEAD upstream/dev'`; do not restrict the commit range to just the +latest patch. Scan uncommitted fixtures too before committing. + +## Historical evidence and validation limits + +The initial extraction validation at `a547c4d8` reported 42 characterization and +service cases, 729 focused cases, 13 relevant Node cases and 17 isolated uvicorn +HTTP checks passing. The historical repository-wide run reported 13,959 passed, +60 skipped and three failures. These counts describe the extraction validation, +not the current revision. The HTTP checks used isolated state and authentication +and included second-owner denial, but exercised neither a configured live model +nor an external tool action. + +The two containment failures reproduced on unchanged baseline `86b886c6` with +extra `Failed to create stream fd: No such file or directory` stderr output: + +- `test_namespace_handshake_closes_model_stdin` +- `test_execution_environment_cannot_replace_probed_bwrap[False]` + +`test_legacy_cleanup_against_a_private_real_tmux_server` failed under the original +full-suite load and passed isolated reruns. Its cause was not fully diagnosed. +These historical failures must be distinguished from new regressions; successful +CI and maintainer review remain integration gates. Earlier AST comparisons and +the [maintainer review](https://github.com/odysseus-dev/odysseus/pull/6711#issuecomment-6170115749) +provide historical extraction evidence, not an independent review of later fixes. + +## Known limitations and follow-up boundaries + +[Issue #6635](https://github.com/odysseus-dev/odysseus/issues/6635) remains open: +manual tests without an appropriate workspace can still fail to execute their +proposed action. Stream cleanup and exact approvals are preserved, but do not +supply a missing workspace. + +Jobs remain process-local and do not survive restarts. Durable jobs, moving the +preferences adapter below transport, consolidating storage under the domain +package, or changing duplicate/publication policies need separate designs. +The canonical manager, skill format/lifecycle modules, provider implementations, +agent authority enforcement and frontend contracts remain their existing owners. + +The [second-review correction report](skills-audit-second-review.md) records the +restored prompt seams, independently expanded constructor baseline, actual +validation results and a separate prioritized plan for the six inherited P2 +failure paths. Model-controlled lifecycle fields and preference-load failures +require maintainer action before automatic rewrite/publication deployment. diff --git a/docs/skills-audit-second-review.md b/docs/skills-audit-second-review.md new file mode 100644 index 000000000..91fdaae43 --- /dev/null +++ b/docs/skills-audit-second-review.md @@ -0,0 +1,200 @@ +# Skills PR #6711 second-review corrections + +The correction scope is route prompt compatibility and structural constructor +contracts. PR #6711 remains draft. Durable skill ownership stays with +`SkillsManager`. No publication policy, authority mechanism, provider runtime, +frontend behavior or protected manager/index implementation was changed. + +The reviewed head was `a6a33674ed4facf11f53e7cdd41f882ad0fa8482`. The independent +original baseline is `86b886c611338282af6d4a717246ea4e256f58df`. Official `dev` +integration was tested against `c3819e3cbf56c4f899eacc66b21115a77d9c570d`. + +## Compatibility corrections + +Service-local imports captured prompt builders outside the route namespace. +Patching the public route symbols therefore stopped affecting execution after +extraction. `AuditDependencies` now binds the current test-task, test-message and +baseline-message builders. Manual execution, exact-approval continuation, initial +audits, baseline arms, self/teacher retries and scheduled batches use those +bindings. The route `_skill_baseline_task` wrapper again delegates to the current +route `_skill_test_task`. Both execution arms still use one shared task, matching +the original behavior. No service imports Skills routes. + +The signature checker treated a factory's module/qualified name as its identity +and omitted Pydantic settings outside the generated Python signature. Closures +with different captured values and default-validation changes were false +negatives. New tests additionally demonstrated hidden validation-alias path and +model-configuration changes. The checker now records public field validation +settings, rebuilt annotations, defaults and inherited model configuration. See +the [Pydantic FieldInfo reference](https://docs.pydantic.dev/latest/api/fields/) +for the distinction between factories, validation aliases and `validate_default`. + +Only known builtin factories are supported by identity. Custom factories, +including factories requiring validated field data, raise `TypeError` without +being invoked during comparison. Unsupported metadata/configuration values also +fail closed. No bytecode, arbitrary object representations or factory output is +serialized. The comparator does not promise equivalence of arbitrary Python +validators or factory implementations. + +The expanded fixture was generated from the original baseline in a new detached +worktree, with only the revised test comparator copied in. Python 3.11 and 3.12 +produced byte-identical files. Every previous parameter/return record remains +equal. All 28 interfaces and 22 endpoint declarations remain present. The +canonical OpenAPI fixture is unchanged, and its original digest is unchanged. +Independent native `inspect.signature` snapshots also match original versus +corrected implementations on each interpreter. + +## Regression evidence and validation + +The new tests were run before production/comparator edits: 12 failed and four +passed, demonstrating the six signature cases and six broken prompt cases. After +correction, all 16 added cases pass. The prompt probes capture actual +`stream_agent_loop` ingress rather than replacing the runners or coordination. +They cover patched and normal execution, guard escaping and taint metadata, +owner authority, exact-approval continuation, and worker retries after both +self and teacher rewrites. The same ten prompt cases pass on the original +implementation. The six signature cases reject custom factories without calling +them and detect default validation, alias paths and model validation settings. + +Environments: Python 3.11.17 and 3.12.13, FastAPI 0.143.1, Pydantic 2.14.1, +pytest 9.1.1. Python test commands set `FASTEMBED_MODEL=review-disabled` and +`HF_HUB_OFFLINE=1` to exercise unavailable optional embeddings without model +downloads. They do not validate live providers or embedding quality. + +| Executed scope | Result | +| --- | --- | +| 53-file Skills, approval, authority and lifecycle selection, Python 3.11 | 757 passed | +| Same selection, Python 3.12 | 757 passed | +| Both characterization/service files, included above | 70 passed | +| Original baseline, available focused selection plus revised characterization | 724 passed | +| Original baseline, revised characterization plus prompt probes, each interpreter | 47 passed | +| Current-dev combined tree, focused selection plus eight intervening dev test files, each interpreter | 900 passed | +| Repository-wide suite, Python 3.11 | 13,991 passed, 60 skipped, two inherited environment failures, six subtests passed | +| Frontend publisher and architecture checks | Passed, zero changed modules | +| Relevant Node tests | 13 passed | +| Compilation, both Python versions | Passed | +| Import graph and fresh service-first/route-first imports, both versions | Passed; acyclic; no reverse Skills route import | +| Original normalized moved-body comparison | 26 of 26 matched after explicit dependency substitutions | +| Pinned/checksum-verified Gitleaks 8.30.1, reachable HEAD and official dev histories | No leaks | +| Same Gitleaks, tracked working files | No leaks | +| Diff/whitespace and protected-source comparisons | Passed | + +The two repository-wide failures are +`tests/test_containment_enforcement.py::test_namespace_handshake_closes_model_stdin` +and `test_execution_environment_cannot_replace_probed_bwrap[False]`. Isolated +reruns on both original and corrected implementations reproduce the same extra +`Failed to create stream fd: No such file or directory` stderr. The independent +review traced it to login-profile `im-config` calls to `systemd-cat` inside +Bubblewrap without the journal stream socket. These environment failures remain; +they were not hidden by excluding tests. A full Python 3.12 suite was not run. + +The combined test tree used the official merge revision `5419ed37`, whose tree +exactly matches `git merge-tree --write-tree upstream/dev a6a33674`, with the +correction diff applied in a detached scratch worktree. Published history was +not rebased, amended, reset or rewritten. + +Local commands, independent fixture captures, baseline reproductions and full +logs are retained under `/tmp/skills-6711-corrections/`. The independent review +and adversarial probe source remain under `/tmp/skills-6711-second/`. These +temporary evidence paths are not portable repository fixtures. CI should read +the committed golden fixtures, not regenerate them from the implementation. + +## Separate inherited P2 hardening plan + +All six defects demonstrably exist on the official original baseline. The six +safety assertions in `/tmp/skills-6711-second/test_adversarial.py` fail on both +the original and corrected trees with the same outcomes. Logs are +`inherited-original.log` and `inherited-head.log` in the correction evidence +directory. The original/source body comparison also matches these paths after +dependency substitutions. There is no demonstrated material worsening from the +extraction. That finding does not make their security impact acceptable. + +Priorities below concern a separate hardening change. Items 1 and 2 are release +blockers requiring maintainer resolution before deploying automatic skill +rewrite/publication workflows. This recommendation is independent of whether +the architecture PR is integrated. No tool-authority bypass or cross-owner +credential disclosure was demonstrated, and no critical extraction regression +was found that requires expanding this compatibility correction. + +1. **Model-controlled lifecycle/provenance fields.** Current source: + [publication.py:328](../services/skills/publication.py#L328), especially line + 342; the bypassed retrieval condition is + [skills.py:703](../services/memory/skills.py#L703). Original source: + [routes/skills_routes.py:1039](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L1039). + `test_rewrite_cannot_promote_model_text_to_builtin` uses the real manager and + saves model markdown with `source: builtin`, `status: published` and + `confidence: 1`. Identity/owner remain pinned, but the lifecycle fields persist + and the skill becomes retrieval-eligible without a passing audit. Corrective + change: allowlist procedure and approved descriptive/retrieval fields during + model rewrites; preserve existing owner, provenance, publication, confidence, + audit and teacher lifecycle state. Only verified policy may update those + fields. Metadata-only fixes must preserve the procedure or trigger a retest. + Add real-manager tests for self, teacher and metadata-only rewrites. + +2. **Preference loading fails open for automatic publication.** Current source: + [publication.py:13](../services/skills/publication.py#L13) and + [skills.py:59](../services/memory/skills.py#L59). Original source: + [routes/skills_routes.py:710](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L710). + `test_preferences_read_failure_does_not_enable_auto_publication` makes the + loader raise `PermissionError`; automatic publication is enabled rather than + preserving the owner's possible opt-out. Corrective change: distinguish a + successfully loaded empty preference store from an exception, log the failure + and disable automatic publication on exceptions. Align the manager retrieval + preference adapter in the separate fix. Test unreadable/malformed existing + preference files and the ordinary empty-store default independently. + +3. **Manual cancellation retains private credentials and stale running state.** + Current source: + [agent_runner.py:125](../services/skills/agent_runner.py#L125) and + [job_coordinator.py:49](../services/skills/job_coordinator.py#L49). Original: + [routes/skills_routes.py:635](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L635). + `test_manual_cancellation_cleans_sensitive_continuation` suspends and cancels + the stream. The stream closes in its originating task, but job status remains + running and `_run.headers.Authorization` remains resident. Polling still hides + these fields; this is retention, not demonstrated disclosure. Corrective + change: handle `CancelledError`, mark cancelled, clear private continuation + data, retire only the attached exact approval and re-raise. Preserve state on + a normal awaiting-approval return. Retain task handles for coordinated + replacement/cancellation and test owner/approval isolation. + +4. **Approval pauses during retries lead to demotion.** Current source: + [audit_workflow.py:244](../services/skills/audit_workflow.py#L244), teacher + retry at line 280 and demotion at line 303. Original: + [routes/skills_routes.py:1448](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L1448) + and lines 1484/1507. `test_retry_approval_preserves_publication_and_confidence` + begins published at .9, fails the first grade, then pauses the rewritten test + for approval. The result is flagged/draft/.35. The lower-level approval is + correctly denied; state handling afterwards is wrong. Corrective change: + immediately recognize `approval_required` after every retry, stop escalation + and demotion, and preserve the intended pre-pause lifecycle state. Coordinate + with item 1 so model rewrites cannot replace that saved state. Test both + self/teacher retries, repeated pauses and batch protection. + +5. **Duplicate losers are binned after keeper publication fails.** Current: + [publication.py:217](../services/skills/publication.py#L217), including loser + writes at line 228. Original: + [routes/skills_routes.py:914](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L914). + `test_duplicate_losers_retained_if_keeper_write_fails` raises disk-full during + keeper publication and still observes a loser binned. Corrective change: + require confirmed keeper persistence before binning losers when automatic + publication is enabled. Treat both exceptions and False returns as failure, + retain candidates and report recovery information. Preserve existing keeper + selection and duplicate scoring rules; add failure/retry tests. + +6. **Reported publication status can disagree with canonical storage.** Current: + [publication.py:320](../services/skills/publication.py#L320). Original: + [routes/skills_routes.py:1017](https://github.com/odysseus-dev/odysseus/blob/86b886c611338282af6d4a717246ea4e256f58df/routes/skills_routes.py#L1017). + `test_publication_does_not_report_unpersisted_success` returns False from the + manager update; the helper reports published while the stored skill remains + draft. Exceptions are also swallowed by the inspected code. Corrective change: + check persistence success, log failures and return reloaded canonical state or + an explicit persistence error. Test False/exception outcomes and the job/UI + projection. Coordinate this change with item 5 without changing publication + thresholds or scoring. + +Maestrum's read-only triage delegation produced no usable result within its +usefulness window and was cancelled. Cleanup completed and the session records +an interrupted result, not a successful review. The coordinator reclaimed the +remaining report work and verified the source references and reproduction logs +above. No worker claim was used as proof. No GitHub issue, new PR, maintainer +comment or PR-description edit was made. diff --git a/docs/tls-trust.md b/docs/tls-trust.md new file mode 100644 index 000000000..71f0fe76e --- /dev/null +++ b/docs/tls-trust.md @@ -0,0 +1,75 @@ +# Outbound HTTPS trust + +Odysseus uses both Python package CA bundles and system trust. HTTPX uses certifi by default; the guarded web/PDF fetcher and webhook delivery explicitly use Python's system SSL context. A configured `LLM_CA_BUNDLE` adds certificates to system trust for model calls. Upgrading certifi alone cannot repair every outbound HTTPS path. + +For the October 2026 eMudhra incident, use certifi 2026.10.23 or newer, pip 26.2.2 or newer, and uv 0.13.5 or newer wherever uv is used. Pip has its own vendored certifi and also loads system roots. Update the operating system's certificate package separately. See the [Python security announcement](https://discuss.python.org/t/109576) and [incident details](https://emudhra.we-have-a-problem.com/). + +Before other pip installs, bootstrap the selected environment with the integrity-pinned wheels: + +```sh +python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt +python -m pip install -r requirements.txt +``` + +The pinned bootstrap protects package integrity even when the old installer's certificate bundle is affected. It is not an operating-system trust-store update. Native Windows/macOS trust and command-line OpenSSL PEM stores may differ; follow the platform vendor's remediation for every store in use. Restart Odysseus and its subprocesses after updating trust. + +On Linux/OpenSSL deployments, inspect both the effective CA file and hashed CA directory, plus the two Python bundles: + +```sh +python scripts/check_ca_trust.py --system --python --overrides --verbose +``` + +Explicit PEM bundles can also be passed as positional arguments. The check rejects the four published root fingerprints, all sixteen published intermediate certificates (including their cross-signs), the cross-signed G3 issuer in the published test chains, and malformed/empty bundles. Checking intermediates matters because adding one as a custom trust anchor can restore an affected chain even after its roots have been removed. + +`--overrides` checks every nonempty `LLM_CA_BUNDLE`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`, `NODE_EXTRA_CA_CERTS` and `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` setting in the checker's process environment, including configured fallback settings. Requests/pip hashed-directory overrides are inspected too; `--system` covers `SSL_CERT_DIR`. Run with the application's environment and filesystem access. The script does not load `.env` or application configuration files; pass any CA paths configured only there explicitly, for example `python scripts/check_ca_trust.py --system --python /path/to/private-ca.pem`. Missing or invalid configured files fail the check. Selecting only `--overrides` with no configured paths also fails instead of reporting that stores were validated. + +`--verbose` reports the Python executable/prefix, user home, effective UID/GID where available, and resolved CA paths, including bundles selected from user-installed packages. Compare these with the running application's identity and interpreter. It does not dump environment variables or credentials. + +This is an offline inventory check of known certificate fingerprints. It does not modify stores, reject requests at runtime, model native OS revocation, or detect arbitrary reissued certificates sharing the compromised keys. Chromium's policy separately matches public keys. Check mounted or replaced CA files again after deployment, remove affected trust through the owning platform/client configuration, and restart clients that cache SSL contexts. A passing build cannot validate custom files introduced later. + +Both Docker build stages use Python 3.14.9, refresh system CA certificates and bootstrap pip/certifi before dependency installation. The build fails if known affected CA certificates remain. Node uses the corrected system store through `--use-openssl-ca`. Python gRPC uses that same PEM file through `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`; updating certifi alone does not replace gRPC's independent wheel bundle. After dependency installation, the image checks gRPC's selected file as well. An explicit gRPC override must be readable and free of known affected CA certificates. [grpcio 1.84.1](https://github.com/grpc/grpc/releases/tag/v1.84.1) includes the upstream root-bundle correction and is the application minimum. Retain the checked override as a consistent image trust selection; older persisted Python environments and explicit client roots still need independent checks. + +The image also bundles Docker CLI [29.9.2](https://github.com/moby/moby/releases/tag/docker-v29.9.2), built with Go 1.26.10's distrust of the compromised intermediate keys. Cookbook uses this client for optional Docker operations. Default Compose does not mount the host Docker socket; local daemon access requires the explicit [host-Docker overlay](../docker/host-docker.yml), whose usual Unix-socket connection does not use TLS. The client update is maintenance of a shipped tool, not evidence that these local calls were exposed. It does not update the host daemon or other containers' clients and trust stores; assess those separately. + +Chromium uses a separate trust store. The Docker image installs a managed `CADistrustedCertificates` policy for the eight compromised intermediate public keys, covering their cross-signs without depending on background revocation updates. It adds no trusted certificate and does not disable certificate verification. The policy and public-certificate provenance are documented in [the browser policy note](../docker/chromium-ca-policy.md). Native browser installations and independently managed profiles require their own platform policy or vendor update; this image policy does not configure host browsers. + +To replace only Odysseus in an already running Compose stack, build from the corrected checkout with a distinct local image tag. Use the same Compose files and project name as the existing deployment for every command below: + +```sh +export ODYSSEUS_IMAGE="odysseus-local:ca-$(git rev-parse --short=12 HEAD)" +docker compose build --pull --no-cache odysseus +docker compose up -d --no-deps --pull never --no-build odysseus +docker image inspect --format '{{.Id}}' "$ODYSSEUS_IMAGE" +docker inspect --format '{{.Image}}' "$(docker compose ps -q odysseus)" +``` + +The two image IDs must match. Keep the selected `ODYSSEUS_IMAGE` in the deployment configuration for subsequent recreations. Compose can prefer the registry image when both `image` and `build` are present; the explicit pull/build flags keep this replacement on the local build. `--no-deps` leaves the existing sidecars running and is intended for an existing stack. For registry deployments, select a verified corrected immutable tag or digest. A merge to `dev` publishes `:dev`; it does not update `:latest`, which is published from `main`. + +Check the standard image as its configured app account, using the image's Python interpreter and the same user-bin PATH prefix as the entrypoint: + +```sh +docker compose exec -T --user 0 odysseus sh -ec ' + gosu_bin=$(command -v gosu) + app_user=$(getent passwd "${PUID:-1000}" | cut -d: -f1) + test -n "$app_user" + exec "$gosu_bin" "$app_user" env PATH="/app/.local/bin:$PATH" \ + /usr/local/bin/python /app/scripts/check_ca_trust.py \ + --system --python --grpc --overrides --verbose +' +``` + +`gosu` selects the account's HOME and drops privileges before Python runs, so the check includes its persisted user packages. The entrypoint itself is not rerun. Plain `docker compose exec ... python` uses the image's default root user and can miss `/app/.local` packages. A custom service user, entrypoint, virtual environment or launch command requires matching that deployment's actual user, HOME and Python interpreter instead of copying the standard command unchanged. Docker exec inherits container-creation environment, not variables exported later by the app or entrypoint: reproduce any additional Python/TLS settings with exec's `--env` options, and pass CA paths configured only in application files explicitly. Confirm the verbose identity and selected paths before accepting the result. + +For containers started non-root or without the SETUID/SETGID capabilities needed by `gosu`, use `docker compose exec --user UID:GID --env HOME=/actual/app/home` with the deployment's actual numeric IDs, home, PATH and interpreter instead. Do not add capabilities merely to run this check. + +SearXNG is a separate HTTPS client and image; updating the Odysseus image does not patch it. Inspect its effective CA path and update or rebuild that image independently. Also inspect persisted `/app/.local` packages, remote Cookbook environments, and externally configured MCP runtimes. Image replacement does not necessarily replace those environments. + +For behavioral validation, all fourteen [published test chains](https://emudhra.we-have-a-problem.com/chains.zip), including the AAA cross-signs and two chains without Certificate Transparency entries, must fail certificate verification through the effective stores. Confirm that an ordinary known-good HTTPS request still succeeds. In Chromium, confirm the managed policy is loaded at `chrome://policy` and test the incident endpoints through the actual browser path with certificate verification enabled. The PEM checker cannot inspect Chromium's policy or its active verifier. Package versions and a successful image build alone do not prove that a running deployment selects the corrected store. + +## Follow-up releases + +The maintained bootstrap selects certifi 2026.10.23 with its verified wheel hash; pip remains 26.2.2. The certifi update removes the C1/C3 roots in addition to the earlier incident changes. CI uses uv 0.13.5, which adds verifier-level rejection of the compromised intermediate keys and updated managed CPython builds. Existing virtual environments do not update automatically. See the [certifi change](https://github.com/certifi/python-certifi/commit/b60466c9f2882d9cf088943c650b0faac06c080c), [uv release](https://github.com/astral-sh/uv/releases/tag/0.13.5), and [Python security releases](https://blog.python.org/2026/10/python-3151-3149-31317/). The pinned bootstrap still replaces the selected versions exactly; preserving newer installed versions and private-mirror support remain separate maintenance. + +SearXNG remains an independent trust owner. Use the [upstream-image validation and rollout instructions](searxng-trust-image.md); updating these application packages does not repair an existing sidecar. Host daemons, native browser policy, optional compiled clients and independently managed integrations require their own effective-client checks. + +CPython's [intermediate-key verifier block](https://github.com/python/cpython/pull/160042) and its [3.14 backport](https://github.com/python/cpython/pull/160569) merged on October 24. They are distinct from Python 3.14.9's bundled-pip update. No released 3.14 interpreter containing that verifier change was identified when this image pin was selected; retain the corrected effective trust stores and track the subsequent security release. hf-xet's [trust fix](https://github.com/huggingface/xet-core/pull/1038) is also merged but not included in the latest published 1.7.0 release at that check. Agent-browser 0.39.2 and primp 2.0.1 are not established incident fixes for their independent native trust paths. A version bump alone does not establish that the active client uses corrected trust. diff --git a/frontend/modules.json b/frontend/modules.json new file mode 100644 index 000000000..0f882adbd --- /dev/null +++ b/frontend/modules.json @@ -0,0 +1,215 @@ +{ + "version": 1, + "modules": [ + { + "source": "shared/actionMenuOrder.js", + "deployment": "js/actionMenuOrder.js" + }, + { + "source": "features/notes/domain/recurrence.js", + "deployment": "js/features/notes/domain/recurrence.js" + }, + { + "source": "features/notes/infrastructure/http.js", + "deployment": "js/features/notes/infrastructure/http.js" + }, + { + "source": "features/notes/infrastructure/storage.js", + "deployment": "js/features/notes/infrastructure/storage.js" + }, + { + "source": "features/notes/application/state.js", + "deployment": "js/features/notes/application/state.js" + }, + { + "source": "features/notes/application/workflows.js", + "deployment": "js/features/notes/application/workflows.js" + }, + { + "source": "features/notes/application/reminders.js", + "deployment": "js/features/notes/application/reminders.js" + }, + { + "source": "features/notes/infrastructure/notifications.js", + "deployment": "js/features/notes/infrastructure/notifications.js" + }, + { + "source": "features/notes/ui/text.js", + "deployment": "js/features/notes/ui/text.js" + }, + { + "source": "features/notes/ui/colors.js", + "deployment": "js/features/notes/ui/colors.js" + }, + { + "source": "features/notes/ui/panel.js", + "deployment": "js/features/notes/ui/panel.js" + }, + { + "source": "features/notes/ui/reminderDisplay.js", + "deployment": "js/features/notes/ui/reminderDisplay.js" + }, + { + "source": "features/notes/ui/selection.js", + "deployment": "js/features/notes/ui/selection.js" + }, + { + "source": "features/notes/ui/filters.js", + "deployment": "js/features/notes/ui/filters.js" + }, + { + "source": "features/notes/ui/cardRendering.js", + "deployment": "js/features/notes/ui/cardRendering.js" + }, + { + "source": "features/notes/ui/layout.js", + "deployment": "js/features/notes/ui/layout.js" + }, + { + "source": "features/notes/ui/quickAdd.js", + "deployment": "js/features/notes/ui/quickAdd.js" + }, + { + "source": "features/notes/ui/cardInteractions.js", + "deployment": "js/features/notes/ui/cardInteractions.js" + }, + { + "source": "features/notes/ui/cardChecklist.js", + "deployment": "js/features/notes/ui/cardChecklist.js" + }, + { + "source": "features/notes/ui/cardReorder.js", + "deployment": "js/features/notes/ui/cardReorder.js" + }, + { + "source": "features/notes/ui/draftEditor.js", + "deployment": "js/features/notes/ui/draftEditor.js" + }, + { + "source": "features/notes/ui/editor.js", + "deployment": "js/features/notes/ui/editor.js" + }, + { + "source": "features/notes/ui/reminderPicker.js", + "deployment": "js/features/notes/ui/reminderPicker.js" + }, + { + "source": "features/notes/ui/checklist.js", + "deployment": "js/features/notes/ui/checklist.js" + }, + { + "source": "features/notes/ui/drawing.js", + "deployment": "js/features/notes/ui/drawing.js" + }, + { + "source": "features/notes/ui/clipboard.js", + "deployment": "js/features/notes/ui/clipboard.js" + }, + { + "source": "features/notes/ui/mobileEditor.js", + "deployment": "js/features/notes/ui/mobileEditor.js" + }, + { + "source": "features/notes/ui/mobileReorder.js", + "deployment": "js/features/notes/ui/mobileReorder.js" + }, + { + "source": "features/notes/ui/checklistReorder.js", + "deployment": "js/features/notes/ui/checklistReorder.js" + }, + { + "source": "features/notes/application/queries.js", + "deployment": "js/features/notes/application/queries.js" + }, + { + "source": "features/notes/application/agentActions.js", + "deployment": "js/features/notes/application/agentActions.js" + }, + { + "source": "features/notes/application/deletion.js", + "deployment": "js/features/notes/application/deletion.js" + }, + { + "source": "features/notes/domain/noteRules.js", + "deployment": "js/features/notes/domain/noteRules.js" + }, + { + "source": "features/notes/index.js", + "deployment": "js/features/notes/index.js", + "legacyImports": [ + { + "specifier": "/static/js/ui.js?v=20260916largetoolscroll1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/modalManager.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/colorPicker.js?v=20260910eyedropper1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/windowDrag.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/tileManager.js?v=20260910responsivebounds1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/modalSnap.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/toolWindowZOrder.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/escMenuStack.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/tourHintDismiss.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + } + ] + }, + { + "source": "features/notes/application/submission.js", + "deployment": "js/features/notes/application/submission.js" + }, + { + "source": "features/notes/application/drafts.js", + "deployment": "js/features/notes/application/drafts.js" + }, + { + "source": "features/notes/ui/lifecycle.js", + "deployment": "js/features/notes/ui/lifecycle.js" + }, + { + "source": "features/notes/application/cardOperations.js", + "deployment": "js/features/notes/application/cardOperations.js" + }, + { + "source": "features/notes/application/checklistOperations.js", + "deployment": "js/features/notes/application/checklistOperations.js" + }, + { + "source": "features/notes/application/selection.js", + "deployment": "js/features/notes/application/selection.js" + }, + { + "source": "features/notes/ui/toolbar.js", + "deployment": "js/features/notes/ui/toolbar.js" + }, + { + "source": "features/notes/ui/windowLayout.js", + "deployment": "js/features/notes/ui/windowLayout.js" + }, + { + "source": "shared/ui/confetti.js", + "deployment": "js/shared/ui/confetti.js" + } + ] +} diff --git a/frontend/src/features/notes/application/agentActions.js b/frontend/src/features/notes/application/agentActions.js new file mode 100644 index 000000000..d96828211 --- /dev/null +++ b/frontend/src/features/notes/application/agentActions.js @@ -0,0 +1,67 @@ +/** Create an agent session, link it to a note and drain its background stream. */ +import { _noteToAgentPrompt } from '../domain/noteRules.js'; +export function createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData, + state, + streamComplete, + uiModule +}) { + async function _agentSolveNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prompt = _noteToAgentPrompt(note); + if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; } + try { + const dc = await (await api.defaultChat()).json(); + if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } + + // 1. Create the session server-side (no UI switch). skip_validation + // avoids re-probing : the default-chat endpoint is already known good. + const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40); + const csFd = createFormData(); + csFd.append('name', 'Agent: ' + label); + csFd.append('endpoint_url', dc.endpoint_url); + csFd.append('model', dc.model); + if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); + csFd.append('skip_validation', 'true'); + const csRes = await api.createSession(csFd); + if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } + const sess = await csRes.json(); + const sid = sess.id; + + // 2. Link the session to the note right away so the tag appears. + const n = state.notes.find(x => x.id === id); + if (n) app.mergeNote(n, { agent_session_id: sid }); + _renderNotes(); + _patchNote(id, { agent_session_id: sid }).catch(() => {}); + + // 3. Kick off the agent run in the background. POST to chat_stream in + // agent mode and drain the SSE so the server runs the loop to + // completion + saves : without rendering anything in the chat UI. + const fd = createFormData(); + fd.append('message', prompt); + fd.append('session', sid); + fd.append('mode', 'agent'); + fd.append('disabled_tools', JSON.stringify(['manage_notes'])); + api.startChat(fd) + .then(async (res) => { + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + // Drain to completion (server finishes + persists the run). + while (true) { const { done } = await reader.read(); if (done) break; } + streamComplete(sid); + }) + .catch(() => {}); + + uiModule.showToast('Agent working in background — tap the Agent tag when ready'); + } catch (e) { + uiModule.showError('Agent failed: ' + (e.message || e)); + } + } + + return { _agentSolveNote }; +} diff --git a/frontend/src/features/notes/application/cardOperations.js b/frontend/src/features/notes/application/cardOperations.js new file mode 100644 index 000000000..1131d2ec0 --- /dev/null +++ b/frontend/src/features/notes/application/cardOperations.js @@ -0,0 +1,79 @@ +/** Optimistic card mutations and rollback. Animation completion is a UI-supplied boundary. */ +export function createCardOperations({ + app, + api, + effects, + pushUndo, + undoArchive +}) { + const state = app.state; + function togglePin(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prevPinned = note.pinned, prevSortOrder = note.sort_order; + app.mergeNote(note, { pinned: !prevPinned }); + const patch = { pinned: note.pinned }; + if (note.pinned) { + const minPinned = state.notes.filter(n => n.pinned && n.id !== id).reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + app.mergeNote(note, { sort_order: minPinned - 1 }); + patch.sort_order = note.sort_order; + } + effects.render(); + api.patchNote(id, patch).catch(() => { + app.mergeNote(note, { pinned: prevPinned, sort_order: prevSortOrder }); + effects.render(); effects.showError('Failed to pin'); + }); + } + async function updateColor(id, color) { + try { + await api.patchNote(id, { color: color || null }); + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, { color }); + } catch { effects.showError('Failed to update color'); } + } + function stageArchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return null; + const removed = app.removeNoteAt(index); + const undo = () => undoArchive(removed, index); + pushUndo({ label: 'archive', run: undo }); + return () => { + effects.render(); + api.patchNote(id, { archived: true }).then(() => effects.archived(undo)).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to archive'); + }); + }; + } + function unarchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + api.patchNote(id, { archived: false }).then(() => effects.showToast('Unarchived')).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to unarchive'); + }); + } + function deleteOptimistically(id, toast = false) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + const deletion = api.deleteNote(id); + const completion = toast ? deletion.then(() => effects.showToast('Deleted')) : deletion; + completion.catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to delete'); + }); + } + // Desktop deliberately persists without updating local sort_order, as before. + function persistOrder(ids) { return api.reorder(ids); } + async function commitMobileOrder(ids) { + try { + await api.reorder(ids); + ids.forEach((id, index) => { + const note = state.notes.find(note => note.id === id); + if (note) app.mergeNote(note, { sort_order: index }); + }); + } catch (error) { console.warn('reorder failed', error); } + } + return { togglePin, updateColor, stageArchive, unarchive, deleteOptimistically, persistOrder, commitMobileOrder }; +} diff --git a/frontend/src/features/notes/application/checklistOperations.js b/frontend/src/features/notes/application/checklistOperations.js new file mode 100644 index 000000000..b4a596b04 --- /dev/null +++ b/frontend/src/features/notes/application/checklistOperations.js @@ -0,0 +1,68 @@ +/** Checklist item workflows. Supplied feedback callbacks update the invoking view. */ +export function createChecklistOperations({ + app, + api, + effects, + uid +}) { + const state = app.state; + function itemNote(id, index) { + const note = state.notes.find(n => n.id === id); + return note && Array.isArray(note.items) && note.items[index] ? note : null; + } + function removeItem(id, index) { + const note = itemNote(id, index); + if (!note) return; + const removed = note.items[index]; + app.mergeNote(note, { items: note.items.filter((_, i) => i !== index) }); + effects.render(); + api.patchNote(id, { items: note.items }).catch(() => { + app.spliceItems(note, index, 0, removed); effects.render(); effects.showError('Failed to remove item'); + }); + } + function editItem(note, index, text) { + const oldText = (note.items[index].text || '').trim(); + if (text === oldText) { effects.render(); return; } + const oldItem = note.items[index]; + if (!text) app.spliceItems(note, index, 1); + else app.updateItem(note, index, { text }); + api.patchNote(note.id, { items: note.items }).catch(() => { + if (!text) app.spliceItems(note, index, 0, oldItem); + else app.updateItem(note, index, { text: oldText }); + effects.render(); effects.showError('Failed to update item'); + }); + effects.render(); + } + function addItem(id, text, added) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const items = Array.isArray(note.items) ? [...note.items] : []; + items.push({ id: uid(), text, done: false }); + app.mergeNote(note, { items }); + added(); effects.render(); + return () => api.patchNote(id, { items }).catch(() => { + app.mergeNote(note, { items: items.slice(0, -1) }); effects.render(); effects.showError('Failed to add item'); + }); + } + function toggleItem(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + const wasDone = note.items.length > 0 && note.items.every(it => it.done); + app.updateItem(note, index, { done: !note.items[index].done }); + changed(note.items[index].done); + if (!wasDone && note.items.length > 0 && note.items.every(it => it.done)) completed(); + api.patchNote(id, { items: note.items }).catch(() => { + app.updateItem(note, index, { done: !note.items[index].done }); changed(note.items[index].done); + }); + } + async function toggleTodayStep(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + app.updateItem(note, index, { done: !note.items[index].done }); changed(); + try { + await api.patchNote(id, { items: note.items }); effects.render(); + if (note.items.every(it => it.done)) completed(); + } catch { app.updateItem(note, index, { done: !note.items[index].done }); } + } + return { removeItem, editItem, addItem, toggleItem, toggleTodayStep }; +} diff --git a/frontend/src/features/notes/application/deletion.js b/frontend/src/features/notes/application/deletion.js new file mode 100644 index 000000000..e31e9b422 --- /dev/null +++ b/frontend/src/features/notes/application/deletion.js @@ -0,0 +1,51 @@ +/** Confirmed deletion and past-reminder clearing workflows. */ +export function createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects, + state +}) { + async function _deleteNote(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); effects.showToast('Deleted'); } + catch (err) { effects.showError(err.message); } + } + + async function _clearPastReminders() { + const targets = state.notes.filter(n => !n.archived && _isPastReminder(n)); + if (!targets.length) { + effects.showToast?.('No past reminders to clear'); + return; + } + const ok = effects.styledConfirm + ? await effects.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }) + : effects.confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`); + if (!ok) return; + await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {}))); + await _fetchNotes(); + _renderNotes(); + effects.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`); + } + + async function deleteFromEditor(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + const index = state.notes.findIndex(note => note.id === id); + if (index >= 0) app.removeNoteAt(index); + app.setEditingId(null); + _renderNotes(); + _deleteNoteApi(id).then(() => effects.showToast('Deleted')).catch(() => { + effects.showError('Failed to delete'); + _fetchNotes().then(() => _renderNotes()); + }); + } + return { _deleteNote, _clearPastReminders, deleteFromEditor }; +} diff --git a/frontend/src/features/notes/application/drafts.js b/frontend/src/features/notes/application/drafts.js new file mode 100644 index 000000000..4f7a4715d --- /dev/null +++ b/frontend/src/features/notes/application/drafts.js @@ -0,0 +1,55 @@ +/** Serialized autosave and synchronous local draft backup. DOM collection is a supplied port. */ +export function createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects +}) { + const state = app.state; + const { _draftKey, _clearDraft } = persistence; + function bindDraft({ collect, id, view }) { + let t = null; + let pending = Promise.resolve(); + let stopped = false; + let warned = false; + const save = () => { + const d = collect(); + // An empty edit can be intentional: don't restore stale server text over it. + try { persistence.setItem(_draftKey(id), JSON.stringify(d)); } + catch { + if (!warned) effects.showError('Draft backup unavailable. Keep this note open until saved.'); + warned = true; + } + return d; + }; + const persist = (d) => { + if (!id || id === '__new__' || d.note_type === 'draw') return pending; + const { _ts, ...payload } = d; + payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null; + pending = pending.then(async () => { + try { + await api.patchNote(id, payload); + view.failed = false; + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, payload); + if (persistence.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id); + } catch { + if (!view.failed) effects.showError('Note not synced. Your edits are kept on this device; retry Save.'); + view.failed = true; + } + }); + return pending; + }; + const flush = () => { scheduler.clearTimeout(t); return save(); }; + const stop = () => { stopped = true; scheduler.clearTimeout(t); return pending; }; + const sched = () => { + const d = save(); + if (stopped) return; + scheduler.clearTimeout(t); + t = scheduler.setTimeout(() => persist(d), 500); + }; + return { flush, stop, schedule: sched }; + } + return { bindDraft }; +} diff --git a/frontend/src/features/notes/application/queries.js b/frontend/src/features/notes/application/queries.js new file mode 100644 index 000000000..6a92250c9 --- /dev/null +++ b/frontend/src/features/notes/application/queries.js @@ -0,0 +1,66 @@ +/** Application-owned filtering, searching and note ordering. */ +import { _noteTags, _visibleNoteTags, _nextGoalStep, _isCalendarReminderNote } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createQueries({ + _hasActiveReminder, + state +}) { + function _sortedVisibleNotes() { + const displayNotes = _userVisibleNotes(); + let filtered = state.activeLabel ? displayNotes.filter(n => _noteTags(n).includes(state.activeLabel)) : displayNotes; + if (state.activeFilter === 'reminders') { + filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date)); + } else if (state.activeFilter === 'no-reminders') { + filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date))); + } else if (state.activeFilter === 'default') { + filtered = filtered.filter(n => _visibleNoteTags(n).length === 0); + } else if (state.activeFilter === 'goals') { + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived); + } else if (state.activeFilter === 'today') { + // Today view: only goals that still have an unchecked step. + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)); + } + if (state.searchQuery) { + filtered = filtered.filter(n => { + const q = state.searchQuery; + if ((n.title || '').toLowerCase().includes(q)) return true; + if ((n.content || '').toLowerCase().includes(q)) return true; + if ((n.label || '').toLowerCase().includes(q)) return true; + if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true; + return false; + }); + } + const sorted = [...filtered].sort((a, b) => { + // In reminders view: sort by due date ascending (soonest first) + if (state.activeFilter === 'reminders') { + const da = new Date(a.due_date || 0).getTime(); + const db = new Date(b.due_date || 0).getTime(); + return da - db; + } + // Archived view: newest archived first (ignore manual sort_order). + if (state.showingArchived) { + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + } + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + // Active reminders (due date in the past, not done/archived) rank + // immediately under the pinned block. + const aActive = _hasActiveReminder(a); + const bActive = _hasActiveReminder(b); + if (aActive && !bActive) return -1; + if (!aActive && bActive) return 1; + const so = (a.sort_order || 0) - (b.sort_order || 0); + if (so !== 0) return so; + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + }); + + + return sorted; + } + + function _userVisibleNotes() { + return state.notes.filter(n => !_isCalendarReminderNote(n)); + } + + return { _sortedVisibleNotes, _userVisibleNotes }; +} diff --git a/frontend/src/features/notes/application/reminders.js b/frontend/src/features/notes/application/reminders.js new file mode 100644 index 000000000..5e2475993 --- /dev/null +++ b/frontend/src/features/notes/application/reminders.js @@ -0,0 +1,182 @@ +/** Page-owned reminder scheduler and notification delivery workflow. */ +import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js'; +export function createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + effects, + fetchNotes, + isNoteFullyDone, + hasItems, + dismissedKey +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders } = persistence; + const _patchNote = api.patchNote; + const _fetchNotes = fetchNotes; + const _isNoteFullyDone = isNoteFullyDone; + const _hasItems = hasItems; + const REMINDER_DISMISSED_AT_KEY = dismissedKey; + const _updateRailBadge = effects.badge; + let _reminderTimer = null; + let startupTimer = null; + let started = false; + let disposed = false; + function _checkReminders() { + if (!state.notes.length) return; + const now = clock.now(); + const fired = _loadFiredReminders(); + let changed = false; + for (const note of state.notes) { + if (!note.due_date || note.archived) continue; + if (!_hasTimeComponent(note.due_date)) continue; + if (fired.has(note.id)) continue; + const due = new Date(note.due_date).getTime(); + if (isNaN(due)) continue; + if (due <= now && due > now - 60000) { + _fireReminder(note); + // Recurring? advance the due_date instead of marking as fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + // Don't add to fired : new due_date is in the future + continue; + } + } + fired.add(note.id); + changed = true; + } else if (due <= now - 60000) { + // Past, never seen : silently advance recurring or mark fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + continue; + } + } + fired.add(note.id); + changed = true; + } + } + if (changed) _saveFiredReminders(fired); + // Always refresh badge : fired state may have changed visually without note mutation + _updateRailBadge(); + } + + function _fireReminder(note) { + const title = note.title || 'Note reminder'; + // Include the verbatim note content so the email/notification actually + // shows what to do, not just a count. Cap the per-item lines (8 max) and + // total length so the body stays inbox-friendly. + let rawBody; + if (_hasItems(note)) { + const pending = (note.items || []) + .filter(i => !i.done && !i.checked) + .map(i => (i.text || '').trim()) + .filter(Boolean); + if (pending.length) { + const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n'); + const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : ''; + rawBody = `Pending (${pending.length}):\n${shown}${extra}`; + } else { + rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`; + } + } else { + rawBody = (note.content || '').slice(0, 400); + } + + // Ask the server to dispatch according to user settings. The server may + // return an LLM-written synthesis line and/or send an email. We still show + // a local browser notification so the user gets immediate feedback even if + // the server path is disabled or slow. + const showLocal = body => notifications.show(title, body, note.id); + + // Fire-and-forget server dispatch. If synthesis comes back quickly enough, + // use it as the notification body; otherwise the local notification has + // already shown with the raw body. + let shown = false; + const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500); + + api.fireReminder({ note_id: note.id, title, body: rawBody }) + .then(r => r.ok ? r.json() : null) + .then(data => { + scheduler.clearTimeout(timer); + if (shown) return; + shown = true; + const body = (data && data.synthesis) ? data.synthesis : rawBody; + showLocal(body); + }) + .catch(() => { + scheduler.clearTimeout(timer); + if (!shown) { shown = true; showLocal(rawBody); } + }); + + // Pulse the card if visible; otherwise queue it so the next time the user + // opens the notes panel the card gets a brief glow. + effects.highlight(note.id); + } + + function _startReminderLoop() { + if (disposed || _reminderTimer) return; + _reminderTimer = scheduler.setInterval(_checkReminders, 30000); + _checkReminders(); // run once immediately + } + + function _countDueReminders() { + return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length; + } + + function _countFiredReminders() { + // Reminders whose time has actually passed (not just date-today), + // and which fired after the last user dismissal. + const now = clock.now(); + return state.notes.filter(n => { + if (n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date || !_hasTimeComponent(n.due_date)) return false; + const t = new Date(n.due_date).getTime(); + if (isNaN(t) || t > now) return false; + return t > state.firedDotDismissedAt; + }).length; + } + + function dismissFiredReminderDot() { + app.setFiredDotDismissedAt(clock.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + _updateRailBadge(); + } + + async function refreshDueBadge(opts = {}) { + // Usually lightweight, but callers that just created a note reminder can + // force a refresh so the background reminder loop sees it immediately. + if (opts.force || state.notes.length === 0) { + try { + const wasArchived = state.showingArchived; + app.setShowingArchived(false); + await _fetchNotes(); + app.setShowingArchived(wasArchived); + } catch {} + } + _updateRailBadge(); + } + + async function _initReminders() { + try { + const res = await api.listNotes(false); + if (res.ok && !disposed) { + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + _startReminderLoop(); + } + } catch {} + } + + function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); } + function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; } + return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders }; +} diff --git a/frontend/src/features/notes/application/selection.js b/frontend/src/features/notes/application/selection.js new file mode 100644 index 000000000..6017025e2 --- /dev/null +++ b/frontend/src/features/notes/application/selection.js @@ -0,0 +1,39 @@ +/** Selected-note attachment retains synchronous acceptance; bulk requests retain partial-failure behavior. */ +export function createSelectionOperations({ + app, + api, + effects, + sendContext, + exitSelection, + fetchNotes +}) { + const state = app.state; + function attachSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + const detail = { noteIds: ids, accepted: false }; + sendContext(detail); + if (!detail.accepted) { + effects.showToast('Choose a chat or model before attaching notes'); + return; + } + exitSelection(); + } + async function archiveSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + await Promise.all(ids.map(id => api.patchNote(id, { archived: true }).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Archived ${ids.length}`); + } + async function deleteSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + if (effects.styledConfirm) { + const ok = await effects.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + } + await Promise.all(ids.map(id => api.deleteNote(id).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Deleted ${ids.length}`); + } + return { attachSelected, archiveSelected, deleteSelected }; +} diff --git a/frontend/src/features/notes/application/state.js b/frontend/src/features/notes/application/state.js new file mode 100644 index 000000000..b87ef11c4 --- /dev/null +++ b/frontend/src/features/notes/application/state.js @@ -0,0 +1,83 @@ +/** The sole Notes state owner. Views have live read-only access; writes use operations. */ +export function createNotesState(initial = {}) { + const values = { + open: false, notes: [], editingId: null, selectedIds: new Set(), + activeLabel: null, activeFilter: null, reminderChipNext: 'reminders', searchQuery: '', + viewMode: initial.viewMode || 'list', showingArchived: false, selectMode: false, + suppressNextNoteTapId: null, labelsCollapsed: !!initial.labelsCollapsed, + loading: false, firedDotDismissedAt: initial.firedDotDismissedAt || 0, + }; + // Preserve captured note identity across asynchronous completions without + // giving a view a writable reference to the application store. + const views = new WeakMap(), targets = new WeakMap(); + const unwrap = value => targets.get(value) || value; + // Copies built from views (spread, filter, map) still hold nested views; + // store only raw data so the owner never keeps its own read-only views. + function unwrapDeep(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return targets.get(value); + if (seen.has(value)) return value; + seen.add(value); + if (Array.isArray(value)) value.forEach((item, index) => { value[index] = unwrapDeep(item, seen); }); + else if (Object.getPrototypeOf(value) === Object.prototype) for (const key of Object.keys(value)) value[key] = unwrapDeep(value[key], seen); + return value; + } + function readonly(value) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return value; + if (views.has(value)) return views.get(value); + const view = new Proxy(value, { + get(target, key) { + if (target instanceof Set) { + if (['add', 'delete', 'clear'].includes(key)) return () => { throw new TypeError('Use Notes selection operations'); }; + if (key === 'valueOf') return () => views.get(target); + if (key === 'forEach') return (callback, thisArg) => target.forEach(value => callback.call(thisArg, value, value, views.get(target))); + const member = Reflect.get(target, key, target); + return typeof member === 'function' ? member.bind(target) : member; + } + return readonly(Reflect.get(target, key)); + }, + set() { throw new TypeError('Use Notes state operations'); }, + deleteProperty() { throw new TypeError('Use Notes state operations'); }, + defineProperty() { throw new TypeError('Use Notes state operations'); }, + setPrototypeOf() { throw new TypeError('Use Notes state operations'); }, + preventExtensions() { throw new TypeError('Use Notes state operations'); }, + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (descriptor && 'value' in descriptor) return { ...descriptor, value: readonly(descriptor.value) }; + return descriptor; + }, + }); + views.set(value, view); targets.set(view, value); + return view; + } + const state = Object.freeze(Object.fromEntries(Object.keys(values).map(key => [key, () => readonly(values[key])]))); + const view = Object.freeze(Object.defineProperties({}, Object.fromEntries(Object.entries(state).map(([key, get]) => [key, { get, enumerable: true }])))); + return { + state: view, + setOpen: value => { values.open = value; }, + setEditingId: value => { values.editingId = value; }, + setActiveLabel: value => { values.activeLabel = value; }, + setActiveFilter: value => { values.activeFilter = value; }, + setReminderChipNext: value => { values.reminderChipNext = value; }, + setSearchQuery: value => { values.searchQuery = value; }, + setViewMode: value => { values.viewMode = value; }, + setShowingArchived: value => { values.showingArchived = value; }, + setSelectMode: value => { values.selectMode = value; }, + setSuppressNextNoteTapId: value => { values.suppressNextNoteTapId = value; }, + setLabelsCollapsed: value => { values.labelsCollapsed = value; }, + setLoading: value => { values.loading = value; }, + setFiredDotDismissedAt: value => { values.firedDotDismissedAt = value; }, + replaceNotes: notes => { values.notes = notes; }, + removeNoteAt: index => readonly(values.notes.splice(index, 1)[0]), + insertNoteAt: (index, note) => { values.notes.splice(index, 0, unwrapDeep(note)); }, + prependNote: note => { values.notes.unshift(unwrapDeep(note)); }, + replaceNoteAt: (index, note) => { values.notes[index] = unwrapDeep(note); }, + mergeNote: (note, patch) => { for (const [key, value] of Object.entries(patch)) unwrap(note)[key] = unwrapDeep(value); }, + updateItem: (note, index, patch) => { Object.assign(unwrap(unwrap(note).items[index]), unwrapDeep({ ...patch })); }, + spliceItems: (note, ...args) => unwrap(unwrap(note).items).splice(...args.map(arg => unwrapDeep(arg))), + selectId: id => { values.selectedIds.add(id); }, + deselectId: id => values.selectedIds.delete(id), + clearSelection: () => values.selectedIds.clear(), + }; +} diff --git a/frontend/src/features/notes/application/submission.js b/frontend/src/features/notes/application/submission.js new file mode 100644 index 000000000..4703eb7c1 --- /dev/null +++ b/frontend/src/features/notes/application/submission.js @@ -0,0 +1,69 @@ +/** Editor submission ordering, optimistic state, reminder rearming and save recovery. */ +export function createNotesSubmission({ + app, + api, + persistence, + clock, + uid, + effects, + fetchNotes +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders, _loadGlowedReminders, _saveGlowedReminders, _loadDraft, _clearDraft } = persistence; + function submitNote(payload, note, isEdit) { + // Reset fired reminder if due_date changed (so re-arm works), and also + // clear the entry-glow seen flag so the new firing glows again on the + // next time the user opens the panel. + if (isEdit && note.due_date !== payload.due_date) { + const fired = _loadFiredReminders(); + fired.delete(note.id); + _saveFiredReminders(fired); + const glowed = _loadGlowedReminders(); + glowed.delete(note.id); + _saveGlowedReminders(glowed); + effects.clearGlow(note.id); + } + // Edited notes move to the top of their section (under pinned). Compute + // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above + // siblings; the pin block keeps its own ordering above this. + if (!payload.pinned) { + // Both edits AND newly-created notes anchor above the rest of the + // unpinned section. Without this, freshly created notes sit at the + // bottom because manually-reordered siblings already carry negative + // sort_order values. + const minUnpinned = state.notes + .filter(n => !n.pinned && (!isEdit || n.id !== note.id)) + .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + payload.sort_order = minUnpinned - 1; + } + // Optimistic update : update local state first, render, then save in background + app.setEditingId(null); + const draftId = isEdit ? note.id : '__new__'; + const submittedDraft = JSON.stringify(_loadDraft(draftId)); + if (isEdit) { + const idx = state.notes.findIndex(n => n.id === note.id); + if (idx >= 0) app.replaceNoteAt(idx, { ...state.notes[idx], ...payload }); + } else { + app.prependNote({ ...payload, id: 'tmp_' + uid(), created_at: new Date(clock.now()).toISOString(), updated_at: new Date(clock.now()).toISOString() }); + } + effects.render(); + // Background save + api.saveNote(payload).then(saved => { + if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId); + if (!isEdit && saved && saved.id) { + // Replace temp ID with real one from server. AND re-render : the + // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign + // bumps the in-memory id, so all subsequent clicks (edit, done, copy, + // archive, delete) silently fail to find the note in `_notes`. + const tmp = state.notes.find(n => n.id.startsWith('tmp_')); + if (tmp) app.mergeNote(tmp, saved); + effects.render(); + } + }).catch(err => { + effects.showError('Save failed: ' + err.message); + fetchNotes().then(() => effects.render()); + }); + + } + return { submitNote }; +} diff --git a/frontend/src/features/notes/application/workflows.js b/frontend/src/features/notes/application/workflows.js new file mode 100644 index 000000000..018cbf8aa --- /dev/null +++ b/frontend/src/features/notes/application/workflows.js @@ -0,0 +1,78 @@ +/** Notes CRUD, optimistic archive and in-memory undo workflows. */ +export function createNotesWorkflows({ + app, + api, + effects +}) { + const state = app.state; + const _undoStack = []; + const _renderNotes = effects.render; + const _patchNote = api.patchNote; + const uiModule = effects; + function _pushUndo(entry) { + _undoStack.push(entry); + if (_undoStack.length > 20) _undoStack.shift(); + } + + function _popAndRunUndo() { + const entry = _undoStack.pop(); + if (entry) entry.run(); + return !!entry; + } + + function _undoArchive(note, prevIdx) { + // Re-insert at original position and clear archived flag on the server. + const safeIdx = Math.min(Math.max(prevIdx, 0), state.notes.length); + app.insertNoteAt(safeIdx, { ...note, archived: false }); + _renderNotes(); + _patchNote(note.id, { archived: false }).catch(() => { + // Roll back local insertion if the server refuses + const i = state.notes.findIndex(n => n.id === note.id); + if (i >= 0) app.removeNoteAt(i); + _renderNotes(); + uiModule.showError('Undo failed'); + }); + } + + function _archiveNoteById(id, { card = null, celebrate = false } = {}) { + if (!id) return false; + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return false; + const note = state.notes[idx]; + if (celebrate && card && note) effects.celebrateArchive(note, card); + const removed = app.removeNoteAt(idx); + app.setEditingId(null); + _renderNotes(); + const undo = () => _undoArchive(removed, idx); + _pushUndo({ label: 'archive', run: undo }); + _patchNote(id, { archived: true }).then(() => { + effects.archived(undo); + }).catch(() => { + app.insertNoteAt(idx, removed); + _renderNotes(); + uiModule.showError('Failed to archive'); + }); + return true; + } + + async function _fetchNotes() { + app.setLoading(true); + try { + const res = await api.listNotes(state.showingArchived); + if (!res.ok) { app.replaceNotes([]); return; } + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + } catch (e) { + console.error('Failed to fetch notes:', e); + app.replaceNotes([]); + } finally { + app.setLoading(false); + } + } + + function _saveNote(note) { return api.saveNote(note); } + + function _deleteNoteApi(id) { return api.deleteNote(id); } + + return { _pushUndo, _popAndRunUndo, _undoArchive, _archiveNoteById, _fetchNotes, _saveNote, _deleteNoteApi, _patchNote, undoCount: () => _undoStack.length }; +} diff --git a/frontend/src/features/notes/domain/noteRules.js b/frontend/src/features/notes/domain/noteRules.js new file mode 100644 index 000000000..ba3f8ae2f --- /dev/null +++ b/frontend/src/features/notes/domain/noteRules.js @@ -0,0 +1,87 @@ +/** Pure Notes completion, tags, reminder and serialization rules. */ +import { _hasTimeComponent } from './recurrence.js'; +export function _isCalendarReminderNote(n) { + return n && n.source === 'calendar' && n.label === 'calendar'; +} + +export function _hasActiveReminder(n, clock) { + if (!n || n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date) return false; + const t = new Date(n.due_date).getTime(); + return !isNaN(t) && t <= clock.now(); +} + +export function _isNoteFullyDone(note) { + if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) { + return note.items.every(it => it.done); + } + return false; +} + +export function _hasItems(note) { + return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist'); +} + +export function _goalProgress(note) { + if (!Array.isArray(note?.items) || note.items.length === 0) return ''; + const done = note.items.filter(it => it.done).length; + return ` ${done}/${note.items.length}`; +} + +export function _nextGoalStep(note) { + if (!Array.isArray(note?.items)) return null; + for (let i = 0; i < note.items.length; i++) { + if (!note.items[i].done) return { idx: i, item: note.items[i] }; + } + return null; +} + +export function _noteTags(n) { + const tags = []; + if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean)); + if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder'); + return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; +} + +export function _visibleNoteTags(n) { + return _noteTags(n).filter(t => t !== 'reminder'); +} + +export function _isPastReminder(n, clock) { + if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false; + const due = new Date(n.due_date).getTime(); + return !isNaN(due) && due <= clock.now(); +} + +export function _serializeNoteForCopy(note) { + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (Array.isArray(note.items) && note.items.length) { + if (lines.length) lines.push(''); + for (const it of note.items) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + } + return lines.join('\n').trim(); +} + +export function _noteToAgentPrompt(note) { + const parts = []; + if ((note.title || '').trim()) parts.push(note.title.trim()); + if ((note.content || '').trim()) parts.push(note.content.trim()); + if (Array.isArray(note.items)) { + note.items.filter(it => !it.done && (it.text || '').trim()) + .forEach(it => parts.push('- ' + it.text.trim())); + } + const body = parts.join('\n'); + return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : ''; +} + +export function _standardVisualVariant(note) { + const source = String(note?.id || note?.title || note?.created_at || 'standard-note'); + let hash = 0; + for (let i = 0; i < source.length; i++) hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; + return Math.abs(hash) % 5; +} diff --git a/frontend/src/features/notes/domain/recurrence.js b/frontend/src/features/notes/domain/recurrence.js new file mode 100644 index 000000000..f72498fbb --- /dev/null +++ b/frontend/src/features/notes/domain/recurrence.js @@ -0,0 +1,269 @@ +// Local-time date and recurrence rules. Callers supply the clock at the original read sites. +export const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; +export const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +export function _hasTimeComponent(dateStr) { + return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); +} + +export function _formatDueDate(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const hasTime = _hasTimeComponent(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const diffDays = Math.round((due - today) / 86400000); + const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; + if (hasTime && d < now) return 'overdue'; + if (!hasTime && diffDays < 0) return 'overdue'; + if (diffDays === 0) return hasTime ? timeStr : 'today'; + if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; +} + +export function _isDueOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + if (isNaN(d)) return false; + if (_hasTimeComponent(dateStr)) return d < new Date(clock.now()); + return d < new Date(new Date(clock.now()).toDateString()); +} + +export function _isDueTodayOrOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + const now = new Date(clock.now()); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return due <= today; +} + +export function _laterTodayDate(clock) { + const now = new Date(clock.now()); + const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today + // If less than 1 hour before 6pm, push to "in 3 hours" instead + if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); + return eight; +} + +export function _tomorrowDate(clock) { + const t = new Date(clock.now()); + t.setDate(t.getDate() + 1); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _nextWeekDate(clock) { + const t = new Date(clock.now()); + const daysUntilMon = (8 - t.getDay()) % 7 || 7; + t.setDate(t.getDate() + daysUntilMon); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _toLocalDatetimeStr(d) { + // Format as YYYY-MM-DDTHH:MM (local, no TZ) + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function _formatReminderTag(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const sameDay = d.toDateString() === now.toDateString(); + const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = d.toDateString() === tomorrow.toDateString(); + const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + if (sameDay) return `Today, ${time}`; + if (isTomorrow) return `Tomorrow, ${time}`; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return `${dateLabel}, ${time}`; +} + +export function _nthWeekdayLabel(d) { + const n = Math.ceil(d.getDate() / 7); // 1..5 + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; +} + +export function _isLastWeekdayOfMonth(d) { + const test = new Date(d); + test.setDate(d.getDate() + 7); + return test.getMonth() !== d.getMonth(); +} + +export function _nthWeekdayOfMonth(year, month, weekday, n) { + const first = new Date(year, month, 1); + const offset = (weekday - first.getDay() + 7) % 7; + let day = 1 + offset + (n - 1) * 7; + // Last day of month + const lastDay = new Date(year, month + 1, 0).getDate(); + if (day > lastDay) day -= 7; + return new Date(year, month, day, 0, 0, 0); +} + +export function _lastWeekdayOfMonth(year, month, weekday) { + const lastDay = new Date(year, month + 1, 0); + const back = (lastDay.getDay() - weekday + 7) % 7; + return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); +} + +export function _snapToRepeat(currentDate, normRepeat, clock) { + const hh = currentDate.getHours(); + const mm = currentDate.getMinutes(); + const now = clock.now(); + const anchor = currentDate.getTime() > now ? currentDate : new Date(clock.now()); + const parts = normRepeat.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + const targetWd = parseInt(parts[1], 10); + if (isNaN(targetWd)) return null; + const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); + const delta = (targetWd - d.getDay() + 7) % 7; + d.setDate(d.getDate() + delta); + if (d.getTime() <= now) d.setDate(d.getDate() + 7); + return d; + } + if (kind === 'monthly') { + const sub = parts[1]; + let y = anchor.getFullYear(); + let m = anchor.getMonth(); + // Walk forward up to 14 months to find the next matching slot. + for (let tries = 0; tries < 14; tries++) { + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + if (isNaN(wantDay)) return null; + const lastDay = new Date(y, m + 1, 0).getDate(); + target = new Date(y, m, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + if (isNaN(n) || isNaN(wd)) return null; + target = _nthWeekdayOfMonth(y, m, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + if (isNaN(wd)) return null; + target = _lastWeekdayOfMonth(y, m, wd); + } else { + return null; + } + target.setHours(hh, mm, 0, 0); + if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; + m++; + if (m > 11) { m = 0; y++; } + } + return null; + } + return null; +} + +export function _formatRepeatLabel(repeat, originalDate) { + if (!repeat || repeat === 'none') return ''; + const norm = _normalizeRepeat(repeat, originalDate); + if (norm === 'daily') return 'Daily'; + if (norm === 'yearly') return 'Yearly'; + const parts = norm.split(':'); + if (parts[0] === 'weekly') { + const wd = parseInt(parts[1], 10); + if (isNaN(wd)) return 'Weekly'; + return `Weekly on ${_DAYS[wd]}s`; + } + if (parts[0] === 'monthly') { + if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Monthly on last ${_DAYS[wd]}`; + } + } + return norm; +} + +export function _normalizeRepeat(repeat, originalDate) { + if (!repeat || repeat === 'none') return 'none'; + if (repeat === 'daily' || repeat === 'yearly') return repeat; + if (/^(weekly|monthly):/.test(repeat)) return repeat; + // Legacy bare values: derive params from the original date + const wd = originalDate.getDay(); + const n = Math.ceil(originalDate.getDate() / 7); + if (repeat === 'weekly') return `weekly:${wd}`; + if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; + if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; + if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; + return repeat; +} + +export function _advanceRecurring(dateStr, repeat, clock) { + const orig = new Date(dateStr); + const hh = orig.getHours(); + const mm = orig.getMinutes(); + let d = new Date(orig); + const norm = _normalizeRepeat(repeat, orig); + if (norm === 'none') return null; + + function step() { + if (norm === 'daily') { + d.setDate(d.getDate() + 1); + return; + } + if (norm === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + return; + } + const parts = norm.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + // Snap to the requested weekday in the next 1-7 days + const targetWd = parseInt(parts[1], 10); + let delta = (targetWd - d.getDay() + 7) % 7; + if (delta === 0) delta = 7; + d.setDate(d.getDate() + delta); + d.setHours(hh, mm, 0, 0); + return; + } + if (kind === 'monthly') { + const sub = parts[1]; + const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); + const nm = (d.getMonth() + 1) % 12; + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + const lastDay = new Date(ny, nm + 1, 0).getDate(); + target = new Date(ny, nm, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + target = _nthWeekdayOfMonth(ny, nm, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + target = _lastWeekdayOfMonth(ny, nm, wd); + } else { + d = null; return; + } + target.setHours(hh, mm, 0, 0); + d = target; + return; + } + d = null; + } + + step(); + if (d === null) return null; + const now = clock.now(); + // Cap catch-up to avoid runaway on a malformed/very-old date. + let guard = 5000; + while (d.getTime() <= now) { + if (--guard <= 0) return null; + step(); + if (d === null) return null; + } + return _toLocalDatetimeStr(d); +} diff --git a/frontend/src/features/notes/index.js b/frontend/src/features/notes/index.js new file mode 100644 index 000000000..146844b8a --- /dev/null +++ b/frontend/src/features/notes/index.js @@ -0,0 +1,503 @@ +/** Notes composition and intentional public API. The legacy entry activates compatibility startup. */ +import { createSelectionOperations } from './application/selection.js'; +import { createToolbar } from './ui/toolbar.js'; +import { createWindowLayout } from './ui/windowLayout.js'; +import { createCardOperations } from './application/cardOperations.js'; +import { createChecklistOperations } from './application/checklistOperations.js'; +import { createNotesSubmission } from './application/submission.js'; +import { createNotesDrafts } from './application/drafts.js'; +import { createNotesLifecycle } from './ui/lifecycle.js'; +import * as noteRules from './domain/noteRules.js'; +import { createText } from './ui/text.js'; +import { createColors } from './ui/colors.js'; +import { createPanel } from './ui/panel.js'; +import { createReminderDisplay } from './ui/reminderDisplay.js'; +import { createSelection } from './ui/selection.js'; +import { createFilters } from './ui/filters.js'; +import { createCardRendering } from './ui/cardRendering.js'; +import { createLayout } from './ui/layout.js'; +import { createQuickAdd } from './ui/quickAdd.js'; +import { createCardInteractions } from './ui/cardInteractions.js'; +import { createCardChecklist } from './ui/cardChecklist.js'; +import { createCardReorder } from './ui/cardReorder.js'; +import { createDraftEditor } from './ui/draftEditor.js'; +import { createEditor } from './ui/editor.js'; +import { createReminderPicker } from './ui/reminderPicker.js'; +import { createChecklist } from './ui/checklist.js'; +import { createDrawing } from './ui/drawing.js'; +import { createClipboard } from './ui/clipboard.js'; +import { createMobileEditor } from './ui/mobileEditor.js'; +import { createMobileReorder } from './ui/mobileReorder.js'; +import { createChecklistReorder } from './ui/checklistReorder.js'; +import { createQueries } from './application/queries.js'; +import { createAgentActions } from './application/agentActions.js'; +import { createDeletion } from './application/deletion.js'; +import { createNotesReminders } from './application/reminders.js'; +import { createNotesNotifications } from './infrastructure/notifications.js'; +import { createNotesState } from './application/state.js'; +import { createNotesWorkflows } from './application/workflows.js'; +import { createNotesApi } from './infrastructure/http.js'; +import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY, NOTES_FIRST_OPEN_HINT_KEY, NOTES_LABELS_COLLAPSED_KEY } from './infrastructure/storage.js'; +import * as dates from './domain/recurrence.js'; +import uiModule from '/static/js/ui.js?v=20260916largetoolscroll1'; +import { spawnConfetti } from '/static/js/shared/ui/confetti.js'; +import * as Modals from '/static/js/modalManager.js'; +import { attachColorPicker } from '/static/js/colorPicker.js?v=20260910eyedropper1'; +import { makeWindowDraggable } from '/static/js/windowDrag.js'; +import { snapModalToZone } from '/static/js/tileManager.js?v=20260910responsivebounds1'; +import { applyEdgeDock, clearDockSide } from '/static/js/modalSnap.js'; +import { topToolWindowZ, topPortalZ } from '/static/js/toolWindowZOrder.js'; +import { bindMenuDismiss, dismissOrRemove } from '/static/js/escMenuStack.js'; +import { bindTourHintDismiss } from '/static/js/tourHintDismiss.js'; + +const api = createNotesApi({ origin: window.location.origin, request: (...args) => fetch(...args) }); +const persistence = createNotesPersistence({ storage: typeof localStorage === 'undefined' ? undefined : localStorage }); +const { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft } = persistence; +const clock = { now: () => Date.now() }; +const app = createNotesState({ + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list', + labelsCollapsed: typeof localStorage !== 'undefined' && localStorage.getItem(NOTES_LABELS_COLLAPSED_KEY) === '1', + firedDotDismissedAt: (() => { try { const v = parseInt(persistence.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10); return Number.isFinite(v) && v > 0 ? v : 0; } catch { return 0; } })(), +}); +const state = app.state; +const lifecycle = createNotesLifecycle(); +const scheduler = { setTimeout: (...args) => setTimeout(...args), clearTimeout: id => clearTimeout(id), setInterval: (...args) => setInterval(...args), clearInterval: id => clearInterval(id) }; +const notifications = createNotesNotifications({ browser: window, notify: window.Notification, openPanel: () => openPanel(), toast: title => uiModule?.showToast?.(title) }); +const reminders = createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + fetchNotes: () => _fetchNotes(), + isNoteFullyDone: note => _isNoteFullyDone(note), + hasItems: note => _hasItems(note), + dismissedKey: REMINDER_DISMISSED_AT_KEY, + effects: { badge: () => _updateRailBadge(), highlight: id => { + _setReminderCardGlow(id, true); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (card) { card.classList.add('note-card-reminder-fired'); setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000); } + else _queuePendingHighlight(id); +} } +}); +const workflows = createNotesWorkflows({ + app, + api, + effects: { + render: () => _renderNotes(), + showError: (...args) => uiModule.showError(...args), + archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }), + celebrateArchive: (note, card) => { if (_hasItems(note) && !(note.items || []).some(i => !i.done)) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); } }, +} +}); +const _NOTE_UNDO_ICON = ''; +function _pushUndo(entry) { return workflows._pushUndo(entry); } +function _popAndRunUndo() { return workflows._popAndRunUndo(); } +function _undoArchive(note, prevIdx) { return workflows._undoArchive(note, prevIdx); } +function _archiveNoteById(id, options) { return workflows._archiveNoteById(id, options); } +function _fetchNotes() { return workflows._fetchNotes(); } + +function _deleteNoteApi(id) { return workflows._deleteNoteApi(id); } +function _patchNote(id, patch) { return workflows._patchNote(id, patch); } +function _uid() { return Math.random().toString(36).slice(2, 10); } +function _formatDueDate(dateStr) { return dates._formatDueDate(dateStr, clock); } +function _isDueOverdue(dateStr) { return dates._isDueOverdue(dateStr, clock); } + +function _laterTodayDate() { return dates._laterTodayDate(clock); } +function _tomorrowDate() { return dates._tomorrowDate(clock); } +function _nextWeekDate() { return dates._nextWeekDate(clock); } +function _formatReminderTag(dateStr) { return dates._formatReminderTag(dateStr, clock); } +function _snapToRepeat(currentDate, normRepeat) { return dates._snapToRepeat(currentDate, normRepeat, clock); } +function _ensureNotificationPermission() { return notifications.ensurePermission(); } + +function _startReminderLoop() { return reminders._startReminderLoop(); } + +function _countFiredReminders() { return reminders._countFiredReminders(); } + +function _hasActiveReminder(...args) { return noteRules._hasActiveReminder(...args, clock); } +const _isNoteFullyDone = noteRules._isNoteFullyDone; +const _hasItems = noteRules._hasItems; +function _isPastReminder(...args) { return noteRules._isPastReminder(...args, clock); } +function _esc(...args) { return text._esc(...args); } + +function _safeImgSrc(...args) { return text._safeImgSrc(...args); } +function _linkify(...args) { return text._linkify(...args); } +function _isBgImage(...args) { return colors._isBgImage(...args); } + +function _dotBg(...args) { return colors._dotBg(...args); } +function _dotIsActive(...args) { return colors._dotIsActive(...args); } +function _customColorStyle(...args) { return colors._customColorStyle(...args); } +function _pickCustomBgImage(...args) { return colors._pickCustomBgImage(...args); } +function _wireFormColors(...args) { return colors._wireFormColors(...args); } + +function openPanel(...args) { return panel.openPanel(...args); } + +function _smoothScrollToNoteCard(...args) { return panel._smoothScrollToNoteCard(...args); } +function _clearViewedReminderGlows(...args) { return reminderDisplay._clearViewedReminderGlows(...args); } +function _setReminderCardGlow(...args) { return reminderDisplay._setReminderCardGlow(...args); } +function _queuePendingHighlight(...args) { return reminderDisplay._queuePendingHighlight(...args); } +function _flushPendingHighlights(...args) { return reminderDisplay._flushPendingHighlights(...args); } +function _updateRailBadge(...args) { return reminderDisplay._updateRailBadge(...args); } +function _enterSelectMode(...args) { return selection._enterSelectMode(...args); } +function _exitSelectMode(...args) { return selection._exitSelectMode(...args); } +function _updateBulkBar(...args) { return selection._updateBulkBar(...args); } + +function _renderLabelsInto(...args) { return filters._renderLabelsInto(...args); } +function _renderLoadingSkeleton(...args) { return cardRendering._renderLoadingSkeleton(...args); } +function _renderNotes(...args) { return cardRendering._renderNotes(...args); } +function _captureCardPositions(...args) { return layout._captureCardPositions(...args); } +function _animateReflow(...args) { return layout._animateReflow(...args); } +function _applyMasonry(...args) { return layout._applyMasonry(...args); } +function _renderQuickAdd(...args) { return quickAdd._renderQuickAdd(...args); } + +function _editNote(...args) { return quickAdd._editNote(...args); } +function _bindCardEvents(...args) { return cardInteractions._bindCardEvents(...args); } +function _wireCardChecklist(...args) { return cardChecklist._wireCardChecklist(...args); } +function _wireTodayView(...args) { return cardChecklist._wireTodayView(...args); } +function _wireCardReorder(...args) { return cardReorder._wireCardReorder(...args); } + +function _wireDraftAutosave(...args) { return draftEditor._wireDraftAutosave(...args); } +function _commitOpenInPlaceEditor(...args) { return draftEditor._commitOpenInPlaceEditor(...args); } +function _applyDraftToNote(...args) { return draftEditor._applyDraftToNote(...args); } +function _buildForm(...args) { return editor._buildForm(...args); } +function _wireReminderPicker(...args) { return reminderPicker._wireReminderPicker(...args); } +function _buildGoalHtml(...args) { return checklist._buildGoalHtml(...args); } +function _wireGoalForm(...args) { return checklist._wireGoalForm(...args); } +function _buildChecklistHtml(...args) { return checklist._buildChecklistHtml(...args); } +function _wireRow(...args) { return checklist._wireRow(...args); } +function _wireChecklist(...args) { return checklist._wireChecklist(...args); } +function _collectItems(...args) { return checklist._collectItems(...args); } +function _buildDrawHtml(...args) { return drawing._buildDrawHtml(...args); } +function _wireCanvas(...args) { return drawing._wireCanvas(...args); } +function _uploadCanvasAsPng(...args) { return drawing._uploadCanvasAsPng(...args); } +function _openNoteCornerMenu(...args) { return clipboard._openNoteCornerMenu(...args); } +function _copyNote(...args) { return clipboard._copyNote(...args); } +function _isNotesMobileMode(...args) { return mobileEditor._isNotesMobileMode(...args); } +function _openMobileFullscreenEdit(...args) { return mobileEditor._openMobileFullscreenEdit(...args); } +function _closeMobileFullscreenEdit(...args) { return mobileEditor._closeMobileFullscreenEdit(...args); } + +function _bindLongPressDrag(...args) { return mobileReorder._bindLongPressDrag(...args); } + +function _wireChecklistTouchReorder(...args) { return checklistReorder._wireChecklistTouchReorder(...args); } + +function _sortedVisibleNotes(...args) { return queries._sortedVisibleNotes(...args); } +function _userVisibleNotes(...args) { return queries._userVisibleNotes(...args); } +function _agentSolveNote(...args) { return agentActions._agentSolveNote(...args); } + +function _clearPastReminders(...args) { return deletion._clearPastReminders(...args); } +const text = createText({ + uiModule +}); +const colors = createColors({ + api +}); +const { COLORS, COLOR_HEX } = colors; +const windowLayout = createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}); +const toolbar = createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}); +const panel = createPanel({ + wireToolbar: toolbar.wireToolbar, + wireSelectionActions: () => selection.wireSelectionActions(), + _wireNotesWindow: windowLayout._wireNotesWindow, + _restoreNotesSidebarDock: windowLayout._restoreNotesSidebarDock, + _bringNotesToFront: windowLayout._bringNotesToFront, + disposeUI: () => { layout.dispose(); mobileReorder.dispose(); checklistReorder.dispose(); lifecycle.dispose(); }, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}); +const reminderDisplay = createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}); +const selectionOperations = createSelectionOperations({ + app, + api, + fetchNotes: _fetchNotes, + exitSelection: _exitSelectMode, + sendContext: detail => window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail })), + effects: { render: _renderNotes, styledConfirm: uiModule.styledConfirm, showToast: (...args) => uiModule.showToast(...args) } +}); +const selection = createSelection({ + operations: selectionOperations, + _renderNotes, + app, + state +}); +const filters = createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}); +const cardRendering = createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}); +const layout = createLayout({ + isOpen: () => state.open +}); +const quickAdd = createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}); +const cardOperations = createCardOperations({ + app, + api, + pushUndo: _pushUndo, + undoArchive: _undoArchive, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), showToast: (...args) => uiModule.showToast(...args), archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }) } +}); +const checklistOperations = createChecklistOperations({ + app, + api, + uid: _uid, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args) } +}); +const cardInteractions = createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}); +const cardChecklist = createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}); +const cardReorder = createCardReorder({ + persistOrder: cardOperations.persistOrder, + lifecycle, + _isNotesMobileMode, + state +}); +const draftEditor = createDraftEditor({ + bindDraft: (...args) => drafts.bindDraft(...args), + _collectItems, + _loadDraft +}); +const editor = createEditor({ + deleteFromEditor: id => deletion.deleteFromEditor(id), + submitNote: (...args) => submission.submitNote(...args), + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}); +const reminderPicker = createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}); +const checklist = createChecklist({ + _esc, + _uid, + spawnConfetti +}); +const drawing = createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}); +const clipboard = createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}); +const mobileEditor = createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}); +const mobileReorder = createMobileReorder({ + commitMobileOrder: cardOperations.commitMobileOrder, + lifecycle, + _applyMasonry, + _enterSelectMode, + app +}); +const checklistReorder = createChecklistReorder({ + lifecycle +}); +const queries = createQueries({ + _hasActiveReminder, + state +}); +const agentActions = createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData: () => new FormData(), + state, + streamComplete: sid => { try { window.sessionModule?.markStreamComplete?.(sid); } catch {} }, + uiModule +}); +const deletion = createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects: { styledConfirm: uiModule.styledConfirm, confirm: message => confirm(message), showToast: (...args) => uiModule.showToast(...args), showError: (...args) => uiModule.showError(...args) }, + state +}); +const notesModule = { openPanel: panel.openPanel, closePanel: panel.closePanel, togglePanel: panel.togglePanel, isPanelOpen: panel.isPanelOpen, openNote: panel.openNote, newNote: quickAdd.newNote, openNotes: panel.openPanel, closeNotes: panel.closePanel, isNotesOpen: panel.isPanelOpen, refreshDueBadge: reminders.refreshDueBadge }; +const publicOpenPanel = panel.openPanel, publicClosePanel = panel.closePanel, publicTogglePanel = panel.togglePanel, publicIsPanelOpen = panel.isPanelOpen, publicOpenNote = panel.openNote, publicNewNote = quickAdd.newNote, publicRefreshDueBadge = reminders.refreshDueBadge, publicDismissFiredReminderDot = reminders.dismissFiredReminderDot; +export { publicOpenPanel as openPanel, publicClosePanel as closePanel, publicTogglePanel as togglePanel, publicIsPanelOpen as isPanelOpen, publicOpenNote as openNote, publicNewNote as newNote, publicRefreshDueBadge as refreshDueBadge, publicDismissFiredReminderDot as dismissFiredReminderDot, publicOpenPanel as openNotes, publicClosePanel as closeNotes, publicIsPanelOpen as isNotesOpen }; +export function startNotesBackground() { reminders.start(); } +export default notesModule; +const submission = createNotesSubmission({ + app, + api, + persistence, + clock, + uid: _uid, + fetchNotes: _fetchNotes, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), clearGlow: id => _setReminderCardGlow(id, false) } +}); +const drafts = createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects: { showError: (...args) => uiModule.showError(...args) } +}); diff --git a/frontend/src/features/notes/infrastructure/http.js b/frontend/src/features/notes/infrastructure/http.js new file mode 100644 index 000000000..eeafd1b0f --- /dev/null +++ b/frontend/src/features/notes/infrastructure/http.js @@ -0,0 +1,29 @@ +/** Notes HTTP port. Response interpretation stays with the owning workflow. */ +export function createNotesApi({ origin, request }) { + const json = (method, body) => ({ method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + async function saveNote(note) { + const response = await request(note.id ? `${origin}/api/notes/${note.id}` : `${origin}/api/notes`, json(note.id ? 'PUT' : 'POST', note)); + if (!response.ok) throw new Error('Failed to save note'); + return await response.json(); + } + async function patchNote(id, patch) { + const response = await request(`${origin}/api/notes/${id}`, json('PUT', patch)); + if (!response.ok) throw new Error('Failed to update note'); + return await response.json(); + } + async function deleteNote(id) { + const response = await request(`${origin}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' }); + if (!response.ok) throw new Error('HTTP ' + response.status); + } + return { + saveNote, patchNote, deleteNote, + listNotes: archived => request(`${origin}/api/notes${archived ? '?archived=true' : ''}`, { credentials: 'same-origin' }), + reorder: ids => request(`${origin}/api/notes/reorder`, json('POST', { ids })), + upload: body => request(`${origin}/api/upload`, { method: 'POST', body, credentials: 'same-origin' }), + uploadUrl: id => `${origin}/api/upload/${id}`, + fireReminder: body => request('/api/notes/fire-reminder', json('POST', body)), + defaultChat: () => request(`${origin}/api/default-chat`, { credentials: 'same-origin' }), + createSession: body => request(`${origin}/api/session`, { method: 'POST', body, credentials: 'same-origin' }), + startChat: body => request(`${origin}/api/chat_stream`, { method: 'POST', body, credentials: 'same-origin' }), + }; +} diff --git a/frontend/src/features/notes/infrastructure/notifications.js b/frontend/src/features/notes/infrastructure/notifications.js new file mode 100644 index 000000000..cd98eb702 --- /dev/null +++ b/frontend/src/features/notes/infrastructure/notifications.js @@ -0,0 +1,20 @@ +/** Browser notification permission/display port, independent of Notes rendering. */ +export function createNotesNotifications({ browser, notify, openPanel, toast }) { + async function ensurePermission() { + if (!('Notification' in browser)) return false; + if (notify.permission === 'granted') return true; + if (notify.permission === 'denied') return false; + try { return await notify.requestPermission() === 'granted'; } + catch { return false; } + } + function show(title, body, noteId) { + if ('Notification' in browser && notify.permission === 'granted') { + try { + const notification = new notify(title, { body, tag: 'note-' + noteId, icon: '/static/favicon.ico' }); + notification.onclick = () => { browser.focus(); openPanel(); notification.close(); }; + } catch {} + } + toast(title); + } + return { ensurePermission, show }; +} diff --git a/frontend/src/features/notes/infrastructure/storage.js b/frontend/src/features/notes/infrastructure/storage.js new file mode 100644 index 000000000..06aaa01ab --- /dev/null +++ b/frontend/src/features/notes/infrastructure/storage.js @@ -0,0 +1,63 @@ +/** Browser persistence and unchanged Notes key/serialization contracts. */ +export const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired'; +export const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed'; +export const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight'; +export const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'; +export const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at'; +export const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1'; +export const NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'; +export const _DRAFT_PREFIX = 'odysseus-note-draft-'; + +export function createNotesPersistence({ storage: localStorage }) { +function _loadPendingHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _loadGlowedReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveGlowedReminders(set) { + try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {} +} + +function _loadActiveHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveActiveHighlights(set) { + try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {} +} + +function _savePendingHighlights(set) { + try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); } + catch {} +} + +function _loadFiredReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveFiredReminders(set) { + try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); } + catch {} +} + +function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); } + +function _loadDraft(id) { + try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; } +} + +function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} } + +return { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft, + getItem: key => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: key => localStorage.removeItem(key), +}; +} diff --git a/frontend/src/features/notes/ui/cardChecklist.js b/frontend/src/features/notes/ui/cardChecklist.js new file mode 100644 index 000000000..6bbf39326 --- /dev/null +++ b/frontend/src/features/notes/ui/cardChecklist.js @@ -0,0 +1,148 @@ +/** Card checklist editing and Today-step interactions. */ +export function createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}) { + function _wireCardChecklist(body) { + // Remove a single checklist item (hover X) + body.querySelectorAll('.note-checkbox-rm').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + checklistOperations.removeItem(noteId, idx); + }); + }); + + function _startChecklistItemEdit(noteId, idx, span) { + if (span.isContentEditable) return; + const note = state.notes.find(n => n.id === noteId); + if (!note || !Array.isArray(note.items) || !note.items[idx]) return; + + span.textContent = note.items[idx].text || ''; + span.contentEditable = "true"; + span.spellcheck = false; + span.focus(); + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(span); + selection.removeAllRanges(); + selection.addRange(range); + + const save = () => { + if (!span.isContentEditable) return; + span.contentEditable = "false"; + const newText = span.textContent.trim(); + checklistOperations.editItem(note, idx, newText); + }; + + const onKeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + save(); + } else if (e.key === 'Escape') { + e.preventDefault(); + span.contentEditable = "false"; + _renderNotes(); + } + }; + + span.addEventListener('blur', save, { once: true }); + span.addEventListener('keydown', onKeydown); + } + + // Edit a single checklist item (hover Edit button) + body.querySelectorAll('.note-checkbox-edit').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + const span = btn.parentElement.querySelector('.note-check-text'); + if (span) _startChecklistItemEdit(noteId, idx, span); + }); + }); + + // Prevent clicks from toggling the row while actively editing inline + body.querySelectorAll('.note-check-text').forEach(span => { + span.addEventListener('click', (e) => { + if (span.isContentEditable) { + e.stopPropagation(); + } + }); + }); + + // Quick-add new checklist item (hover input at bottom of todo cards) + body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('keydown', async (e) => { + e.stopPropagation(); + if (e.key !== 'Enter') return; + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + const noteId = input.dataset.noteId; + const persist = checklistOperations.addItem(noteId, text, () => { input.value = ''; }); + if (!persist) return; + // Refocus the input on the same card + setTimeout(() => { + const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`); + if (next) next.focus(); + }, 0); + persist(); + }); + }); + + // Checkboxes (dot toggle, optimistic) : disabled in select mode + body.querySelectorAll('.note-checkbox').forEach(el => { + el.addEventListener('click', (e) => { + if (state.selectMode) return; // let card-level handler take over + e.preventDefault(); + e.stopPropagation(); + const noteId = el.dataset.noteId; + const idx = parseInt(el.dataset.idx); + checklistOperations.toggleItem(noteId, idx, done => el.classList.toggle('done', done), () => { + const card = el.closest('.note-card'); + if (card) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); } + }); + }); + }); + + + } + + function _wireTodayView(body) { + body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => { + dot.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = dot.dataset.noteId; + const idx = parseInt(dot.dataset.idx); + const row = dot.closest('.notes-today-row'); + await checklistOperations.toggleTodayStep(id, idx, () => row?.classList.add('done'), () => { + const r = (row || dot).getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); + }); + }); + }); + body.querySelectorAll('.notes-today-title').forEach(el => { + el.addEventListener('click', () => { + const id = el.dataset.noteId; + if (!id) return; + // Drop the Today filter first so the regular card list is rendered; + // _editNote needs to find a .note-card in the DOM to replace with + // the editor form. + app.setActiveFilter(null); + _renderNotes(); + _editNote(id); + }); + }); + } + + return { _wireCardChecklist, _wireTodayView }; +} diff --git a/frontend/src/features/notes/ui/cardInteractions.js b/frontend/src/features/notes/ui/cardInteractions.js new file mode 100644 index 000000000..09e083c41 --- /dev/null +++ b/frontend/src/features/notes/ui/cardInteractions.js @@ -0,0 +1,337 @@ +/** Card taps, pin/color controls, menus and archive animation. */ +export function createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}) { + function _bindCardEvents(body) { + // Skip completed leading rows so the first unfinished checklist item is + // visible when a note preview has its own scroll area. + requestAnimationFrame(() => { + body.querySelectorAll('.note-checklist-preview').forEach(preview => { + const firstPending = preview.querySelector('.note-checkbox:not(.done)'); + if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4); + }); + }); + const tapToEditOrSelect = (cardEl) => { + const id = cardEl.dataset.noteId; + // A long-press enters select mode and re-renders the card. Ignore the + // synthetic click that mobile browsers emit when that same touch ends. + if (state.suppressNextNoteTapId === id) { + app.setSuppressNextNoteTapId(null); + return; + } + if (state.selectMode) { + const cb = cardEl.querySelector('.note-card-cb'); + if (cb) { + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + } + } else if (_isNotesMobileMode()) { + // Mobile: open the per-note fullscreen edit overlay instead of the + // in-place form. Tiles on mobile are read-only previews. + _openMobileFullscreenEdit(id, cardEl); + } else { + _editNote(id); + } + }; + // Mobile: long-press anywhere on a note card → enter Select mode and + // select that card. Cancelled by movement (so it doesn't interfere with + // vertical scrolling) or by lifting the finger before the timer fires. + if (_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card)); + } + body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => { + card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true); + }); + // Click title : edit, or toggle select in select mode + body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + _smoothScrollToNoteCard(el.closest('.note-card')); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click content : edit, or toggle select in select mode + body.querySelectorAll('.note-content-preview').forEach(el => { + el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); + }); + // A saved drawing is the note's primary content. Open its editor directly + // when the preview is clicked, matching title/content preview behavior. + body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click empty area of checklist preview (not on checkbox/X) : edit + body.querySelectorAll('.note-checklist-preview').forEach(el => { + el.addEventListener('wheel', (e) => { + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + const pane = el.closest('.notes-pane-body'); + if (pane) { + pane.scrollTop += e.deltaY; + e.preventDefault(); + } + } + }, { passive: false }); + el.addEventListener('click', (e) => { + if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Clicking todo item text now toggles its checkbox : let the click bubble + // up to the parent .note-checkbox row handler. To open the editor, the + // user clicks the pencil corner. + // (No-op block kept as a marker : removing the listener entirely means + // clicks naturally bubble to the row toggle below.) + // In select mode, clicking anywhere on the card toggles selection + if (state.selectMode) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Mobile, non-select: tapping anywhere on the card body (not on an + // interactive child : buttons, pin, checkbox, color dot, reminder pill, + // agent tag, links) opens the fullscreen editor. Previously only the + // title / content preview triggered edit, so padding + empty gutters were + // dead zones that felt broken on mobile. + if (_isNotesMobileMode() && !state.selectMode) { + const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest(_INTERACTIVE)) return; + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Multi-select checkbox (only in select mode) + body.querySelectorAll('.note-card-cb').forEach(cb => { + cb.addEventListener('click', (e) => e.stopPropagation()); + cb.addEventListener('change', () => { + const id = cb.dataset.noteId; + if (cb.checked) app.selectId(id); + else app.deselectId(id); + cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked); + _updateBulkBar(); + }); + }); + // Pin toggle (optimistic) + body.querySelectorAll('.note-card-pin').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.togglePin(id); + }); + }); + // Color picker + const _applyCardColor = async (card, id, newColor) => { + const isBg = _isBgImage(newColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); }); + if (newColor && !isBg) card.classList.add('note-color-' + newColor); + if (isBg) card.setAttribute('style', _customColorStyle(newColor)); + else card.removeAttribute('style'); + card.querySelectorAll('.note-card-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor)); + d.style.background = _dotBg(d.dataset.color, newColor); + }); + await cardOperations.updateColor(id, newColor); + }; + body.querySelectorAll('.note-card-color-dot').forEach(dot => { + dot.addEventListener('click', (e) => { + e.stopPropagation(); + const card = dot.closest('.note-card'); + const id = card.dataset.noteId; + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); }); + return; + } + _applyCardColor(card, id, dot.dataset.color); + }); + }); + // Plain pencil corner → open editor. The unarchive corner shares the + // .note-card-edit-corner class for styling, so :not() keeps the edit + // handler off it. + body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (id) _editNote(id); + }); + }); + // Copy corner : bottom-right, just left of the Done check. Shared with + // the Ctrl/Cmd+C shortcut wired further down so both code paths run the + // same serializer + feedback flash. + // ⋯ corner menu : Copy + Agent (solve-this-todo). + body.querySelectorAll('.note-card-corner-menu').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _openNoteCornerMenu(btn); + }); + }); + body.querySelectorAll('.note-card-label-chip').forEach(chip => { + chip.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const label = chip.dataset.noteLabelFilter; + if (!label) return; + if (state.activeLabel === label && state.activeFilter === null) { + app.setActiveLabel(null); + } else { + app.setActiveFilter(null); + app.setActiveLabel(label); + } + _renderNotes(); + }); + }); + // Done (✓) at bottom-right : only visible on hover for active notes. + body.querySelectorAll('.note-card-done').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + const card = btn.closest('.note-card'); + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return; + // Celebrate completion : same confetti shower the bulk-archive uses. + if (card) { + const r = card.getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); + } + const finish = cardOperations.stageArchive(id); + if (!finish) return; + if (card) { + card.classList.add('note-card-sliding-out'); + let done = false; + const once = () => { if (done) return; done = true; finish(); }; + card.addEventListener('transitionend', once, { once: true }); + setTimeout(once, 400); + } else { + finish(); + } + }); + }); + // Unarchive corner : only visible in archive view. + body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Trash corner : archive view only. Permanent delete, no confirmation. + body.querySelectorAll('.note-card-corner-trash').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id, true); + }); + }); + + body.querySelectorAll('.note-card-archive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (!id) return; + const card = btn.closest('.note-card'); + let done = false; + const finishRemove = () => { + if (done) return; + done = true; + _archiveNoteById(id, { card, celebrate: true }); + }; + if (card) { + card.classList.add('note-card-sliding-out'); + card.addEventListener('transitionend', finishRemove, { once: true }); + setTimeout(finishRemove, 400); + } else { + finishRemove(); + } + }); + }); + // Unarchive (optimistic) : only present in archive view + body.querySelectorAll('.note-card-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Delete (optimistic) + body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id); + }); + }); + // Copy entire checklist (title + items, markdown-style) + body.querySelectorAll('.note-card-copy').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (lines.length) lines.push(''); + for (const it of (note.items || [])) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + const text = lines.join('\n').trim(); + try { + await navigator.clipboard.writeText(text); + uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`); + } catch { + // Fallback for browsers blocking the async API + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); uiModule.showToast?.('Copied'); } + catch { uiModule.showError?.('Copy failed'); } + ta.remove(); + } + }); + }); + + _wireCardChecklist(body); + _wireCardReorder(body); + + } + + return { _bindCardEvents }; +} diff --git a/frontend/src/features/notes/ui/cardRendering.js b/frontend/src/features/notes/ui/cardRendering.js new file mode 100644 index 000000000..315e47218 --- /dev/null +++ b/frontend/src/features/notes/ui/cardRendering.js @@ -0,0 +1,244 @@ +/** Render note cards from application queries and read-only state. */ +import { _nextGoalStep, _goalProgress, _hasItems, _standardVisualVariant, _visibleNoteTags } from '../domain/noteRules.js'; +import { _hasTimeComponent, _formatRepeatLabel } from '../domain/recurrence.js'; +export function createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}) { + function _renderLoadingSkeleton() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + const skel = document.createElement('div'); + skel.className = 'notes-skeleton'; + skel.innerHTML = ` +
+
+
+
+ `; + body.appendChild(skel); + } + + function _renderNotes() { + _updateRailBadge(); + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + const prevPositions = _captureCardPositions(); + const activeReminderHighlights = _loadActiveHighlights(); + + const sorted = _sortedVisibleNotes(); + + let html = ''; + // Today view: render a compact card listing the next-unchecked step from + // each active goal. Tapping a step toggles it done (same idx-based wiring + // as regular checkboxes). Tapping the title opens the goal note for full + // editing. + if (state.activeFilter === 'today') { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', `
All caught up — no pending goal steps right now.
`); + } else { + let todayHtml = `
+
+ + Today · one step per goal +
+
`; + for (const note of sorted) { + const next = _nextGoalStep(note); + if (!next) continue; + const progress = _goalProgress(note).trim(); + todayHtml += `
+ +
+
${_esc(note.title || '(untitled goal)')}
+
${_linkify(next.item.text || '')}
+
+ ${_esc(progress)} +
`; + } + todayHtml += `
`; + body.insertAdjacentHTML('beforeend', todayHtml); + } + _wireTodayView(body); + return; + } + for (const note of sorted) { + if (state.editingId === note.id) continue; // skip : form is shown instead + const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)'; + const dueFmt = _formatDueDate(note.due_date); + const overdue = _isDueOverdue(note.due_date); + + let contentHtml = ''; + if (_hasItems(note) && Array.isArray(note.items)) { + // Goal notes can carry a free-form description above the step list : + // todos rarely do, but the same render works for both. + if (note.note_type === 'goal' && (note.content || '').trim()) { + const fullText = note.content || ''; + const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText; + contentHtml += `
${_esc(preview)}
`; + } + contentHtml += '
'; + // Show ALL items : the preview container is scrollable (CSS caps + // its max-height + overflow-y:auto), so there's no need to truncate. + for (let i = 0; i < note.items.length; i++) { + const item = note.items[i]; + const doneClass = item.done ? ' done' : ''; + const indent = Math.min(item.indent || 0, 3); + contentHtml += `
+ + ${_linkify(item.text)} + + +
`; + } + contentHtml += '
'; + } else { + const fullText = note.content || ''; + const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText; + // _linkify already calls _esc internally, so URLs become clickable + // anchors (used by e.g. the "remind me to reply" email deep-link). + contentHtml = preview ? `
${_linkify(preview)}
` : ''; + } + + const isBg = _isBgImage(note.color); + const cc = (note.color && !isBg) ? ' note-color-' + note.color : ''; + const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : ''; + const isStandard = !note.note_type || note.note_type === 'note'; + const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : ''; + const sel = state.selectedIds.has(note.id) ? ' note-card-selected' : ''; + const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date) + ? `
+ + ${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''} +
` + : ''; + const noteTags = _visibleNoteTags(note); + const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `${dueFmt}` : ''; + const colorDots = COLORS.map(c => ``).join(''); + const goalClass = note.note_type === 'goal' ? ' note-card-goal' : ''; + const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : ''; + const goalPill = note.note_type === 'goal' + ? ` + + Goal${_goalProgress(note)} + ` + : ''; + const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : ''; + html += `
+ ${state.selectMode ? `` : ''} + ${goalPill} + + ${state.showingArchived + ? ` + ` + : ` + ${_hasItems(note) ? `` : ''}`} +
+
${_esc(note.title || '')}
+ ${dueBadge} +
+ ${_safeImgSrc(note.image_url) ? `` : ''} + ${contentHtml} + ${_hasItems(note) ? `
` : ''} + ${reminderTagHtml} + ${noteTags.length ? `
${noteTags.map(t => ``).join(' ')}
` : ''} +
+
${colorDots}
+ + ${state.showingArchived ? ` + + ` : ` + ${_hasItems(note) ? ` + ` : ''} + + + `} +
+
`; + } + + // Always render quick-add at top (collapsed unless user is typing) + const existingForm = body.querySelector('.note-form'); + if (existingForm && state.editingId === '__new__') { + // Keep the expanded form, replace cards after it + const next = [...body.children].filter(c => c !== existingForm); + next.forEach(c => c.remove()); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + existingForm.insertAdjacentHTML('afterend', html); + } + } else { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes yet ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + body.insertAdjacentHTML('beforeend', html); + } + } + + _bindCardEvents(body); + _animateReflow(prevPositions); + _applyMasonry(body); + } + + return { _renderLoadingSkeleton, _renderNotes }; +} diff --git a/frontend/src/features/notes/ui/cardReorder.js b/frontend/src/features/notes/ui/cardReorder.js new file mode 100644 index 000000000..dd32d517c --- /dev/null +++ b/frontend/src/features/notes/ui/cardReorder.js @@ -0,0 +1,181 @@ +/** Desktop and large-touch-device card reorder gestures. */ +export function createCardReorder({ + lifecycle, + _isNotesMobileMode, + persistOrder, + state +}) { + function _wireCardReorder(body) { + lifecycle.release(body); + // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom + // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is + // unreliable on touch browsers and can compete with the long-press flow. + if (!_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('dragstart', (e) => { + if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) { + e.preventDefault(); + return; + } + card.classList.add('dragging'); + body.classList.add('drag-active'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {} + }); + card.addEventListener('dragend', async () => { + card.classList.remove('dragging'); + body.classList.remove('drag-active'); + body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + try { await persistOrder(ids); } + catch {} + }); + }); + } + // Track which card we last swapped with so a single hover-over triggers + // one swap, not a jitter as the pointer keeps moving inside the same card. + let _lastSwapId = null; + function _maybeSwap(dragging, clientX, clientY) { + const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card'); + if (!target || target === dragging || !body.contains(target)) return; + const id = target.dataset.noteId; + if (id === _lastSwapId) return; + // FLIP across ALL siblings. In list view only `target` moves visually, but + // in grid view (2-col) and when the pinned-section grid-column-start rule + // shifts, several cards reflow at once. Capture every card's pre-swap rect, + // do the DOM swap, then animate any that actually moved. The dragging card + // is excluded : it's already finger-tracked via translate3d. + const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging); + const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()])); + const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling; + body.insertBefore(dragging, target); + body.insertBefore(target, draggingNext); + for (const c of cards) { + const prev = prevRects.get(c); + const next = c.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue; + c.style.transition = 'none'; + c.style.transform = `translate(${dx}px, ${dy}px)`; + requestAnimationFrame(() => { + c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)'; + c.style.transform = ''; + c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true }); + }); + } + _lastSwapId = id; + } + lifecycle.listen(body, body, 'dragover', (e) => { + e.preventDefault(); + const dragging = body.querySelector('.note-card.dragging'); + if (!dragging) return; + _maybeSwap(dragging, e.clientX, e.clientY); + }); + lifecycle.listen(body, body, 'dragend', () => { _lastSwapId = null; }); + + // Legacy touch drag for larger touch devices only. Phone-sized Notes uses + // the placeholder sorter wired by `_bindLongPressDrag`; running both flows + // makes one press start two independent drag sessions. + if (!_isNotesMobileMode() && 'ontouchstart' in window) { + let dragCard = null; + let isDragging = false; + let longPressTimer = null; + let startX = 0, startY = 0; + const LONG_PRESS_MS = 350; + const MOVE_THRESHOLD_PX = 8; + const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a'; + + // Anchor for the finger-follow transform. Recomputed after every swap so + // the card stays under the finger across reorderings. + let anchorX = 0, anchorY = 0; + const _follow = (clientX, clientY) => { + if (!dragCard) return; + const dx = clientX - anchorX; + const dy = clientY - anchorY; + // Compose with the CSS .dragging transform (scale + rotate). + dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`; + }; + const _reanchor = (clientX, clientY) => { + if (!dragCard) return; + // Clear any prior translate so the card resettles in its natural slot, + // then anchor at the finger's current position. Subsequent _follow calls + // translate by (finger - anchor), keeping the finger at the same relative + // spot on the card it had when we re-anchored. + dragCard.style.transform = ''; + anchorX = clientX; + anchorY = clientY; + }; + + const _endDrag = (committed) => { + clearTimeout(longPressTimer); + longPressTimer = null; + if (dragCard) { + dragCard.classList.remove('dragging'); + dragCard.style.transform = ''; + } + body.classList.remove('drag-active'); + _lastSwapId = null; + if (isDragging) { + document.documentElement.style.touchAction = ''; + if (committed) { + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + persistOrder(ids).catch(() => {}); + } + } + dragCard = null; + isDragging = false; + }; + lifecycle.onDispose(body, () => _endDrag(false)); + + lifecycle.listen(body, body, 'touchstart', (e) => { + if (state.selectMode) return; + const card = e.target.closest('.note-card'); + if (!card) return; + if (e.target.closest(_selectorSkip)) return; + const t = e.touches[0]; + startX = t.clientX; startY = t.clientY; + dragCard = card; + longPressTimer = setTimeout(() => { + if (!dragCard) return; + isDragging = true; + dragCard.classList.add('dragging'); + body.classList.add('drag-active'); + document.documentElement.style.touchAction = 'none'; + _reanchor(startX, startY); + try { if (navigator.vibrate) navigator.vibrate(15); } catch {} + }, LONG_PRESS_MS); + }, { passive: true }); + + lifecycle.listen(body, body, 'touchmove', (e) => { + if (!dragCard) return; + const t = e.touches[0]; + if (!isDragging) { + // Movement before long-press fires = user is scrolling; cancel pickup. + if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) { + clearTimeout(longPressTimer); + longPressTimer = null; + dragCard = null; + } + return; + } + e.preventDefault(); + // Live-follow the finger first, then check for a swap. After a swap the + // card's natural slot moves, so we re-anchor and re-apply the offset. + _follow(t.clientX, t.clientY); + const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + _maybeSwap(dragCard, t.clientX, t.clientY); + const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + if (before !== after) { + _reanchor(t.clientX, t.clientY); + _follow(t.clientX, t.clientY); + } + }, { passive: false }); + + lifecycle.listen(body, body, 'touchend', () => _endDrag(true)); + lifecycle.listen(body, body, 'touchcancel', () => _endDrag(false)); + } + } + + return { _wireCardReorder }; +} diff --git a/frontend/src/features/notes/ui/checklist.js b/frontend/src/features/notes/ui/checklist.js new file mode 100644 index 000000000..59a01aaa4 --- /dev/null +++ b/frontend/src/features/notes/ui/checklist.js @@ -0,0 +1,190 @@ +/** Checklist rows, goal forms, keyboard input and item collection. */ +export function createChecklist({ + _esc, + _uid, + spawnConfetti +}) { + function _buildGoalHtml(note, items) { + const desc = (note?.content || '').toString(); + return ` +
+ + ${_buildChecklistHtml(items)} +
+ `; + } + + function _wireGoalForm(form, container) { + if (!container) return; + // _wireHashtag is a closure inside _buildForm : out of scope here. Inline + // the same behavior (type "#foo " in the description → tag added to the + // form's label input) so editing a goal note doesn't ReferenceError. + const desc = container.querySelector('.note-form-goal-desc'); + const labelInput = form?.querySelector('.note-form-label'); + if (desc && labelInput) { + const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + desc.addEventListener('input', () => { + const m = tagRe.exec(desc.value); + if (!m) return; + const tag = m[2]; + // Same dedup-after-stripping fix as the plain note hashtag handler. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = desc.value.length - m[0].length + m[1].length; + desc.value = desc.value.slice(0, cut); + }); + } + // Always wire the checklist. The previous gate on a `note-form-goal-fresh` + // class was dead code : nothing ever set that class, so the editor never + // hooked up add/drag/Tab. + _wireChecklist(container); + } + + function _buildChecklistHtml(items) { + let html = '
'; + for (const item of items) { + const indent = Math.min(item.indent || 0, 3); + html += `
+ ⋮⋮ + + + +
`; + } + // `type="button"` matters on mobile : without it some browsers treat + // bare
`; + return html; + } + + function _wireRow(row, container) { + row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove()); + row.querySelector('.note-cl-dot')?.addEventListener('click', () => { + const wasDone = row.classList.contains('done'); + row.classList.toggle('done'); + // The custom dot has no native input/change event. Notify the form so + // mobile edit mode switches its action from Archive back to Update. + container.dispatchEvent(new Event('input', { bubbles: true })); + const becameDone = !wasDone; // we just flipped it + const dot = row.querySelector('.note-cl-dot'); + const dRect = (dot || row).getBoundingClientRect(); + // Small confetti burst on each fresh check so the user gets a + // "well done" beat per item, not just the grand-finale on all-done. + if (becameDone) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16); + } + // Bigger burst when the whole list is now done. + const rows = [...container.querySelectorAll('.note-cl-row')]; + const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0); + if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60); + } + }); + const txt = row.querySelector('.note-cl-text'); + txt?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); } + else if (e.key === 'Tab') { + e.preventDefault(); + const cur = parseInt(row.dataset.indent || '0'); + const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1); + row.dataset.indent = String(next); + row.style.paddingLeft = (next * 16) + 'px'; + } else if (e.key === 'Backspace' && txt.value === '') { + e.preventDefault(); + const prev = row.previousElementSibling; + row.remove(); + if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus(); + } + }); + // Drag handlers + row.addEventListener('dragstart', (e) => { + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {} + }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + }); + } + + function _wireChecklist(container) { + if (!container) return; + // Delegate the + Add click off the container so re-renders + mobile + // touch quirks don't leave the button dead. The previous direct + // `addEventListener` on the button silently broke on mobile when + // _wireChecklist ran more than once (or before the button was in DOM). + if (!container._addDelegated) { + container._addDelegated = true; + container.addEventListener('click', (ev) => { + const addBtn = ev.target.closest('.note-cl-add'); + if (!addBtn || !container.contains(addBtn)) return; + ev.preventDefault(); + ev.stopPropagation(); + const inputs = container.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const row = document.createElement('div'); + row.className = 'note-cl-row'; + row.draggable = true; + row.dataset.itemId = _uid(); + row.dataset.indent = '0'; + row.innerHTML = `⋮⋮`; + inputs.insertBefore(row, addBtn); + row.querySelector('.note-cl-text').focus(); + _wireRow(row, container); + }); + } + container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container)); + + // Drag-over handler on the inputs container + const inputs = container.querySelector('.note-checklist-inputs'); + if (inputs) { + inputs.addEventListener('dragover', (e) => { + e.preventDefault(); + const dragging = inputs.querySelector('.note-cl-row.dragging'); + if (!dragging) return; + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')]; + const after = rows.find(r => { + const box = r.getBoundingClientRect(); + return e.clientY < box.top + box.height / 2; + }); + if (after) { + after.classList.add('drop-before'); + inputs.insertBefore(dragging, after); + } else if (rows.length) { + rows[rows.length - 1].classList.add('drop-after'); + inputs.insertBefore(dragging, container.querySelector('.note-cl-add')); + } + }); + inputs.addEventListener('dragleave', (e) => { + if (!inputs.contains(e.relatedTarget)) { + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + } + }); + } + } + + function _collectItems(form) { + const items = []; + form.querySelectorAll('.note-cl-row').forEach(row => { + const text = row.querySelector('.note-cl-text')?.value?.trim(); + if (text) items.push({ + id: row.dataset.itemId || _uid(), + text, + done: row.classList.contains('done'), + indent: parseInt(row.dataset.indent || '0'), + }); + }); + return items; + } + + return { _buildGoalHtml, _wireGoalForm, _buildChecklistHtml, _wireRow, _wireChecklist, _collectItems }; +} diff --git a/frontend/src/features/notes/ui/checklistReorder.js b/frontend/src/features/notes/ui/checklistReorder.js new file mode 100644 index 000000000..c5c7e1101 --- /dev/null +++ b/frontend/src/features/notes/ui/checklistReorder.js @@ -0,0 +1,111 @@ +/** Mobile checklist grip dragging and document listener ownership. */ +export function createChecklistReorder({ + lifecycle +}) { + let _clDrag = null; + + let _clDocBound = false; + let _clOwner = null; + + function _wireChecklistTouchReorder(form) { + const container = form.querySelector('.note-checklist-inputs'); + if (!container) return; + container.querySelectorAll('.note-cl-grip').forEach(grip => { + if (grip.dataset.touchBound === '1') return; + grip.dataset.touchBound = '1'; + grip.addEventListener('touchstart', (e) => { + if (e.touches.length !== 1) return; + e.preventDefault(); + const row = grip.closest('.note-cl-row'); + if (!row) return; + _beginChecklistGrab(row, container, e.touches[0]); + }, { passive: false }); + }); + if (!_clDocBound || _clOwner !== form) { + if (_clOwner) lifecycle.release(_clOwner); + _clOwner = form; + lifecycle.listen(form, document, 'touchmove', _onClTouchMove, { passive: false }); + lifecycle.listen(form, document, 'touchend', _onClTouchEnd, { passive: true }); + lifecycle.listen(form, document, 'touchcancel', _onClTouchEnd, { passive: true }); + _clDocBound = true; + } + } + + function _beginChecklistGrab(row, container, touch) { + if (_clDrag) return; + const rect = row.getBoundingClientRect(); + const placeholder = document.createElement('div'); + placeholder.className = 'note-cl-row-placeholder'; + placeholder.style.height = rect.height + 'px'; + container.insertBefore(placeholder, row); + + row.classList.add('note-cl-row-dragging'); + row.style.position = 'fixed'; + row.style.left = rect.left + 'px'; + row.style.top = rect.top + 'px'; + row.style.width = rect.width + 'px'; + row.style.zIndex = '10002'; + row.style.pointerEvents = 'none'; + + _clDrag = { + row, placeholder, container, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onClTouchMove(e) { + if (!_clDrag) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const t = e.touches[0]; + const { row, placeholder, container } = _clDrag; + row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px'; + row.style.top = (t.clientY - _clDrag.grabOffsetY) + 'px'; + + const under = document.elementFromPoint(t.clientX, t.clientY); + const target = under && under.closest + ? under.closest('.note-cl-row:not(.note-cl-row-dragging)') + : null; + if (!target || target === row) return; + if (target.parentNode !== container) return; + + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (t.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + container.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + container.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onClTouchEnd() { + if (!_clDrag) return; + const { row, placeholder } = _clDrag; + _clDrag = null; + const phRect = placeholder.getBoundingClientRect(); + row.style.transition = 'left 0.18s ease, top 0.18s ease'; + row.style.left = phRect.left + 'px'; + row.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(row, placeholder); + placeholder.remove(); + row.classList.remove('note-cl-row-dragging'); + row.style.cssText = ''; + // Order is persisted as part of the form's normal save (rows are + // re-serialized in DOM order via _collectItems). + }, 200); + } + + function dispose() { + if (_clDrag) _onClTouchEnd(); + _clDocBound = false; + _clOwner = null; + } + return { dispose, _wireChecklistTouchReorder, _beginChecklistGrab, _onClTouchMove, _onClTouchEnd }; +} diff --git a/frontend/src/features/notes/ui/clipboard.js b/frontend/src/features/notes/ui/clipboard.js new file mode 100644 index 000000000..1dab84258 --- /dev/null +++ b/frontend/src/features/notes/ui/clipboard.js @@ -0,0 +1,82 @@ +/** Note clipboard feedback and the Copy/Agent corner menu. */ +import { _serializeNoteForCopy } from '../domain/noteRules.js'; +export function createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}) { + function _openNoteCornerMenu(btn) { + document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const menu = document.createElement('div'); + menu.className = 'note-corner-menu-dropdown'; + menu.innerHTML = ` + + `; + document.body.appendChild(menu); + const r = btn.getBoundingClientRect(); + // Right-align to the ⋯ button, clamped to the viewport. + const mw = 168; + let left = Math.min(r.right - mw, window.innerWidth - mw - 8); + left = Math.max(8, left); + // Drop down by default; flip up if there isn't room below (the button + // sits at the card's bottom edge now). + const mh = menu.offsetHeight || 96; + const below = window.innerHeight - r.bottom; + const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); + menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`; + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); + menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); + } + + async function _copyNote(noteId, btnEl) { + const note = state.notes.find(n => n.id === noteId); + if (!note) return false; + const text = _serializeNoteForCopy(note); + if (!text) return false; + let ok = false; + try { + await navigator.clipboard.writeText(text); + ok = true; + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { ok = document.execCommand('copy'); } catch { ok = false; } + ta.remove(); + } + if (ok) { + if (btnEl && !btnEl._copyFlashing) { + const original = btnEl.innerHTML; + btnEl._copyFlashing = true; + btnEl.innerHTML = ''; + btnEl.classList.add('copied'); + setTimeout(() => { + btnEl.innerHTML = original; + btnEl.classList.remove('copied'); + btnEl._copyFlashing = false; + }, 1200); + } + uiModule.showToast?.('Copied'); + } else { + uiModule.showError?.('Copy failed'); + } + return ok; + } + + return { _openNoteCornerMenu, _copyNote }; +} diff --git a/frontend/src/features/notes/ui/colors.js b/frontend/src/features/notes/ui/colors.js new file mode 100644 index 000000000..0a9503cfb --- /dev/null +++ b/frontend/src/features/notes/ui/colors.js @@ -0,0 +1,111 @@ +/** Note colors, custom background selection and form color controls. */ +export function createColors({ + api +}) { + const COLORS = [ + { name: 'none', value: '' }, + { name: 'red', value: 'red' }, + { name: 'orange', value: 'orange' }, + { name: 'yellow', value: 'yellow' }, + { name: 'green', value: 'green' }, + { name: 'blue', value: 'blue' }, + { name: 'purple', value: 'purple' }, + { name: 'custom', value: 'custom' }, // sentinel : clicking opens native color picker + ]; + + const COLOR_HEX = { + '': 'var(--border)', + // Pale/pastel palette : matches the calendar event color picker. + red: '#f0b5ba', + orange: '#e8ccb2', + yellow: '#f2dfbd', + green: '#cce0bc', + blue: '#b0d7f7', + purple: '#e2bcee', + }; + + const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; + + function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); } + + function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; } + + function _dotBg(value, noteColor) { + if (value === 'custom') { + const url = _bgImageUrl(noteColor); + return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT; + } + return COLOR_HEX[value]; + } + + function _dotIsActive(value, noteColor) { + if (value === 'custom') return _isBgImage(noteColor); + return value === (noteColor || ''); + } + + function _customColorStyle(c) { + if (!_isBgImage(c)) return ''; + const url = _bgImageUrl(c); + return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`; + } + + function _pickCustomBgImage() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; + document.body.appendChild(input); + let done = false; + const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + finish(api.uploadUrl(fileId)); + } catch { finish(null); } + }); + // Best-effort cleanup if user dismisses the dialog. + setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); + input.click(); + }); + } + + function _wireFormColors(form, setColor) { + // Color dots : apply to entire form immediately + const _applyFormColor = (newColor) => { + const currentColor = newColor || ''; + setColor(currentColor); + form.dataset.noteColor = currentColor; + const isBg = _isBgImage(currentColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); }); + if (currentColor && !isBg) form.classList.add('note-color-' + currentColor); + if (isBg) form.setAttribute('style', _customColorStyle(currentColor)); + else form.removeAttribute('style'); + form.querySelectorAll('.note-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor)); + d.style.background = _dotBg(d.dataset.color, currentColor); + }); + form.dispatchEvent(new Event('change', { bubbles: true })); + }; + form.querySelectorAll('.note-color-dot').forEach(dot => { + dot.addEventListener('click', () => { + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); }); + return; + } + _applyFormColor(dot.dataset.color); + }); + }); + + + } + + return { COLORS, COLOR_HEX, _isBgImage, _bgImageUrl, _dotBg, _dotIsActive, _customColorStyle, _pickCustomBgImage, _wireFormColors }; +} diff --git a/frontend/src/features/notes/ui/draftEditor.js b/frontend/src/features/notes/ui/draftEditor.js new file mode 100644 index 000000000..09df33b79 --- /dev/null +++ b/frontend/src/features/notes/ui/draftEditor.js @@ -0,0 +1,67 @@ +/** Collect editor drafts, restore them and wire the autosave workflow. */ +export function createDraftEditor({ + bindDraft, + _collectItems, + _loadDraft +}) { + function _collectFormDraft(form) { + if (!form) return null; + const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || form.dataset.noteType || 'note'; + const d = { + _ts: Date.now(), + note_type: type, + color: form.dataset.noteColor || '', + title: form.querySelector('.note-form-title')?.value || '', + label: form.querySelector('.note-form-label')?.value || '', + due_date: form.querySelector('.note-form-due')?.value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + }; + if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || ''; + else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); } + else d.items = _collectItems(form); + return d; + } + + function _isDraftEmpty(d) { + if (!d) return true; + if ((d.title || '').trim()) return false; + if ((d.content || '').trim()) return false; + if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false; + return true; + } + + function _wireDraftAutosave(form, id) { + const view = { + get failed() { return form._autosaveFailed; }, + set failed(value) { form._autosaveFailed = value; }, + }; + const draft = bindDraft({ collect: () => _collectFormDraft(form), id, view }); + form._flushDraft = draft.flush; + form._stopDraftAutosave = draft.stop; + form.addEventListener('input', draft.schedule); + form.addEventListener('change', draft.schedule); + form.addEventListener('click', event => { + if (event.target.closest('.note-cl-dot, .note-cl-rm')) draft.schedule(); + }); + } + + function _commitOpenInPlaceEditor() { + const form = document.querySelector('#notes-pane .note-form'); + if (!form) return; + const d = _collectFormDraft(form); + if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; } + form.querySelector('.note-form-save')?.click(); + } + + function _applyDraftToNote(note, id) { + const d = _loadDraft(id); + if (!d) return { note, restored: false }; + const merged = { ...(note || {}) }; + ['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => { + if (d[k] !== undefined) merged[k] = d[k]; + }); + return { note: merged, restored: true }; + } + + return { _collectFormDraft, _isDraftEmpty, _wireDraftAutosave, _commitOpenInPlaceEditor, _applyDraftToNote }; +} diff --git a/frontend/src/features/notes/ui/drawing.js b/frontend/src/features/notes/ui/drawing.js new file mode 100644 index 000000000..da33c9236 --- /dev/null +++ b/frontend/src/features/notes/ui/drawing.js @@ -0,0 +1,442 @@ +/** Canvas drawing tools, local drawing undo and PNG uploads. */ +export function createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}) { + function _buildDrawHtml() { + return ` +
+ +
+ + +
+ + +
+ + + + +
+
+ `; + } + + function _wireCanvas(container, initialImageUrl) { + const canvas = container.querySelector('.note-form-canvas'); + if (!canvas) return; + const resolveThemeColor = (expression, fallback) => { + const probe = document.createElement('span'); + probe.style.color = expression; + probe.style.position = 'fixed'; + probe.style.visibility = 'hidden'; + document.body.appendChild(probe); + const resolved = getComputedStyle(probe).color || fallback; + probe.remove(); + return resolved; + }; + const colorToHex = (color, fallback) => { + try { + const probe = document.createElement('canvas'); + probe.width = probe.height = 1; + const probeCtx = probe.getContext('2d'); + probeCtx.fillStyle = color; + probeCtx.fillRect(0, 0, 1, 1); + const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`; + } catch { return fallback; } + }; + const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34'); + const surfaceColorHex = colorToHex(surfaceColor, '#282c34'); + const highlightColor = colorToHex( + resolveThemeColor('var(--accent, var(--red))', '#e06c75'), + '#e06c75', + ); + const colorInput = container.querySelector('.note-form-draw-color'); + if (colorInput) colorInput.value = highlightColor; + // Bump the backing-store resolution for retina displays so strokes stay + // crisp. Set only style.width : leaving style.height to auto so the canvas + // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions + // are pinned, max-width:100% shrinks the width only, leaving rasterized + // glyphs visibly stretched relative to their on-screen input. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const cssW = canvas.width; + const cssH = canvas.height; + // Fill the container up to the logical width (don't pin a hard 600px, + // which on a narrow phone forces the card wider than the viewport and + // pushes the drawing outside the note). _pos() scales pointer coords by + // the actual displayed width, so accuracy is preserved at any size. + canvas.style.width = '100%'; + canvas.style.maxWidth = cssW + 'px'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = cssW + ' / ' + cssH; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Load prior drawing as starting point so consecutive edits compose. + const safeInitialImageUrl = _safeImgSrc(initialImageUrl); + if (safeInitialImageUrl) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} }; + img.src = safeInitialImageUrl; + // Float an X over the canvas so the user can blank it out and go back to + // a clean draw surface. Removes itself once clicked. + const wrap = container.querySelector('.note-form-draw-wrap'); + if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) { + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'note-form-draw-bg-rm'; + rm.title = 'Clear photo (regular draw)'; + rm.innerHTML = '×'; + rm.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + rm.remove(); + }); + wrap.appendChild(rm); + } + } + + // Swap the native browser color dialog for the in-house HSV picker + // (same one used by Themes + the gallery editor). Existing `input` event + // listeners + .value reads keep working : see colorPicker.js. + if (colorInput) attachColorPicker(colorInput); + const sizeInput = container.querySelector('.note-form-draw-size'); + const beSeg = container.querySelector('.note-form-draw-be'); + const brushBtn = container.querySelector('.note-form-draw-brush'); + const eraserBtn = container.querySelector('.note-form-draw-eraser'); + const textBtn = container.querySelector('.note-form-draw-text'); + const lineBtn = container.querySelector('.note-form-draw-line'); + const circleBtn = container.querySelector('.note-form-draw-circle'); + const undoBtn = container.querySelector('.note-form-draw-undo'); + // Single source of truth for what clicks/drags do. Other booleans are + // derived from this so we never end up with conflicting "eraser AND text" + // states (the bug that made T appear broken after using the eraser). + // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle' + let mode = 'pen'; + let drawing = false; + let last = null; + // The text tool has three preset sizes (CSS px font-size). + const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 }; + // Line / circle stroke widths in logical pixels : three crisp options. + const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 }; + // Snapshot taken at the start of a shape drag so the preview can repaint + // cleanly on each move without accumulating intermediate strokes. + let _shapeSnapshot = null; + + // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each + // operation : stroke, text commit, or future operations : and pop+restore + // on Undo. Cap to 30 to keep memory bounded. + const _undoStack = []; + const UNDO_LIMIT = 30; + const _syncUndoButton = () => { + const available = _undoStack.length > 0; + undoBtn?.classList.toggle('has-undo', available); + undoBtn?.toggleAttribute('disabled', !available); + undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true'); + }; + const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true })); + const _snapshot = () => { + try { + const w = canvas.width, h = canvas.height; + _undoStack.push(ctx.getImageData(0, 0, w, h)); + if (_undoStack.length > UNDO_LIMIT) _undoStack.shift(); + _syncUndoButton(); + } catch {} + }; + const _undo = () => { + const prev = _undoStack.pop(); + if (!prev) { _syncUndoButton(); return; } + // Restore against the raw backing store: temporarily reset the active + // ctx scale, paint the snapshot 1:1, then reapply our standard transform. + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(prev, 0, 0); + ctx.restore(); + _markCanvasDirty(); + _syncUndoButton(); + }; + + const _pos = (e) => { + // CSS can shrink the canvas (max-width:100%) without changing its logical + // size, so compute the displayed-to-logical scale per axis. Pointer coords + // are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH). + const r = canvas.getBoundingClientRect(); + const sx = cssW / r.width; + const sy = cssH / r.height; + const t = e.touches ? e.touches[0] : e; + return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; + }; + const _begin = (e) => { + if (mode.startsWith('text-')) { + // Stop the event so the browser doesn't synthesize a follow-up click + // that would blur the input we're about to create. + e.preventDefault?.(); + e.stopPropagation?.(); + _openTextInput(e); + return; + } + _snapshot(); + last = _pos(e); + drawing = true; + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Capture the backing pixels so the preview can replay each move from + // the same starting state (otherwise live shapes accumulate). + try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {} + return; + } + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + }; + + // Drop an HTML input at the click position so the user can type, then + // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form + // annotations work in our doc editor. + let _activeTextInput = null; + const _openTextInput = (e) => { + // Commit any prior pending input before starting a new one : otherwise + // the first click leaves an orphaned input the user thinks "didn't work". + if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} } + const r = canvas.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + // Position is anchored to the wrap, not the canvas; since the canvas is + // the first child of the wrap and the wrap has no padding, they share an + // origin, so we offset from the canvas rect directly. + const px = t.clientX - r.left; + const py = t.clientY - r.top; + const logical = _pos(e); + // Size is decided by which T variant is active (S/M/L), not the stroke + // slider : those are independent dials. + const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm'; + const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m; + const wrap = container.querySelector('.note-form-draw-wrap'); + if (!wrap) return; + if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'note-form-draw-textinput'; + input.placeholder = 'type then Enter'; + const color = colorInput?.value || '#222'; + const maxW = Math.max(120, Math.floor(r.width - px - 4)); + input.style.cssText = [ + 'position:absolute', + `left:${px}px`, + `top:${Math.max(0, py - sizeCss * 0.7)}px`, + `font:${sizeCss}px Arial, sans-serif`, + `color:${color}`, + 'background:#ffffff', + 'border:2px solid var(--accent)', + 'border-radius:4px', + 'outline:none', + 'padding:2px 6px', + 'min-width:120px', + `max-width:${maxW}px`, + 'z-index:1000', + 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', + 'pointer-events:auto', + ].join(';'); + wrap.appendChild(input); + _activeTextInput = input; + // Focus synchronously so the call still counts as inside the user gesture + // on iOS / Android, then re-focus on the next frame in case a competing + // synthetic event (touch → click) moved focus away. + input.focus(); + requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); }); + let committed = false; + const commit = () => { + if (committed) return; + committed = true; + const text = input.value; + if (_activeTextInput === input) _activeTextInput = null; + input.remove(); + if (!text) return; + // Snapshot BEFORE rasterizing so Undo removes the text in one step. + _snapshot(); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = color; + // Canvas now scales uniformly (style.height: auto), so a single ratio + // suffices for picking the logical font size that matches what the + // user just saw in the HTML input. + const sx = cssW / r.width; + const logicalSize = sizeCss * sx; + ctx.font = `${logicalSize}px sans-serif`; + ctx.textBaseline = 'top'; + ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7); + ctx.restore(); + _markCanvasDirty(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } + else if (ev.key === 'Escape') { input.value = ''; input.blur(); } + }); + }; + const _move = (e) => { + if (!drawing) return; + e.preventDefault?.(); + const p = _pos(e); + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Restore the pre-shape bitmap, then redraw the preview shape from + // anchor → current pointer. + if (_shapeSnapshot) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(_shapeSnapshot, 0, 0); + ctx.restore(); + } + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = colorInput?.value || '#222'; + const sizeKey = mode.slice(-1); + ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m; + ctx.beginPath(); + if (mode.startsWith('line-')) { + ctx.moveTo(last.x, last.y); + ctx.lineTo(p.x, p.y); + } else { + const dx = p.x - last.x; + const dy = p.y - last.y; + const radius = Math.hypot(dx, dy); + ctx.arc(last.x, last.y, radius, 0, Math.PI * 2); + } + ctx.stroke(); + return; + } + const erasing = mode === 'eraser'; + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor); + ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + last = p; + }; + const _end = () => { + if (drawing) _markCanvasDirty(); + drawing = false; last = null; _shapeSnapshot = null; + }; + + canvas.addEventListener('mousedown', _begin); + canvas.addEventListener('mousemove', _move); + lifecycle.listen(canvas, window, 'mouseup', _end); + // Non-passive so text mode can preventDefault : otherwise the synthetic + // mousedown/click that follows a touch can blur the freshly-created text + // input on iOS Safari, making T look like a no-op. + canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false }); + canvas.addEventListener('touchmove', _move, { passive: false }); + canvas.addEventListener('touchend', _end); + canvas.addEventListener('touchcancel', _end); + + // Single mode setter : keeps the toolbar, swatch, and cursor in sync, and + // makes sure exiting the eraser restores the user's chosen color whether + // they exit by clicking another tool or by toggling eraser off. + let _preEraseColor = null; + const _setMode = (next) => { + const wasEraser = mode === 'eraser'; + mode = next; + const isEraser = next === 'eraser'; + const isPen = next === 'pen'; + const isText = next.startsWith('text-'); + // Swatch: theme surface while erasing, restored when leaving that mode. + if (isEraser && !wasEraser && colorInput) { + _preEraseColor = colorInput.value; + colorInput.value = surfaceColorHex; + } else if (!isEraser && wasEraser && colorInput && _preEraseColor) { + colorInput.value = _preEraseColor; + _preEraseColor = null; + } + // Brush/Eraser segmented pill : slides to the active side. When a non- + // pen/eraser tool (T / line / circle) is active, neither half is + // highlighted, but the pill still indicates which side the user was on + // last so they can return with one click. + const isLine = next.startsWith('line-'); + const isCircle = next.startsWith('circle-'); + beSeg?.classList.toggle('is-eraser', isEraser); + brushBtn?.classList.toggle('active', isPen); + eraserBtn?.classList.toggle('active', isEraser); + textBtn?.classList.toggle('active', isText); + lineBtn?.classList.toggle('active', isLine); + circleBtn?.classList.toggle('active', isCircle); + // Per-button size badges (S/M/L), driven off the mode suffix. + const tBadge = textBtn?.querySelector('.note-form-draw-text-badge'); + if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : ''; + const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge'); + if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : ''; + const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge'); + if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : ''; + // Reflect the chosen size in the icon itself : thicker line/circle stroke + // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`. + const _sz = next.slice(-1); + [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l')); + if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz); + if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz); + if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz); + canvas.style.cursor = isText ? 'text' : 'crosshair'; + }; + brushBtn?.addEventListener('click', () => _setMode('pen')); + eraserBtn?.addEventListener('click', () => _setMode('eraser')); + // T / Line / Circle: each cycles its own three sizes, then back to pen. + const _cycle = (prefix) => { + const seq = ['s', 'm', 'l']; + if (!mode.startsWith(prefix)) return prefix + 's'; + const cur = mode.slice(-1); + const next = seq[seq.indexOf(cur) + 1]; + return next ? prefix + next : 'pen'; + }; + textBtn?.addEventListener('click', () => _setMode(_cycle('text-'))); + lineBtn?.addEventListener('click', () => _setMode(_cycle('line-'))); + circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-'))); + undoBtn?.addEventListener('click', () => _undo()); + _syncUndoButton(); + + // Stash so the save handler can read it later without re-resolving DOM. + canvas._cssW = cssW; + canvas._cssH = cssH; + return canvas; + } + + async function _uploadCanvasAsPng(canvas, galleryId = null) { + if (!canvas) return null; + const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); + if (!blob) return null; + const fd = new FormData(); + fd.append('files', blob, 'drawing.png'); + if (galleryId) fd.append('gallery_id', galleryId); + try { + const res = await api.upload(fd); + const data = await res.json(); + const id = data.files?.[0]?.id; + return id ? { + url: api.uploadUrl(id), + galleryId: data.files?.[0]?.gallery_id || null, + } : null; + } catch { return null; } + } + + return { _buildDrawHtml, _wireCanvas, _uploadCanvasAsPng }; +} diff --git a/frontend/src/features/notes/ui/editor.js b/frontend/src/features/notes/ui/editor.js new file mode 100644 index 000000000..f5bdc7c9f --- /dev/null +++ b/frontend/src/features/notes/ui/editor.js @@ -0,0 +1,518 @@ +/** Build and wire note, checklist, goal and drawing editor forms. */ +export function createEditor({ + deleteFromEditor, + submitNote, + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}) { + function _buildForm(note = null) { + const isEdit = note && note.id; + const type = note?.note_type === 'checklist' ? 'todo' : (note?.note_type || 'note'); + const color = note?.color || ''; + const items = note?.items || [{ id: _uid(), text: '', done: false }]; + + const form = document.createElement('div'); + form.className = 'note-form'; + form.dataset.noteType = type; + form.dataset.noteColor = color || ''; + if (color && !_isBgImage(color)) form.classList.add('note-color-' + color); + if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color)); + let currentImageUrl = _safeImgSrc(note?.image_url || ''); + form.innerHTML = ` +
+ + + + +
+ ${currentImageUrl && type !== 'draw' ? `
` : ''} +
+ ${type === 'note' + ? `` + : type === 'draw' + ? _buildDrawHtml() + : type === 'goal' + ? _buildGoalHtml(note, items) + : _buildChecklistHtml(items)} +
+
+
+
+ + + +
+ + +
+ ${COLORS.map(c => ``).join('')} +
+ +
+ ${isEdit ? ` + + + ` : ''} + + + +
+
+ `; + + let currentType = type; + let currentColor = color; + // Stash original-form values so round-trips (Note→Todo→Note) restore the + // user's hand-formatted text instead of a join of generated items. Same the + // other way: if you started in todo, switch to note, switch back, items + // come back unchanged. + let _stashedNoteText = (type === 'note') ? (note?.content || '') : null; + let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null; + // Goal mode kept its own pair of stashes (description + steps) so a + // Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in + // the type picker was later removed, so the only entry point now is + // *editing* an existing goal-typed note : but the switch handler still + // accepts Goal→Todo/Note transitions (downgrading legacy goals), so + // these stashes still earn their keep. + let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null; + let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null; + + // Drawing also stashes the saved image URL so it survives Note↔Draw flips. + let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null; + const _refreshFormLayout = () => { + const body = form.closest('.notes-pane-body'); + if (!body) return; + _applyMasonry(body); + requestAnimationFrame(() => { + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + }); + }; + + // Type segmented control : Note | Todo | Draw + form.querySelectorAll('.note-form-type-pill').forEach(pill => { + pill.addEventListener('click', () => { + const newType = pill.dataset.type; + if (newType === currentType) return; + const bodyEl = form.querySelector('.note-form-body'); + // Stash whatever the user has in the current mode before swapping it + // out, so a subsequent flip back restores their work. + if (currentType === 'note') { + _stashedNoteText = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'todo') { + _stashedTodoItems = _collectItems(form); + } else if (currentType === 'goal') { + _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || ''; + _stashedGoalItems = _collectItems(form); + } else if (currentType === 'draw') { + const c = form.querySelector('.note-form-canvas'); + if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} } + } + // Render the new mode's body and re-wire its inputs. + if (newType === 'todo') { + let nextItems; + if (_stashedTodoItems && _stashedTodoItems.length) { + nextItems = _stashedTodoItems; + } else if (_stashedGoalItems && _stashedGoalItems.length) { + // Going Goal→Todo keeps the AI-generated steps as a plain checklist. + nextItems = _stashedGoalItems; + } else if (_stashedNoteText) { + const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean); + nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }]; + } else { + nextItems = [{ id: _uid(), text: '', done: false }]; + } + bodyEl.innerHTML = _buildChecklistHtml(nextItems); + _wireChecklist(bodyEl); + } else if (newType === 'draw') { + bodyEl.innerHTML = _buildDrawHtml(); + // If the user just attached a photo (via the photo button) and then + // toggled to Draw, paint that photo onto the canvas so they can draw + // on top of it. _stashedDrawUrl wins if they were drawing earlier in + // the same edit session. + _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null); + } else { + const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '') + ? _stashedNoteText + : (_stashedGoalDesc && _stashedGoalDesc) + || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n'); + bodyEl.innerHTML = ``; + _wireHashtag(bodyEl.querySelector('.note-form-content')); + } + const focusEl = newType === 'note' + ? bodyEl.querySelector('.note-form-content') + : newType === 'todo' + ? bodyEl.querySelector('.note-cl-text') + : null; + if (focusEl) { + requestAnimationFrame(() => { + focusEl.focus({ preventScroll: true }); + try { + const end = focusEl.value.length; + focusEl.setSelectionRange(end, end); + } catch {} + }); + } + currentType = newType; + const seg = form.querySelector('.note-form-type-seg'); + seg?.classList.toggle('is-todo', newType === 'todo'); + seg?.classList.toggle('is-draw', newType === 'draw'); + form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType)); + // The standalone image preview (form-image-wrap) and the canvas would + // otherwise both show the same image_url when editing a drawn note. + // Hide it in draw mode, restore it when leaving draw mode. + const imgWrap = form.querySelector('.note-form-image-wrap'); + if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : ''; + // The background-color dots set the note card's bg : they make no sense + // for a drawn note (the canvas image IS the card content), so hide them. + const bgPicker = form.querySelector('.note-color-picker'); + if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : ''; + if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64'; + } + _refreshFormLayout(); + }); + }); + + // Slide a finger across the Note/Todo/Draw control to switch modes (mobile). + // On touchmove we find the pill under the finger and click it : reusing the + // pill click handler above, so the body re-renders + content stashing all + // work. Only fires when crossing into a *different* pill. + const _typeSeg = form.querySelector('.note-form-type-seg'); + if (_typeSeg) { + let _sliding = false; + const _activateAt = (x, y) => { + const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill'); + if (pill && !pill.classList.contains('active')) pill.click(); + }; + _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true }); + _typeSeg.addEventListener('touchmove', (e) => { + if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: true }); + _typeSeg.addEventListener('touchend', () => { _sliding = false; }); + _typeSeg.addEventListener('touchcancel', () => { _sliding = false; }); + } + + _wireFormColors(form, value => { currentColor = value; }); + + if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body')); + if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body')); + if (currentType === 'draw') { + _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null); + // Same hides we apply on type-switch : keep them consistent on initial open. + const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none'; + const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none'; + } + + // Auto-grow the plain-note textarea so editing longer notes is + // comfortable : it expands with the content (up to a cap) instead of + // staying a cramped 4-row box. The user can still drag-resize too. + const _contentTa = form.querySelector('.note-form-content'); + if (_contentTa) { + const _grow = () => { + _contentTa.style.height = 'auto'; + // Inline form: cap at ~50vh so a huge note doesn't push the action + // buttons off-screen. Fullscreen mobile overlay: the body scrolls and + // there are no inline buttons crowding, so allow nearly the full height + // : capping at 50vh there clipped longer notes ("part disappears"). + const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay'); + const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5)); + _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px'; + }; + _contentTa.addEventListener('input', _grow); + // Grow on open so existing content is fully visible. Run again after the + // fullscreen overlay's open animation settles : measuring mid-animation + // (the overlay starts scaled/transitioning) can under-size the box. + setTimeout(_grow, 0); + setTimeout(_grow, 360); + } + + _wireReminderPicker(form); + + // Photo upload + const photoBtn = form.querySelector('.note-form-photo-btn'); + const photoInput = form.querySelector('.note-form-photo-input'); + if (photoBtn && photoInput) { + photoBtn.addEventListener('click', () => photoInput.click()); + photoInput.addEventListener('change', async () => { + const file = photoInput.files?.[0]; + if (!file) return; + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + currentImageUrl = api.uploadUrl(fileId); + // Only ever keep the latest attached photo : drop any existing wrap + // before inserting a fresh one. Picking a second photo replaces the + // first instead of stacking. + form.querySelector('.note-form-image-wrap')?.remove(); + const wrap = document.createElement('div'); + wrap.className = 'note-form-image-wrap'; + wrap.innerHTML = ``; + // Insert AFTER the whole header (a flex-row), not after the + // title input itself : otherwise the image lands as a sibling + // of the title inside the header and flex puts them side-by-side. + form.querySelector('.note-form-header').after(wrap); + wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; }); + wrap.querySelector('img').src = currentImageUrl; + } catch (err) { uiModule.showError('Image upload failed'); } + photoInput.value = ''; + }); + } + // Existing image remove + form.querySelector('.note-form-image-rm')?.addEventListener('click', () => { + form.querySelector('.note-form-image-wrap')?.remove(); + currentImageUrl = ''; + }); + + // Title Enter -> focus body (textarea or first checklist item) + form.querySelector('.note-form-title').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); return; } + const firstItem = form.querySelector('.note-cl-text'); + if (firstItem) firstItem.focus(); + } + }); + + // Hashtag → label: typing "#foo " in title/content appends "foo" to the + // space-separated tag list. Repeats are deduplicated, so #foo #foo only + // keeps one. Tags already present in the label field are left alone. + const labelInput = form.querySelector('.note-form-label'); + const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + function _wireHashtag(el) { + if (!el || !labelInput) return; + el.addEventListener('input', () => { + const m = _hashtagRe.exec(el.value); + if (!m) return; + const tag = m[2]; + // Dedup against the stripped form : labelInput may already hold `#tag` + // (after Enter normalised), so includes(tag) on the raw split would + // miss the duplicate and append a bare `tag` next to `#tag`. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = el.value.length - m[0].length + m[1].length; + el.value = el.value.slice(0, cut); + }); + } + _wireHashtag(form.querySelector('.note-form-title')); + _wireHashtag(form.querySelector('.note-form-content')); + // Pressing Enter in the tag field commits the current word as its own tag + // and parks the cursor after a trailing space, so the next word becomes a + // separate tag rather than overwriting the previous one. + labelInput?.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); + e.stopPropagation(); + // Strip any leading #s the user typed, dedupe, then re-prepend exactly + // one #. So typing "foo" or "#foo" both end up as "#foo " in the input; + // the save handler keeps stripping #s before storing so DB stays clean. + const tags = [...new Set(labelInput.value.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (!tags.length) return; + labelInput.value = tags.map(t => '#' + t).join(' ') + ' '; + labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + }); + + // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save + // Escape -> cancel edit + form.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + form.querySelector('.note-form-save')?.click(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + form.querySelector('.note-form-cancel')?.click(); + } + }); + + // Save. Prevent the button from stealing focus on press: on mobile, the + // first tap would otherwise just blur the focused textarea/input (closing + // the keyboard and shifting layout), so the tap never reached the button and + // you had to tap "Done" twice. mousedown preventDefault keeps focus put while + // still letting the click fire. + const _saveBtnEl0 = form.querySelector('.note-form-save'); + _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault()); + _saveBtnEl0.addEventListener('click', async () => { + // Guard against spam-clicks: the drawing save AWAITS a canvas upload before + // the optimistic re-render removes the form, so without this a slow upload + // let repeated clicks create duplicate notes. + const _saveBtn = form.querySelector('.note-form-save'); + if (_saveBtn._saving) return; + // Mobile: when an existing note is opened and closed without edits, the + // Update (✓) button morphs into Archive (set up below). Route the click + // directly through the archive flow. Using a DOM .click() proxy here was + // fragile on mobile because the real archive button can move from the + // footer to the fullscreen header. + if (_saveBtn.classList.contains('archive-mode')) { + _archiveNoteById(note?.id); + return; + } + _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; + try { + form._flushDraft?.(); + await form._stopDraftAutosave?.(); + form._flushDraft?.(); + const title = form.querySelector('.note-form-title').value.trim(); + // Normalize tag input: split on whitespace, strip leading #s, dedupe, + // re-join with single spaces. Empty → null. + const _rawLabel = form.querySelector('.note-form-label')?.value || ''; + const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder'); + const labelVal = _tags.length ? _tags.join(' ') : null; + const payload = { + title, + note_type: currentType, + color: currentColor, + label: labelVal, + due_date: form.querySelector('.note-form-due').value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + image_url: currentImageUrl || null, + }; + if (currentType === 'note') { + payload.content = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'draw') { + // Upload the canvas PNG before saving so image_url points to a + // persistent file. We block the save until upload completes : drawings + // can't be re-rendered later without the URL. + const canvas = form.querySelector('.note-form-canvas'); + const upload = await _uploadCanvasAsPng(canvas, note?.gallery_id || null); + if (!upload?.url) { uiModule.showError('Failed to save drawing'); return; } + payload.image_url = upload.url; + if (upload.galleryId) payload.gallery_id = upload.galleryId; + } else if (currentType === 'goal') { + // Legacy: existing goal-type notes still edit through this branch. + // No AI involvement : save as a normal note with description + items. + payload.content = form.querySelector('.note-form-goal-desc')?.value || ''; + payload.items = _collectItems(form); + } else { + payload.items = _collectItems(form); + } + if (isEdit) payload.id = note.id; + submitNote(payload, note, isEdit); + } finally { + // Re-enable on early returns / errors. On success the form is removed by + // the optimistic re-render, so re-enabling the detached button is a no-op. + _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = ''; + } + }); + + // Mobile-only: when editing an existing note, the Update (✓) button starts in + // archive-mode (visually + behaviorally) and flips to Update on the first + // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever + // touching a separate Archive button. + if (isEdit && window.innerWidth <= 768) { + const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); + const _isFinishedTodo = () => { + if (currentType !== 'todo') return false; + const rows = [...form.querySelectorAll('.note-cl-row')]; + const withText = rows.filter(row => (row.querySelector('.note-cl-text')?.value || '').trim()); + return withText.length > 0 && withText.every(row => row.classList.contains('done')); + }; + const _enterArchive = () => { + _saveBtnEl0.classList.add('archive-mode'); + const label = _isFinishedTodo() ? 'Finish' : 'Archive'; + if (_saveLabelEl) _saveLabelEl.textContent = label; + _saveBtnEl0.title = label; + }; + const _enterUpdate = () => { + if (!_saveBtnEl0.classList.contains('archive-mode')) return; + _saveBtnEl0.classList.remove('archive-mode'); + if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; + _saveBtnEl0.title = 'Update'; + }; + _enterArchive(); + form.addEventListener('input', _enterUpdate, true); + form.addEventListener('change', _enterUpdate, true); + // Checklist dots use a custom control and dispatch their update before + // this click bubbles. Re-evaluate afterward so a fully completed todo + // offers Finish (which archives/removes it from the active list). + form.addEventListener('click', (event) => { + if (!event.target.closest('.note-cl-dot')) return; + setTimeout(() => { + if (_isFinishedTodo()) _enterArchive(); + else _enterUpdate(); + }, 0); + }, true); + } + + // Cancel + form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); app.setEditingId(null); _renderNotes(); }); + + // Archive / Delete : edit-mode-only buttons, mirror the (now-hidden) card actions. + form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => { + if (!isEdit) return; + _archiveNoteById(note.id); + }); + form.querySelector('.note-form-delete-btn')?.addEventListener('click', () => { + if (!isEdit) return; + return deleteFromEditor(note.id); + }); + + // Autosave a draft to persistence on every change so unsaved edits + // survive connection loss / reload / accidental close. + _wireDraftAutosave(form, isEdit ? note.id : '__new__'); + + return form; + } + + return { _buildForm }; +} diff --git a/frontend/src/features/notes/ui/filters.js b/frontend/src/features/notes/ui/filters.js new file mode 100644 index 000000000..49142a260 --- /dev/null +++ b/frontend/src/features/notes/ui/filters.js @@ -0,0 +1,139 @@ +/** Label chips, reminder/goal filters and collapsed tag-strip rendering. */ +import { _visibleNoteTags, _nextGoalStep } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}) { + function _renderLabels(root = document) { + const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar'); + if (!bar) return; + if (state.labelsCollapsed) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + const visibleNotes = _userVisibleNotes(); + const labels = new Set(); + for (const n of visibleNotes) for (const t of _visibleNoteTags(n)) labels.add(t); + const sortedLabels = [...labels].sort(); + // Count active reminders (not archived, has datetime due_date) + const reminderCount = visibleNotes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length; + const pastReminderCount = visibleNotes.filter(n => !n.archived && _isPastReminder(n)).length; + const defaultCount = visibleNotes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length; + // Active goals = non-archived goal notes. Today view lists pending steps + // from each, so we surface the count next to the chip. + const goalCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived).length; + const todayCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length; + const hasUsefulFilters = sortedLabels.length > 0 || reminderCount > 0 || goalCount > 0; + const hasActiveFilter = state.activeLabel !== null || state.activeFilter !== null; + if (!hasUsefulFilters && !hasActiveFilter) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + bar.style.display = ''; + const allActive = state.activeLabel === null && state.activeFilter === null; + let html = ``; + html += ``; + if (todayCount > 0) { + const isOn = state.activeFilter === 'today'; + const icon = ''; + html += ``; + } + if (goalCount > 0) { + const isOn = state.activeFilter === 'goals'; + const icon = ''; + html += ``; + } + const isReminderOn = state.activeFilter === 'reminders'; + const isReminderOff = state.activeFilter === 'no-reminders'; + const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`; + const reminderIcon = isReminderOff + // bell-off icon + ? '' + : ''; + html += ``; + const showingReminders = state.activeFilter === 'reminders'; + if (showingReminders && pastReminderCount > 0) { + html += ``; + } + for (const lbl of sortedLabels) { + html += ``; + } + bar.innerHTML = html; + bar.querySelectorAll('.notes-label-chip').forEach(chip => { + chip.addEventListener('click', () => { + if (chip.dataset.action === 'all') { + app.setActiveLabel(null); + app.setActiveFilter(null); + } else if (chip.dataset.action === 'today') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'today') ? null : 'today'); + } else if (chip.dataset.action === 'goals') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'goals') ? null : 'goals'); + } else if (chip.dataset.action === 'default') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'default') ? null : 'default'); + } else if (chip.dataset.action === 'reminders') { + app.setActiveLabel(null); + // Cycle: null → reminders → null → no-reminders → null → reminders → ... + if (state.activeFilter === null) { + app.setActiveFilter(state.reminderChipNext); + app.setReminderChipNext((state.reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders'); + } else { + app.setActiveFilter(null); + } + } else if (chip.dataset.action === 'clear-past-reminders') { + _clearPastReminders(); + return; + } else { + app.setActiveFilter(null); + app.setActiveLabel(chip.dataset.label || null); + } + _renderNotes(); + }); + }); + } + + function _renderLabelsInto(_body) { + if (!_body) return; + const pane = _body.closest('.notes-pane'); + const toggleHost = pane?.querySelector('.notes-search-bar') || _body; + let toggle = toggleHost.querySelector(':scope > .notes-label-toggle'); + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'notes-label-toggle cal-filter-toggle'; + toggle.addEventListener('click', () => { + app.setLabelsCollapsed(!state.labelsCollapsed); + try { persistence.setItem(NOTES_LABELS_COLLAPSED_KEY, state.labelsCollapsed ? '1' : '0'); } catch {} + _renderNotes(); + }); + const selectButton = toggleHost.querySelector('#notes-select-btn'); + toggleHost.insertBefore(toggle, selectButton || null); + } + toggle.textContent = state.labelsCollapsed ? '+ Tags' : '− Tags'; + toggle.title = state.labelsCollapsed ? 'Show tags' : 'Hide tags'; + toggle.setAttribute('aria-expanded', state.labelsCollapsed ? 'false' : 'true'); + + // The shared chip scroller wraps this row once it is mounted. + let bar = _body.querySelector('.notes-labels-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'notes-labels-bar'; + _body.appendChild(bar); + } + _renderLabels(_body); + } + + return { _renderLabels, _renderLabelsInto }; +} diff --git a/frontend/src/features/notes/ui/layout.js b/frontend/src/features/notes/ui/layout.js new file mode 100644 index 000000000..8935feff5 --- /dev/null +++ b/frontend/src/features/notes/ui/layout.js @@ -0,0 +1,123 @@ +/** Card reflow animation and the owned masonry resize observer. */ +export function createLayout({ isOpen = () => true } = {}) { + function _captureCardPositions() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return null; + const positions = new Map(); + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + if (id) positions.set(id, card.getBoundingClientRect()); + }); + return positions; + } + + function _animateReflow(prevPositions) { + if (!prevPositions || !prevPositions.size) return; + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || !body.isConnected) return; + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const prev = prevPositions.get(id); + if (!prev) return; + const next = card.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; + // Invert: jump back to old position + card.style.transition = 'none'; + card.style.transform = `translate(${dx}px, ${dy}px)`; + // Play: animate to 0 + requestAnimationFrame(() => { + card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)'; + card.style.transform = ''; + card.addEventListener('transitionend', () => { + card.style.transition = ''; + }, { once: true }); + }); + }); + } + + function _applyMasonry(body) { + if (!body || !body.isConnected) return; + const pane = body.closest('.notes-pane'); + const isGrid = pane?.classList.contains('notes-view-grid'); + const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches; + // Tear down any prior observer (defensive : _renderNotes wipes body.innerHTML). + if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; } + // A save that resolves after close re-renders the leaving pane; don't + // re-create the observer that panel teardown already disposed. + if (!isOpen()) return; + if (!isGrid) { + // Clear any leftover inline spans so list view lays out normally. + body.querySelectorAll('.note-card, .notes-label-toggle, .notes-labels-bar, .notes-quick-add, .note-form, .doclib-chip-scroll-frame:has(> .notes-labels-bar)').forEach(c => { c.style.gridRowEnd = ''; }); + return; + } + const ROW_PX = 4; + const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX)); + const recomputeFullRows = () => { + const quickAdd = body.querySelector('.notes-quick-add'); + const labelsToggle = body.querySelector('.notes-label-toggle'); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsToggle && getComputedStyle(labelsToggle).display !== 'none') { + labelsToggle.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsToggle.scrollHeight))}`; + } + if (labelsLayout && getComputedStyle(labelsLayout).display !== 'none') { + const shave = isMobileGrid ? 4 : 0; + labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`; + } + if (quickAdd) { + const shave = isMobileGrid ? 4 : 0; + quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`; + } + body.querySelectorAll('.note-form').forEach(form => { + form.style.gridColumn = '1 / -1'; + const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw'); + const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1; + const renderedHeight = form.getBoundingClientRect?.().height || 0; + const drawReserve = isDrawForm && isMobileGrid ? 12 : 12; + const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve; + form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`; + }); + }; + const recompute = (card) => { + // scrollHeight returns the natural content height : card.getBoundingClientRect() + // would return the grid cell height (collapsed to 4px until the span is set, + // which is the value we're trying to compute). + const h = card.scrollHeight + (isMobileGrid ? 6 : 8); + if (h <= 0) return; + card.style.gridRowEnd = `span ${spanForHeight(h)}`; + }; + recomputeFullRows(); + body.querySelectorAll('.note-card').forEach(recompute); + // Watch masonry participants : content can grow (image load, todo edits, + // quick-add/form expansion), and stale spans are what cause visual merging. + if ('ResizeObserver' in window) { + _masonryObserver = new ResizeObserver(entries => { + let fullRowsChanged = false; + for (const e of entries) { + if (e.target.classList.contains('note-card')) recompute(e.target); + else fullRowsChanged = true; + } + if (fullRowsChanged) recomputeFullRows(); + }); + body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c)); + body.querySelectorAll('.notes-label-toggle, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c)); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsLayout) _masonryObserver.observe(labelsLayout); + } + } + + let _masonryObserver = null; + + function dispose() { + _masonryObserver?.disconnect(); + _masonryObserver = null; + } + return { dispose, _captureCardPositions, _animateReflow, _applyMasonry }; +} diff --git a/frontend/src/features/notes/ui/lifecycle.js b/frontend/src/features/notes/ui/lifecycle.js new file mode 100644 index 000000000..67a8da199 --- /dev/null +++ b/frontend/src/features/notes/ui/lifecycle.js @@ -0,0 +1,36 @@ +/** Notes-owned document/window listeners. Detached editor/menu owners release their resources. */ +export function createNotesLifecycle() { + const owners = new Map(); + let observer = null; + function release(owner) { + const entry = owners.get(owner); + if (!entry) return; + owners.delete(owner); + for (const remove of entry.removers) remove(); + if (!owners.size) { observer?.disconnect(); observer = null; } + } + function onDispose(owner, remove) { + if (!owner) throw new Error('Notes listener needs a lifecycle owner'); + let entry = owners.get(owner); + if (!entry) { + entry = { connected: owner.isConnected, removers: [] }; + owners.set(owner, entry); + } + entry.removers.push(remove); + if (!observer) { + observer = new MutationObserver(() => { + for (const [node, scope] of owners) { + if (node.isConnected) scope.connected = true; + else if (scope.connected) release(node); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + } + function listen(owner, target, event, listener, options) { + onDispose(owner, () => target.removeEventListener(event, listener, options)); + target.addEventListener(event, listener, options); + } + function dispose() { for (const owner of [...owners.keys()]) release(owner); } + return { listen, onDispose, release, dispose }; +} diff --git a/frontend/src/features/notes/ui/mobileEditor.js b/frontend/src/features/notes/ui/mobileEditor.js new file mode 100644 index 000000000..f751ba7aa --- /dev/null +++ b/frontend/src/features/notes/ui/mobileEditor.js @@ -0,0 +1,257 @@ +/** Mobile fullscreen editing, link reader mode and return to the panel. */ +export function createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}) { + function _isNotesMobileMode() { + return ('ontouchstart' in window) && window.innerWidth <= 768; + } + + let _mobileFsOverlay = null; + + let _mobileFsNoteId = null; + + function _openMobileFullscreenEdit(id, fromCard) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + // Tear down any previous overlay (defensive). + _closeMobileFullscreenEdit({ save: false }); + _mobileFsNoteId = id; + app.setEditingId(id); + + const overlay = document.createElement('div'); + overlay.className = 'note-fullscreen-overlay'; + overlay.innerHTML = ` +
+ +
+
+
+ `; + const body = overlay.querySelector('.note-fullscreen-body'); + // Reuse the same edit form the in-place flow builds. Save buttons, + // checklist toggles, etc. all work as-is. Restore any unsaved draft. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + body.appendChild(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + document.body.appendChild(overlay); + _mobileFsOverlay = overlay; + + // Animate up from the tapped tile's position so the transition reads + // as a zoom rather than a hard cut. + if (fromCard) { + const r = fromCard.getBoundingClientRect(); + const vw = window.innerWidth, vh = window.innerHeight; + overlay.style.transformOrigin = + `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`; + } + overlay.classList.add('opening'); + requestAnimationFrame(() => overlay.classList.add('open')); + + // Wire the back button : saves whatever the form has and closes. + // mousedown preventDefault so it doesn't blur the input on first tap (which + // would eat the tap and require a second one). + const _backBtn = overlay.querySelector('.note-fullscreen-back'); + _backBtn.addEventListener('mousedown', (e) => e.preventDefault()); + _backBtn.addEventListener('click', () => { + _closeMobileFullscreenEdit({ save: true }); + }); + + // The form's built-in Cancel only resets the in-place edit state; in + // the overlay context it does nothing visible. Replace its handler so + // Cancel actually dismisses the overlay without saving. + const cancelBtn = form.querySelector('.note-form-cancel'); + if (cancelBtn) { + const fresh = cancelBtn.cloneNode(true); + cancelBtn.parentNode.replaceChild(fresh, cancelBtn); + fresh.addEventListener('mousedown', (e) => e.preventDefault()); + fresh.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _closeMobileFullscreenEdit({ save: false }); + }); + } + // The built-in Save handler does the API call + refresh, but doesn't + // dismiss our overlay. Augment it (do NOT replace : the original is + // async and we'd lose the API call) to schedule a close once the + // save+render has had time to complete. + const saveBtn = form.querySelector('.note-form-save'); + if (saveBtn) { + saveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350); + }); + } + // Make the checklist row drag handle (⋮⋮) actually work on touch. + // The form's default uses HTML5 native draggable which never fires + // on iOS/Android. Wire touch-based reorder for any row inside the + // overlay's checklist. + _wireChecklistTouchReorder(form); + + // For each todo row whose text contains a URL, swap the bare + // for a linkified so URLs are tappable. Tapping anywhere that + // ISN'T a link flips back to the input for editing. + form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode); + + // Move only Delete from the form's footer actions row into the header. + // Archive intentionally stays out of the header: the footer Update button + // already morphs into the mobile Archive action for an untouched note. + const headerActions = overlay.querySelector('.note-fullscreen-actions'); + const archiveBtn = form.querySelector('.note-form-archive-btn'); + const deleteBtn = form.querySelector('.note-form-delete-btn'); + if (headerActions && deleteBtn) { + deleteBtn.classList.remove('note-form-collapsible'); + headerActions.appendChild(deleteBtn); + } + // The built-in archive/delete handlers re-render the notes grid but + // leave THIS overlay sitting in front of it : looks like nothing + // happened. Add follow-up listeners that close the overlay so the + // user sees the action take effect. + if (archiveBtn) { + archiveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200); + }); + } + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + // Delete shows a styled confirm : give it room to resolve before + // we try to dismiss the overlay. + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500); + }); + } + + // Tuck the tags input into the bottom actions row (Cancel / Update), + // pinned to the LEFT. Frees the meta row of an extra wrapping line + // and groups all the "exit" controls together. + const actionsGroup = form.querySelector('.note-form-actions-group'); + const tagsInput = form.querySelector('.note-form-label'); + if (actionsGroup && tagsInput) { + actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild); + } + + // For checklist-type notes, move the photo (attach image) button into + // the same row as the + Add button (right side) : keeps the meta row + // tidy and puts the camera within thumb-reach of the active edit. + const addBtn = form.querySelector('.note-cl-add'); + const photoBtn = form.querySelector('.note-form-photo-btn'); + if (addBtn && photoBtn) { + const addRow = document.createElement('div'); + addRow.className = 'note-cl-add-row'; + addBtn.parentNode.insertBefore(addRow, addBtn); + addRow.appendChild(addBtn); + addRow.appendChild(photoBtn); + // Tapping anywhere on the row (the empty gap, the dashed border, + // the "+ Add" label) triggers add. The photo button keeps its own + // click target so attach-image isn't ambushed. + addRow.addEventListener('click', (e) => { + if (e.target.closest('.note-form-photo-btn')) return; + if (e.target === addBtn || addBtn.contains(e.target)) return; + addBtn.click(); + }); + // The form's delegated "+ Add" handler does + // inputs.insertBefore(newRow, addBtn) + // : but addBtn is no longer a direct child of `.note-checklist-inputs` + // now that we wrapped it. Bind a fresh handler that does the same + // thing but inserts before the WRAPPING row, and stop propagation + // so the broken delegate never runs. + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const inputs = form.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const newRow = document.createElement('div'); + newRow.className = 'note-cl-row'; + newRow.draggable = true; + newRow.dataset.itemId = _uid(); + newRow.dataset.indent = '0'; + newRow.innerHTML = '⋮⋮'; + inputs.insertBefore(newRow, addRow); + _wireRow(newRow, inputs); + // Touch reorder on the freshly-added row's grip. + _wireChecklistTouchReorder(form); + newRow.querySelector('.note-cl-text')?.focus(); + }, { capture: true }); + } + + // Read-mode overlay for plain notes: render the content as a div with + // clickable hyperlinks, layered above the textarea. Tapping anywhere + // in the overlay that ISN'T a link hides the overlay and focuses the + // textarea so the user can start editing. Tapping a link opens it. + const ta = form.querySelector('.note-form-content'); + if (ta && (note.content || '').trim()) { + const reader = document.createElement('div'); + reader.className = 'note-form-content-reader'; + reader.innerHTML = _linkify(note.content || ''); + ta.style.display = 'none'; + ta.insertAdjacentElement('beforebegin', reader); + reader.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let links open normally + reader.remove(); + ta.style.display = ''; + // Let the browser place the cursor naturally : forcing + // setSelectionRange right after focus() raced with the underlying + // tap event and produced inconsistent cursor positions on mobile. + ta.focus({ preventScroll: true }); + }); + } + + // Opening an EXISTING note → read mode, no keyboard pop. Only a + // brand-new note (created via the + button) should auto-focus an + // input field. The user can tap the content to switch to edit. + // (New-note creation flows through _createNote, not this function.) + } + + function _closeMobileFullscreenEdit(opts = {}) { + if (!_mobileFsOverlay) return; + const overlay = _mobileFsOverlay; + overlay.querySelector('.note-form')?._flushDraft?.(); + _mobileFsOverlay = null; + // If the form has a Save button, click it on close so edits aren't lost + // when the user uses the back arrow instead of an explicit Save. + if (opts.save) { + const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]'); + if (saveBtn) try { saveBtn.click(); } catch {} + } + overlay.classList.remove('open'); + overlay.classList.add('closing'); + setTimeout(() => { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + _mobileFsNoteId = null; + app.setEditingId(null); + // Refresh the grid so any save the user made is reflected. + if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); } + }, 220); + } + + function _addRowReadMode(row) { + const txt = row.querySelector('.note-cl-text'); + if (!txt) return; + const val = txt.value || ''; + if (!/(https?:\/\/|www\.)/i.test(val)) return; + if (row.querySelector('.note-cl-text-reader')) return; // already wired + const span = document.createElement('span'); + span.className = 'note-cl-text-reader'; + span.innerHTML = _linkify(val); + txt.style.display = 'none'; + txt.insertAdjacentElement('beforebegin', span); + span.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let the link open + span.remove(); + txt.style.display = ''; + txt.focus({ preventScroll: true }); + }); + } + + return { _isNotesMobileMode, _openMobileFullscreenEdit, _closeMobileFullscreenEdit, _addRowReadMode }; +} diff --git a/frontend/src/features/notes/ui/mobileReorder.js b/frontend/src/features/notes/ui/mobileReorder.js new file mode 100644 index 000000000..b5f6404d3 --- /dev/null +++ b/frontend/src/features/notes/ui/mobileReorder.js @@ -0,0 +1,214 @@ +/** Mobile card long press, placeholder drag and reorder persistence. */ +export function createMobileReorder({ + lifecycle, + _applyMasonry, + _enterSelectMode, + commitMobileOrder, + app, +}) { + function _bindLongPressDrag(card) { + let pressTimer = null; + let startX = 0, startY = 0; + let armed = false; + const CANCEL_PX = 8; + const HOLD_MS = 450; + + card.addEventListener('touchstart', (e) => { + // Don't fight scroll on touchpoints over real interactive children + // (in mobile-mode they're CSS-hidden anyway, but be defensive). + if (e.target.closest('button, input, a, .note-form')) return; + if (e.touches.length !== 1) return; + armed = true; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + pressTimer = setTimeout(() => { + if (!armed) return; + try { navigator.vibrate?.(15); } catch {} + app.setSuppressNextNoteTapId(card.dataset.noteId); + _enterSelectMode(card.dataset.noteId); + }, HOLD_MS); + }, { passive: true }); + card.addEventListener('touchmove', (e) => { + if (!armed) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + } + }, { passive: true }); + const cancel = () => { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + }; + lifecycle.onDispose(card, cancel); + card.addEventListener('touchend', cancel, { passive: true }); + card.addEventListener('touchcancel', cancel, { passive: true }); + } + + let _dragState = null; + + let _docDragHandlersBound = false; + + function _enterDragMode(initialCard, initialTouch) { + document.body.classList.add('notes-drag-mode'); + document.querySelectorAll('.note-card').forEach(_setupDragForCard); + if (!_docDragHandlersBound) { + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchmove', _onDocTouchMove, { passive: false }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchend', _onDocTouchEnd, { passive: true }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchcancel', _onDocTouchEnd, { passive: true }); + _docDragHandlersBound = true; + } + // Auto-grab the card the user long-pressed : they're already holding + // their finger on it, so begin the drag straight away. Releasing + // (touchend) commits the reorder AND exits drag mode in one motion. + if (initialCard && initialTouch) { + _beginGrab(initialCard, initialTouch); + } + } + + function _exitDragMode() { + document.body.classList.remove('notes-drag-mode'); + if (_dragState) { + // Defensive: if exit fires while a drag is in flight, snap the card back. + _onDocTouchEnd(); + } + // Leave _docDragHandlersBound true so re-entering drag mode reuses them. + } + + function _setupDragForCard(card) { + if (card.dataset.dragBound === '1') return; + card.dataset.dragBound = '1'; + card.addEventListener('touchstart', (e) => { + if (!document.body.classList.contains('notes-drag-mode')) return; + if (e.touches.length !== 1) return; + if (_dragState) return; + e.preventDefault(); + e.stopPropagation(); + _beginGrab(card, e.touches[0]); + }, { passive: false }); + } + + function _beginGrab(card, touch) { + const rect = card.getBoundingClientRect(); + const prevStyle = card.getAttribute('style') || ''; + // Placeholder fills the card's old slot so the grid layout doesn't reflow. + const placeholder = document.createElement('div'); + placeholder.className = 'note-card-placeholder'; + placeholder.style.width = rect.width + 'px'; + placeholder.style.height = rect.height + 'px'; + placeholder.style.margin = getComputedStyle(card).margin; + if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd; + const grid = card.parentNode; + grid.insertBefore(placeholder, card); + + // Detach the card visually : fixed-position, anchored to the finger. + card.classList.add('note-card-dragging'); + card.style.position = 'fixed'; + card.style.left = rect.left + 'px'; + card.style.top = rect.top + 'px'; + card.style.width = rect.width + 'px'; + card.style.height = rect.height + 'px'; + card.style.margin = '0'; + card.style.zIndex = '10001'; + // pointer-events:none so elementFromPoint sees the card BENEATH the finger + card.style.pointerEvents = 'none'; + + _dragState = { + card, placeholder, grid, prevStyle, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onDocTouchMove(e) { + if (!_dragState) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const { card, placeholder, grid } = _dragState; + card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px'; + const quickAdd = grid.querySelector('.notes-quick-add'); + const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top; + const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8); + const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY)); + card.style.top = nextTop + 'px'; + + const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY)); + const under = document.elementFromPoint(touch.clientX, hitY); + const target = under && under.closest + ? under.closest('.note-card:not(.note-card-dragging)') + : null; + if (!target || target === card) return; + if (target.parentNode !== grid) return; + + // Move the PLACEHOLDER (not the card) above or below the target depending + // on which half of the target the finger is in. This is the hysteresis + // that stops the oscillation : once the placeholder moves past a card, + // the cursor has to cross THAT card's midpoint in the other direction + // to swap back. + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (touch.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + grid.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + grid.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onDocTouchEnd() { + if (!_dragState) return; + const { card, placeholder, grid, prevStyle } = _dragState; + _dragState = null; + // Animate the card from its current fixed position to where the + // placeholder sits, then re-parent and clear inline styles. Drag + // mode auto-exits once the snap finishes : release = done. + const phRect = placeholder.getBoundingClientRect(); + card.style.transition = 'left 0.2s ease, top 0.2s ease'; + card.style.left = phRect.left + 'px'; + card.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + // Restore the card's pre-drag inline styles. Mobile masonry stores + // grid-row-end inline, and custom backgrounds use inline style too; wiping + // cssText made dropped cards collapse into neighboring notes in grid view. + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + _applyMasonry(grid); + _commitNoteReorder(); + // One drag, one exit : release ends the rearrange session entirely. + if (document.body.classList.contains('notes-drag-mode')) { + document.body.classList.remove('notes-drag-mode'); + } + }, 210); + } + + async function _commitNoteReorder() { + const grid = document.querySelector('#notes-pane .notes-pane-body'); + if (!grid) return; + const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean); + if (!ids.length) return; + await commitMobileOrder(ids); + } + + function dispose() { + if (_dragState) { + const { card, placeholder, prevStyle } = _dragState; + _dragState = null; + placeholder.parentNode?.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + } + document.body.classList.remove('notes-drag-mode'); + _docDragHandlersBound = false; + } + return { dispose, _bindLongPressDrag, _enterDragMode, _exitDragMode, _setupDragForCard, _beginGrab, _onDocTouchMove, _onDocTouchEnd, _commitNoteReorder }; +} diff --git a/frontend/src/features/notes/ui/panel.js b/frontend/src/features/notes/ui/panel.js new file mode 100644 index 000000000..2265897c0 --- /dev/null +++ b/frontend/src/features/notes/ui/panel.js @@ -0,0 +1,506 @@ +/** Panel mounting, keyboard handlers, minimization and note navigation. */ +export function createPanel({ + wireToolbar, + wireSelectionActions, + _wireNotesWindow, + _restoreNotesSidebarDock, + _bringNotesToFront, + disposeUI, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}) { + function _forceCloseNotesPanel() { + app.setOpen(false); + app.setEditingId(null); + try { _commitOpenInPlaceEditor(); } catch {} + try { _closeMobileFullscreenEdit({ save: true }); } catch {} + try { _clearViewedReminderGlows(); } catch {} + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); + document.getElementById('tool-notes-btn')?.classList.remove('active'); + try { Modals.unregister('notes-panel'); } catch {} + try { document.getElementById('notes-pane')?.remove(); } catch {} + try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} + try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} + } + + function _showNotesFirstOpenHint(pane) { + if (!pane || typeof persistence === 'undefined') return; + try { + if (persistence.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; + persistence.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); + } catch { + return; + } + + dismissOrRemove(document.getElementById('notes-first-open-hint')); + const hint = document.createElement('div'); + hint.id = 'notes-first-open-hint'; + hint.className = 'tour-hint'; + hint.innerHTML = ` +
Notes is your basic todo list, and also where reminders are managed.
+ + `; + document.body.appendChild(hint); + + const place = () => { + const r = pane.getBoundingClientRect(); + const hw = hint.offsetWidth || 260; + hint.style.top = Math.max(12, r.top + 58) + 'px'; + hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; + }; + requestAnimationFrame(() => { + place(); + hint.classList.add('tour-hint-in'); + }); + lifecycle.listen(hint, window, 'resize', place); + bindTourHintDismiss(hint, { + timeout: 6500, + fade: 180, + onDismiss: () => window.removeEventListener('resize', place), + }); + } + + function _ensureNotesChipRegistered() { + if (Modals.isRegistered('notes-panel')) return; + Modals.register('notes-panel', { + railBtnId: 'rail-notes', + sidebarBtnId: 'tool-notes-btn', + restoreFn: () => { openPanel(); }, + closeFn: () => { _forceCloseNotesPanel(); }, + }); + } + + function openPanel() { + if (state.open) { + _bringNotesToFront(); + return; + } + // Notes and the document editor occupy the same primary workspace. Preserve + // an open document as a dock chip instead of allowing the two panes to stack + // into a broken layout (or closing the user's document outright). + try { + if (window.documentModule?.isPanelOpen?.()) { + window.documentModule.closePanel('down'); + } + } catch (_) {} + app.setOpen(true); + app.setEditingId(null); + app.setSearchQuery(''); + _clearViewedReminderGlows(); + app.setFiredDotDismissedAt(Date.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + + const container = document.getElementById('chat-container'); + if (!container) return; + + document.body.classList.add('notes-view'); + + // On mobile the notes panel takes the whole screen : auto-close the + // sidebar so the panel isn't cropped underneath it. + if (window.innerWidth <= 768) { + const sb = document.getElementById('sidebar'); + if (sb) sb.classList.add('hidden'); + document.body.classList.add('sidebar-collapsed'); + } + // Mobile mode: tiles become read-only previews (no inline checkbox / + // edit / archive / etc.), tap opens a fullscreen edit overlay, + // long-press enters drag-to-reorder mode. See _bindCardEvents + + // .notes-mobile-mode CSS rules. + if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); + + // Toggle button state + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.add('active'); + + // Create panel + const pane = document.createElement('div'); + pane.id = 'notes-pane'; + pane.className = 'notes-pane'; + pane.innerHTML = ` + +
+

Notes

+ + + + +
+ + +
+ `; + + // On mobile open as a full-screen bottom sheet (slide up), not the desktop + // side panel. Set inline so it wins over the base .notes-pane rule regardless + // of cascade specifics (the CSS @media override wasn't reliably applying, + // which left it as a side panel squeezing the chat). + if (window.innerWidth <= 768) { + pane.style.position = 'fixed'; + pane.style.inset = 'var(--sheet-top) 0 0'; + pane.style.width = '100%'; + pane.style.maxWidth = '100%'; + pane.style.zIndex = '170'; + pane.style.borderRadius = '14px 14px 0 0'; + pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; + pane.style.transformOrigin = 'bottom center'; + } + + // Mount on body so Notes can behave like the other draggable windows. On + // desktop it is immediately docked to the right by _restoreNotesSidebarDock. + const backdrop = document.createElement('div'); + backdrop.className = 'notes-pane-backdrop'; + backdrop.id = 'notes-pane-backdrop'; + backdrop.addEventListener('click', (ev) => { + if (ev.target === backdrop) closePanel('down'); + }); + backdrop.appendChild(pane); + document.body.appendChild(backdrop); + _wireNotesWindow(pane); + _restoreNotesSidebarDock(pane); + _bringNotesToFront(pane); + + // Events + // (Close chevron removed : swipe down on mobile, tool-rail toggle on desktop.) + + // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). + // Mirrors the document sheet gesture : finger-following, velocity-based + // dismiss, rubber-band on up-drag, spring snap-back. + _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); + _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); + + pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); + pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); + + const minBtn = document.getElementById('notes-minimize-btn'); + if (minBtn) minBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closePanel('down'); + }); + wireToolbar(pane); + // Select mode + document.getElementById('notes-select-btn').addEventListener('click', () => { + if (state.selectMode) _exitSelectMode(); else _enterSelectMode(); + }); + // Esc cancels select mode. Notes uses a toggle "Select" button rather + // than a *-bulk-cancel button, so the global Esc-cancel handler in + // keyboard-shortcuts.js can't reach it : handle it here. Capture phase + // + stopPropagation so Esc cancels select instead of closing the panel. + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + } + _notesSelectEscHandler = (e) => { + if (e.key === 'Escape' && state.selectMode) { + e.preventDefault(); + e.stopPropagation(); + _exitSelectMode(); + } + }; + document.addEventListener('keydown', _notesSelectEscHandler, true); + wireSelectionActions(); + // Escape: exit select mode first (if active), otherwise close the panel. + // Skip when the user is editing a form field : those have their own + // ESC-to-cancel handlers and we don't want to nuke the whole panel + // mid-edit. + // Idempotent: remove any previous handler from a prior openPanel so + // re-opening doesn't stack multiple handlers. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + _notesKeydownHandler = (e) => { + if (!state.open) return; + const t = e.target; + const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); + // Ctrl/Cmd+Z anywhere in the panel : undo the last note action. Skip when + // typing in a field so the browser's normal text-undo still works. + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { + if (inField) return; + if (workflows.undoCount() === 0) return; + e.preventDefault(); + _popAndRunUndo(); + return; + } + // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the + // user is editing or has an active text selection (let the browser handle + // a real text copy in that case). + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { + if (inField) return; + const sel = window.getSelection?.(); + if (sel && sel.toString && sel.toString().length > 0) return; + const hovered = document.querySelector('.note-card:hover'); + if (!hovered) return; + const id = hovered.dataset.noteId; + if (!id) return; + e.preventDefault(); + // Flash the ⋯ menu button (copy now lives in that menu). + const btn = hovered.querySelector('.note-card-corner-menu'); + _copyNote(id, btn); + return; + } + if (e.key !== 'Escape') return; + if (inField) return; + if (state.selectMode) { _exitSelectMode(); return; } + if (state.showingArchived) { + // Mirror the archive toggle button: flip back to active notes. + document.getElementById('notes-archive-toggle')?.click(); + return; + } + _forceCloseNotesPanel(); + }; + document.addEventListener('keydown', _notesKeydownHandler); + + // Load : show skeleton immediately, then fetch + _renderLoadingSkeleton(); + // Defer the highlight flush to the next frame so it runs *after* the cards + // are committed to the DOM (and any FLIP animations have settled), giving + // the querySelector lookups inside something to find. + _fetchNotes().then(() => { + if (!pane.isConnected || !state.open) { _startReminderLoop(); return; } + _renderNotes(); + requestAnimationFrame(() => _flushPendingHighlights()); + _startReminderLoop(); + _showNotesFirstOpenHint(pane); + }); + } + + function closePanel(direction) { + if (!state.open) return; + app.setOpen(false); + app.setEditingId(null); + _clearViewedReminderGlows(); + const _minimize = direction === 'down'; + if (_minimize) { + _ensureNotesChipRegistered(); + } else if (Modals.isRegistered('notes-panel')) { + Modals.unregister('notes-panel'); + } + + // Drop panel-owned keydown listeners. The reminder interval is page-owned. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view'); + document.body.classList.remove('notes-mobile-mode'); + document.body.classList.remove('notes-drag-mode'); + // Closing the panel should PRESERVE in-progress edits, not drop them. + // Commit any open in-place editor, and close the mobile fullscreen + // overlay with save=true so the note is persisted. + try { _commitOpenInPlaceEditor(); } catch {} + _closeMobileFullscreenEdit({ save: true }); + // /notes route may have collapsed the wide sidebar to a rail; restore. + try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} + + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.remove('active'); + + const pane = document.getElementById('notes-pane'); + const backdrop = document.getElementById('notes-pane-backdrop'); + if (pane) { + // Scale-out + fade. Match the enter animation duration so close feels + // like the same gesture played backwards. + pane.classList.add('notes-pane-leaving'); + const _cleanup = () => { + try { pane.remove(); } catch {} + try { backdrop?.remove(); } catch {} + }; + pane.addEventListener('animationend', _cleanup, { once: true }); + // Belt-and-braces: if animation is skipped (reduced motion / detached + // tab) the listener won't fire; remove after the expected duration. + setTimeout(_cleanup, 220); + } else if (backdrop) { + backdrop.remove(); + } + // Show the dock chip for a swipe-down minimize (tap it to reopen). + if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } + } + + function togglePanel() { + if (state.open) closePanel(); + else openPanel(); + } + + function isPanelOpen() { return state.open; } + + async function openNote(noteId) { + const wasOpen = !!(isPanelOpen && isPanelOpen()); + app.setShowingArchived(false); + app.setActiveLabel(null); + app.setActiveFilter(null); + app.setSearchQuery(''); + if (!wasOpen) { + openPanel(); + } else { + _bringNotesToFront(); + const searchEl = document.getElementById('notes-search'); + if (searchEl) searchEl.value = ''; + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.remove('notes-pane-archive'); + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) archiveBtn.classList.remove('active'); + await _fetchNotes(); + _renderNotes(); + } + // openPanel() kicks off _fetchNotes() asynchronously, so the cards for + // newly-created notes may not be in the DOM yet. Poll until the card exists. + if (!noteId) return; + let tries = 0; + const findAndFlash = () => { + const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) + || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); + if (card) { + _smoothScrollToNoteCard(card); + card.classList.add('note-card-flash'); + setTimeout(() => card.classList.remove('note-card-flash'), 1600); + return true; + } + return false; + }; + const tryNext = () => { + if (findAndFlash()) return; + if (++tries < 20) setTimeout(tryNext, 200); + }; + setTimeout(tryNext, 120); + } + + let _notesKeydownHandler = null; + + let _notesSelectEscHandler = null; + + function _wireNotesSwipeDismiss(el, pane) { + if (!el || !pane) return; + const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; + let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; + let dragging = false, cancelled = false; + + el.addEventListener('touchstart', (e) => { + if (window.innerWidth > 768 || e.touches.length !== 1) return; + if (e.target.closest('button, input, select, label, textarea')) return; + const t = e.touches[0]; + startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; + velocity = 0; dragging = false; cancelled = false; + }, { passive: true }); + + el.addEventListener('touchmove', (e) => { + if (cancelled || window.innerWidth > 768) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - startX); + const dy = t.clientY - startY; + if (!dragging) { + if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } + if (Math.abs(dy) > 8) { + dragging = true; + pane.style.animation = 'none'; + pane.style.transition = 'none'; + pane.style.willChange = 'transform'; + } else return; + } + const dt = e.timeStamp - lastT; + if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; + lastY = t.clientY; lastT = e.timeStamp; + e.preventDefault(); + pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; + }, { passive: false }); + + const endSwipe = () => { + if (!dragging) return; + dragging = false; + pane.style.willChange = ''; + const dy = lastY - startY; + if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { + // Slide fully off-screen, then minimise. Keep it translated down (don't + // reset) so it doesn't flash back before closePanel removes it. + pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; + pane.style.transform = 'translateY(100%)'; + setTimeout(() => closePanel('down'), 200); + } else { + pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; + pane.style.transform = ''; + setTimeout(() => { pane.style.transition = ''; }, 260); + } + }; + el.addEventListener('touchend', endSwipe, { passive: true }); + el.addEventListener('touchcancel', endSwipe, { passive: true }); + } + + function _smoothScrollToNoteCard(card) { + if (!card) return; + const scroller = card.closest('.notes-pane-body'); + if (!scroller) { + try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2; + scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' }); + } + + function disposeOwnedPortals() { + document.querySelectorAll('.note-reminder-menu, .note-corner-menu-dropdown, #notes-first-open-hint').forEach(dismissOrRemove); + } + + return { _forceCloseNotesPanel, openPanel, closePanel, togglePanel, isPanelOpen, openNote, _smoothScrollToNoteCard }; +} diff --git a/frontend/src/features/notes/ui/quickAdd.js b/frontend/src/features/notes/ui/quickAdd.js new file mode 100644 index 000000000..3a723fcdc --- /dev/null +++ b/frontend/src/features/notes/ui/quickAdd.js @@ -0,0 +1,170 @@ +/** Quick-add expansion, draft restoration and opening inline editors. */ +export function createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}) { + function _renderQuickAdd(body) { + const wrap = document.createElement('div'); + wrap.className = 'notes-quick-add'; + // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw : + // drawing happens in the expanded form). The pill that's active steers + // both the placeholder and the type the form opens in. + wrap.innerHTML = ` +
+ + +
+ + + `; + body.appendChild(wrap); + + const input = wrap.querySelector('.notes-quick-input'); + const seg = wrap.querySelector('.notes-quick-type-seg'); + let currentType = 'todo'; + const setType = (t) => { + if (t !== 'note' && t !== 'todo') return; + currentType = t; + seg.classList.toggle('is-todo', t === 'todo'); + seg.classList.toggle('is-note', t === 'note'); + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + const on = p.dataset.type === t; + p.classList.toggle('active', on); + p.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…'; + }; + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + p.addEventListener('click', (e) => { + e.stopPropagation(); + setType(p.dataset.type); + }); + }); + // Click input or type → expand to full form + const expandToForm = (initialType = 'note', initialText = '') => { + app.setEditingId('__new__'); + const form = _buildForm({ note_type: initialType }); + form.classList.add('note-form-new'); + if (initialText) { + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.value = initialText; + } + const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid') + && window.matchMedia('(max-width: 768px)').matches; + if (mobileGrid) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = 'span 64'; + } + wrap.replaceWith(form); + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) { + titleEl.focus(); + // Move caret to end + titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length); + } + }; + // Expand only on real intent: a click directly on the input, or actual + // typing. Focus alone : including focus stolen from a missed nearby + // click : no longer creates an empty form. + input.addEventListener('click', () => expandToForm(currentType, input.value)); + input.addEventListener('input', () => expandToForm(currentType, input.value)); + wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => { + e.stopPropagation(); + expandToForm(currentType); + // Trigger photo input on the new form + setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50); + }); + } + + function _createNote(type = 'todo') { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || state.editingId === '__new__') return; + app.setEditingId('__new__'); + // Restore an unsaved new-note draft if one survived a prior close/loss. + const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__'); + const form = _buildForm(_n); + form.classList.add('note-form-new'); + body.prepend(form); + const initialField = type === 'note' + ? form.querySelector('.note-form-content') + : form.querySelector('.note-cl-text, .note-form-title'); + initialField?.focus(); + if (restored) uiModule.showToast('Restored unsaved note'); + } + + function newNote() { + if (!state.open) openPanel(); + requestAnimationFrame(() => _createNote('note')); + } + + function _editNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + app.setEditingId(id); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) return; + // Restore an unsaved draft (from a prior connection loss / close) over + // the saved note so the user picks up where they left off. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + card.replaceWith(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + // Pinned notes live in the first masonry column : the edit form has + // column-span:all, which can leave the form rendered above the fold or + // visually buried under neighboring pinned cards. Bring it into view + // (and onto a higher stacking context) so editing a pinned note always + // pops to the top. + form.style.position = 'relative'; + form.style.zIndex = '5'; + // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px) + // after replaceWith, which is much shorter than the actual form. Recompute + // masonry so the form gets the correct row span and cards below stop + // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as + // the user types / adds checklist items. + const _body = form.closest('.notes-pane-body'); + if (_body) { + _applyMasonry(_body); + requestAnimationFrame(() => _applyMasonry(_body)); + } + requestAnimationFrame(() => { + try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); } + catch { form.scrollIntoView(); } + }); + // Pick the most useful field to focus. On phones especially, the user + // taps Edit to type : landing in the title when there's already a title + // (and likely a body to extend) loses momentum. Prefer the body textarea + // for plain notes, the first checklist item for todos, fall back to title. + const _focusBest = () => { + if (note.note_type === 'note' || !note.note_type) { + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; } + } + if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') { + // Last non-empty checklist row, or the first row if all empty. + const rows = form.querySelectorAll('.note-cl-row .note-cl-text'); + let target = null; + for (const inp of rows) { if ((inp.value || '').trim()) target = inp; } + target = target || rows[0]; + if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; } + } + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.focus(); + }; + _focusBest(); + } + + return { _renderQuickAdd, _createNote, newNote, _editNote }; +} diff --git a/frontend/src/features/notes/ui/reminderDisplay.js b/frontend/src/features/notes/ui/reminderDisplay.js new file mode 100644 index 000000000..5f6507d46 --- /dev/null +++ b/frontend/src/features/notes/ui/reminderDisplay.js @@ -0,0 +1,129 @@ +/** Reminder badges, sticky card highlights and entry scrolling. */ +import { _isNoteFullyDone } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}) { + function _clearViewedReminderGlows() { + const active = _loadActiveHighlights(); + if (!active.size) return; + _saveActiveHighlights(new Set()); + document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => { + card.classList.remove('note-card-reminder-fired-sticky'); + }); + } + + function _setReminderCardGlow(noteId, on = true) { + if (!noteId) return; + const active = _loadActiveHighlights(); + if (on) active.add(noteId); + else active.delete(noteId); + _saveActiveHighlights(active); + document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => { + card.classList.toggle('note-card-reminder-fired-sticky', on); + }); + } + + function _queuePendingHighlight(noteId) { + const set = _loadPendingHighlights(); + set.add(noteId); + _savePendingHighlights(set); + } + + function _flushPendingHighlights() { + // Fresh firings (queued by the background loop while the panel was closed) + // glow unconditionally : a notification just told the user something + // happened, so we always point at the note even if it was glowed before. + const queued = _loadPendingHighlights(); + const glowed = _loadGlowedReminders(); + const toGlow = new Set(queued); + // For notes that are merely overdue at open time (no fresh firing event), + // only glow the ones we haven't already shown : otherwise reopening the + // panel keeps lighting up old reminders forever. + for (const n of state.notes) { + if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue; + if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id); + } + // Always consume the queue. + _savePendingHighlights(new Set()); + if (!toGlow.size) return; + let firstCard = null; + for (const id of toGlow) { + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) continue; + _setReminderCardGlow(id, true); + if (!firstCard) firstCard = card; + glowed.add(id); + } + _saveGlowedReminders(glowed); + // Bring the first one into view so it can't get buried below the fold. + if (firstCard) { + requestAnimationFrame(() => { + _smoothScrollToNoteCard(firstCard); + }); + } + } + + function _updateRailBadge() { + const fired = _countFiredReminders(); + // Rail (mini sidebar) : only show the count when reminders have ACTUALLY + // fired since the last dismissal (i.e. you haven't opened notes yet). + // Showing every overdue note forever made the badge feel permanent. + const railBtn = document.getElementById('rail-notes'); + if (railBtn) { + let badge = railBtn.querySelector('.rail-notes-badge'); + if (fired > 0) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'rail-notes-badge'; + railBtn.appendChild(badge); + } + badge.textContent = fired > 99 ? '99+' : String(fired); + badge.classList.add('fired'); + } else if (badge) { + badge.remove(); + } + } + // Main sidebar button + const sidebarBtn = document.getElementById('tool-notes-btn'); + if (sidebarBtn) { + let dot = sidebarBtn.querySelector('.tool-notes-dot'); + if (fired > 0) { + if (!dot) { + dot = document.createElement('span'); + dot.className = 'tool-notes-dot'; + const newNoteBtn = sidebarBtn.querySelector('#notes-new-note-btn'); + sidebarBtn.insertBefore(dot, newNoteBtn || null); + } + } else if (dot) { + dot.remove(); + } + } + // Individual note cards : pulse ones with fired reminders + document.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note || note.archived || _isNoteFullyDone(note)) { + card.classList.remove('note-card-reminder-due'); + return; + } + if (note.due_date && _hasTimeComponent(note.due_date)) { + const t = new Date(note.due_date).getTime(); + card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now()); + } else { + card.classList.remove('note-card-reminder-due'); + } + }); + } + + return { _clearViewedReminderGlows, _setReminderCardGlow, _queuePendingHighlight, _flushPendingHighlights, _updateRailBadge }; +} diff --git a/frontend/src/features/notes/ui/reminderPicker.js b/frontend/src/features/notes/ui/reminderPicker.js new file mode 100644 index 000000000..da9d00398 --- /dev/null +++ b/frontend/src/features/notes/ui/reminderPicker.js @@ -0,0 +1,335 @@ +/** Reminder date presets and recurrence picker interactions. */ +import { _formatRepeatLabel, _toLocalDatetimeStr, _normalizeRepeat, _DAYS, _ORDINALS } from '../domain/recurrence.js'; +export function createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}) { + function _wireReminderPicker(form) { + // Reminder bell : opens dropdown menu + const remindBtn = form.querySelector('.note-form-remind-btn'); + const dueInput = form.querySelector('.note-form-due'); + const repeatInput = form.querySelector('.note-form-repeat'); + const tagsEl = form.querySelector('.note-form-reminder-tags'); + + function _renderReminderTag() { + if (!tagsEl) return; + const v = dueInput.value; + const rep = repeatInput.value || 'none'; + if (!v) { tagsEl.innerHTML = ''; return; } + const label = _formatReminderTag(v); + const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : ''; + tagsEl.innerHTML = ``; + tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => { + if (e.target.classList.contains('note-reminder-tag-x')) { + dueInput.value = ''; + repeatInput.value = 'none'; + _renderReminderTag(); + return; + } + _openReminderMenu(remindBtn || tagsEl, true); + }); + } + + function _openReminderMenu(anchor, isEdit = false) { + // Close any existing menu + document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + document.body.appendChild(menu); + + const presetItems = [ + { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) }, + { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) }, + { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) }, + { label: 'Select date and time', sub: '', action: () => _pickCustomDate() }, + ]; + + // Sub-page state for the repeat picker. null = top page. + // 'weekly' | 'monthly' | 'monthly_nth' + let subMode = null; + // Temporary state for monthly_nth so user can click N then weekday (or vice versa) + // before committing. + let nthDraft = { n: 0, w: -1 }; + + const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + function getNorm() { + if (!dueInput.value) return 'none'; + return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value)); + } + + function commit(val) { + repeatInput.value = val; + _renderReminderTag(); + menu.remove(); + } + + // Like commit, but first snaps dueInput.value forward to the next matching + // slot for the chosen recurrence. Use for weekly/monthly variants where the + // current due date may not match the chosen pattern (e.g. user picks + // "weekly on Mondays" while the date is a Wednesday). + function snapAndCommit(val) { + if (dueInput.value) { + const cur = new Date(dueInput.value); + const norm = _normalizeRepeat(val, cur); + const snapped = _snapToRepeat(cur, norm); + if (snapped) { + dueInput.value = _toLocalDatetimeStr(snapped); + if (remindBtn) remindBtn.classList.add('has-date'); + } + } + commit(val); + } + + function reposition() { + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 220; + const mh = menu.offsetHeight || 280; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + } + + function render() { + let html = ''; + + if (subMode === null) { + html += '
Remind me later
'; + for (let i = 0; i < presetItems.length; i++) { + const it = presetItems[i]; + html += ``; + } + if (isEdit && dueInput.value) { + const norm = getNorm(); + html += '
'; + html += '
Repeat
'; + // None + html += ``; + // Daily + html += ``; + // Weekly → + { + const isW = norm.startsWith('weekly:'); + const wd = isW ? parseInt(norm.split(':')[1], 10) : null; + const sub = isW && !isNaN(wd) ? `${_DAYS[wd]}` : ''; + html += ``; + } + // Monthly → + { + const isM = norm.startsWith('monthly:'); + const sub = isM ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + // Yearly + html += ``; + } + } else if (subMode === 'weekly') { + const norm = getNorm(); + const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1; + html += ``; + html += '
Weekly on…
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + } else if (subMode === 'monthly') { + const norm = getNorm(); + const dueDate = new Date(dueInput.value); + const dayN = dueDate.getDate(); + html += ``; + html += '
Monthly on…
'; + // Day N : uses the chosen date's day. Always offered. + const dayVal = `monthly:day:${dayN}`; + html += ``; + // Nth weekday → + { + const isNth = norm.startsWith('monthly:nth:'); + const sub = isNth ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + } else if (subMode === 'monthly_nth') { + // Pick ordinal (1..4) and weekday (0..6); commit when both chosen. + html += ``; + html += '
Nth weekday of month
'; + html += '
Which one
'; + html += '
'; + for (let i = 1; i <= 4; i++) { + html += ``; + } + html += '
'; + html += '
Weekday
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + html += '
'; + const ready = nthDraft.n > 0 && nthDraft.w >= 0; + const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday'; + html += ``; + } + + menu.innerHTML = html; + reposition(); + wire(); + } + + function wire() { + menu.querySelectorAll('[data-action]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + const a = el.dataset.action; + if (a === 'preset') { + const it = presetItems[parseInt(el.dataset.i, 10)]; + it.action(); + menu.remove(); + } else if (a === 'set') { + snapAndCommit(el.dataset.val); + } else if (a === 'sub') { + subMode = el.dataset.sub; + // Seed nth draft from saved value only on first entry : preserve + // in-progress picks across back-trips (Nth → back → Nth again). + if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) { + const norm = getNorm(); + const m = norm.match(/^monthly:nth:(\d):(\d)$/); + if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) }; + } + render(); + } else if (a === 'back') { + subMode = null; + render(); + } else if (a === 'back-monthly') { + subMode = 'monthly'; + render(); + } else if (a === 'weekly-pick') { + snapAndCommit(`weekly:${el.dataset.wd}`); + } else if (a === 'nth-n') { + nthDraft.n = parseInt(el.dataset.n, 10); + render(); + } else if (a === 'nth-w') { + nthDraft.w = parseInt(el.dataset.wd, 10); + render(); + } else if (a === 'nth-save') { + if (nthDraft.n > 0 && nthDraft.w >= 0) { + snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`); + } + } + }); + }); + } + + render(); + // Click outside to close (single global handler attached after first paint) + setTimeout(() => { + const close = (e) => { + if (!menu.isConnected) { document.removeEventListener('click', close); return; } + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } + }; + if (menu.isConnected) lifecycle.listen(menu, document, 'click', close); + }, 0); + } + + function _monthlyShortDescriptor(norm) { + const parts = norm.split(':'); + if (parts[1] === 'day') return `Day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Last ${_DAYS[wd].slice(0, 3)}`; + } + return ''; + } + + function _setReminder(datetimeLocalStr) { + dueInput.value = datetimeLocalStr; + if (remindBtn) { + remindBtn.classList.add('has-date'); + // Jingle the bell. CSS handles the animation; remove + reflow + re-add + // so it replays every time the user sets/changes a reminder. + const _bell = remindBtn.querySelector('svg'); + if (_bell) { + _bell.classList.remove('jingling'); + void _bell.offsetWidth; + _bell.classList.add('jingling'); + setTimeout(() => _bell.classList.remove('jingling'), 700); + } + } + _renderReminderTag(); + _ensureNotificationPermission(); + } + + function _pickCustomDate() { + // Replace the dropdown menu with a small inline picker + document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); + menu.innerHTML = ` +
Pick date and time
+
+ +
+
+ + `; + document.body.appendChild(menu); + // Position next to the bell button + const anchor = remindBtn || form.querySelector('.note-form-reminder-tags'); + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 240; + const mh = menu.offsetHeight || 200; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + const dInput = menu.querySelector('.note-reminder-date-input'); + dInput.value = initial; + dInput.focus(); + if (typeof dInput.showPicker === 'function') { + try { dInput.showPicker(); } catch {} + } + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { + if (dInput.value) _setReminder(dInput.value); + close(); + }); + } + + if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); + _renderReminderTag(); + + + } + + return { _wireReminderPicker }; +} diff --git a/frontend/src/features/notes/ui/selection.js b/frontend/src/features/notes/ui/selection.js new file mode 100644 index 000000000..e64549dff --- /dev/null +++ b/frontend/src/features/notes/ui/selection.js @@ -0,0 +1,61 @@ +/** Selection controls and view feedback for accepted attachments. */ +export function createSelection({ + operations, + _renderNotes, + app, + state +}) { + function _enterSelectMode(initialId = null) { + app.setSelectMode(true); + app.clearSelection(); + if (initialId) app.selectId(initialId); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + if (bar) bar.classList.remove('hidden'); + if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } + _renderNotes(); + _updateBulkBar(); + } + + function _exitSelectMode() { + app.setSelectMode(false); + app.clearSelection(); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + const all = document.getElementById('notes-select-all'); + if (bar) bar.classList.add('hidden'); + if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } + if (all) all.checked = false; + _renderNotes(); + } + + function _updateBulkBar() { + const count = state.selectedIds.size; + const countEl = document.getElementById('notes-selected-count'); + const sendAgentBtn = document.getElementById('notes-send-agent'); + const archiveBtn = document.getElementById('notes-bulk-archive'); + const deleteBtn = document.getElementById('notes-bulk-delete'); + const allEl = document.getElementById('notes-select-all'); + if (countEl) countEl.textContent = `${count} Selected`; + if (sendAgentBtn) sendAgentBtn.disabled = count === 0; + if (archiveBtn) archiveBtn.disabled = count === 0; + if (deleteBtn) deleteBtn.disabled = count === 0; + if (allEl) allEl.checked = state.notes.length > 0 && state.notes.every(n => state.selectedIds.has(n.id)); + // Toggle select-mode class so todo dots don't react to hover + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.toggle('notes-select-mode', count > 0); + } + + function wireSelectionActions() { + document.getElementById('notes-select-all').addEventListener('change', e => { + if (e.target.checked) state.notes.forEach(n => app.selectId(n.id)); + else app.clearSelection(); + _renderNotes(); _updateBulkBar(); + }); + document.getElementById('notes-send-agent').addEventListener('click', operations.attachSelected); + document.getElementById('notes-bulk-archive').addEventListener('click', operations.archiveSelected); + document.getElementById('notes-bulk-delete').addEventListener('click', operations.deleteSelected); + document.getElementById('notes-bulk-cancel').addEventListener('click', _exitSelectMode); + } + return { wireSelectionActions, _enterSelectMode, _exitSelectMode, _updateBulkBar }; +} diff --git a/frontend/src/features/notes/ui/text.js b/frontend/src/features/notes/ui/text.js new file mode 100644 index 000000000..75ecbaf78 --- /dev/null +++ b/frontend/src/features/notes/ui/text.js @@ -0,0 +1,39 @@ +/** Escaping, safe image sources and link rendering. */ +export function createText({ + uiModule +}) { + function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(//g, '>'); } + + function _attrEsc(s) { + return String(s || '') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + .replace(/`/g, '`'); + } + + function _safeImgSrc(s) { + const v = (s || '').trim(); + if (!v) return ''; + if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v; + if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v; + return ''; + } + + function _linkify(s) { + const escaped = _esc(s); + const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g; + return escaped.replace(urlRe, (m) => { + let url = m; + // Trim a trailing ')' that doesn't have a matching '(' inside the URL + if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { + url = url.slice(0, -1); + } + const href = url.startsWith('www.') ? `https://${url}` : url; + return `${url}` + (url !== m ? m.slice(url.length) : ''); + }); + } + + return { _esc, _attrEsc, _safeImgSrc, _linkify }; +} diff --git a/frontend/src/features/notes/ui/toolbar.js b/frontend/src/features/notes/ui/toolbar.js new file mode 100644 index 000000000..3c3c9fe58 --- /dev/null +++ b/frontend/src/features/notes/ui/toolbar.js @@ -0,0 +1,78 @@ +/** Panel search, archive and list/grid controls. */ +export function createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}) { + function wireToolbar(pane) { + // Search + const searchEl = document.getElementById('notes-search'); + if (searchEl) { + searchEl.addEventListener('input', () => { + app.setSearchQuery(searchEl.value.trim().toLowerCase()); + _renderNotes(); + }); + } + + // View toggle + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) { + const ARCHIVE_ICON = 'Archive'; + const CLOSE_ICON = 'Archive'; + const syncArchiveBtn = () => { + archiveBtn.classList.toggle('active', state.showingArchived); + archiveBtn.title = state.showingArchived ? 'Exit archive' : 'View archive'; + archiveBtn.style.opacity = state.showingArchived ? '1' : '0.8'; + // Swap to an X while in archive view so it doubles as a close-back- + // to-active-notes toggle. + archiveBtn.innerHTML = state.showingArchived ? CLOSE_ICON : ARCHIVE_ICON; + // Tint the whole pane so it's obvious you're not in the active list. + pane.classList.toggle('notes-pane-archive', state.showingArchived); + }; + syncArchiveBtn(); + archiveBtn.addEventListener('click', async () => { + app.setShowingArchived(!state.showingArchived); + app.clearSelection(); + syncArchiveBtn(); + // Brief fade so the body content swap doesn't snap : the bg-tint + // change is already eased by CSS transitions on .notes-pane*. + const _bodyEl = document.querySelector('#notes-pane .notes-pane-body'); + if (_bodyEl) { + _bodyEl.style.transition = 'opacity 0.18s ease'; + _bodyEl.style.opacity = '0.25'; + } + await _fetchNotes(); + _renderNotes(); + if (_bodyEl) { + requestAnimationFrame(() => { + _bodyEl.style.opacity = ''; + _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true }); + }); + } + }); + } + const viewBtn = document.getElementById('notes-view-toggle'); + if (viewBtn) { + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + // Label shows what you'll switch TO : "Grid" while in list, "List" while in grid. + const _setViewLabel = () => { + const lbl = viewBtn.querySelector('.notes-header-btn-label'); + if (lbl) lbl.textContent = state.viewMode === 'grid' ? 'List' : 'Grid'; + }; + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + viewBtn.addEventListener('click', () => { + app.setViewMode(state.viewMode === 'grid' ? 'list' : 'grid'); + try { persistence.setItem('odysseus-notes-view', state.viewMode); } catch {} + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + }); + } + + } + return { wireToolbar }; +} diff --git a/frontend/src/features/notes/ui/windowLayout.js b/frontend/src/features/notes/ui/windowLayout.js new file mode 100644 index 000000000..9e9173a47 --- /dev/null +++ b/frontend/src/features/notes/ui/windowLayout.js @@ -0,0 +1,114 @@ +/** Shared-window integration: safe fullscreen bounds, docking and stacking. */ +export function createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}) { + function _notesFullscreenSafeRect() { + const vw = window.innerWidth || document.documentElement.clientWidth || 0; + const vh = window.innerHeight || document.documentElement.clientHeight || 0; + let left = 0; + let right = vw; + + const sidebar = document.getElementById('sidebar'); + const rail = document.getElementById('icon-rail'); + const hamburgerRight = document.body.classList.contains('hamburger-right') + || sidebar?.classList.contains('right-side') + || rail?.classList.contains('right-side'); + + const reserve = (el) => { + if (!el || getComputedStyle(el).display === 'none') return; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + if (hamburgerRight) right = Math.min(right, rect.left); + else left = Math.max(left, rect.right); + }; + + if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar); + reserve(rail); + + // The fixed hamburger can remain visible even when the rail/sidebar is + // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it. + const hamburger = document.getElementById('hamburger-btn'); + if (hamburger && getComputedStyle(hamburger).display !== 'none') { + const rect = hamburger.getBoundingClientRect(); + const pad = 8; + if (hamburgerRight) right = Math.min(right, rect.left - pad); + else left = Math.max(left, rect.right + pad); + } + + left = Math.max(0, Math.min(left, vw - 80)); + right = Math.max(left + 80, Math.min(right, vw)); + return { left, top: 0, width: right - left, height: vh }; + } + + function _wireNotesWindow(pane) { + if (!pane || pane.dataset.windowDragWired === '1') return; + const header = pane.querySelector('.notes-pane-header'); + if (!header) return; + pane.dataset.windowDragWired = '1'; + makeWindowDraggable(pane, { + content: pane, + header, + fsClass: 'notes-window-fullscreen', + skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber', + enableDock: true, + enableLeftDock: true, + onEnterFullscreen: () => { + pane.classList.add('notes-window-fullscreen'); + snapModalToZone(pane, { + name: 'fullscreen', + rect: _notesFullscreenSafeRect(), + }); + }, + onExitFullscreen: () => { + _restoreNotesSidebarDock(pane); + }, + }); + } + + function _clearNotesSnapStyles(pane) { + if (!pane) return; + const hadLeft = pane.classList.contains('modal-left-docked'); + const hadRight = pane.classList.contains('modal-right-docked'); + pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked'); + if (hadLeft) clearDockSide('left', pane); + if (hadRight) clearDockSide('right', pane); + ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height', + 'max-height', 'margin', 'transform', 'border-radius'] + .forEach((prop) => pane.style.removeProperty(prop)); + delete pane.dataset._tilePreSnap; + delete pane.dataset._tileZone; + delete pane._preDockSnapshot; + delete pane._dockSide; + delete pane._dockSuspended; + } + + function _restoreNotesSidebarDock(pane) { + if (!pane || window.innerWidth <= 768) return; + _clearNotesSnapStyles(pane); + if (!pane.isConnected) return; + applyEdgeDock(pane, 'right'); + } + + function _topToolWindowZ(exclude = null) { + return topToolWindowZ({ exclude }); + } + + function _bringNotesToFront(pane = document.getElementById('notes-pane')) { + if (!pane) return; + const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement; + const z = _topToolWindowZ(backdrop) + 1; + if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important'); + try { + window.dispatchEvent(new CustomEvent('odysseus:modal-opened', { + detail: { id: 'notes-panel', modal: pane }, + })); + } catch (_) {} + } + + + return { _wireNotesWindow, _restoreNotesSidebarDock, _bringNotesToFront }; +} diff --git a/frontend/src/package.json b/frontend/src/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/frontend/src/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/frontend/src/shared/actionMenuOrder.js b/frontend/src/shared/actionMenuOrder.js new file mode 100644 index 000000000..f5cd80b9d --- /dev/null +++ b/frontend/src/shared/actionMenuOrder.js @@ -0,0 +1,29 @@ +/** Stable semantic ordering for item-level action menus. */ +const COMMON_ACTION_ORDER = [ + { rank: 200, pattern: /^(rename|change name)\b/i }, + { rank: 650, pattern: /^select\b/i }, + { rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i }, + { rank: 500, pattern: /^(copy|clone|duplicate)\b/i }, + { rank: 550, pattern: /^(export|download)\b/i }, + { rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i }, + { rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i }, + { rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i }, + { rank: 1000, pattern: /^cancel\b/i }, +]; + +// Canonical Select glyph for item-level menus. Toolbar toggles may use the +// smaller version, but dropdown rows should all use this exact SVG. +export const SELECT_MENU_ICON = ''; + +export function actionMenuRank(item) { + if (Number.isFinite(item?.menuOrder)) return item.menuOrder; + const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim(); + return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100; +} + +export function orderActionMenuItems(items) { + return items + .map((item, index) => ({ item, index, rank: actionMenuRank(item) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(entry => entry.item); +} diff --git a/frontend/src/shared/ui/confetti.js b/frontend/src/shared/ui/confetti.js new file mode 100644 index 000000000..205f9acdc --- /dev/null +++ b/frontend/src/shared/ui/confetti.js @@ -0,0 +1,28 @@ +/** Stateless celebration renderer shared by Notes and Compare. */ +export function spawnConfetti(cx, cy, count) { + const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff']; + for (let i = 0; i < count; i++) { + const el = document.createElement('div'); + el.className = 'confetti-piece'; + const color = colors[Math.floor(Math.random() * colors.length)]; + const size = 5 + Math.random() * 8; + const isCircle = Math.random() > 0.5; + el.style.width = size + 'px'; + el.style.height = (isCircle ? size : size * 0.6) + 'px'; + el.style.background = color; + el.style.borderRadius = isCircle ? '50%' : '2px'; + el.style.left = cx + 'px'; + el.style.top = cy + 'px'; + const angle = Math.random() * Math.PI * 2; + const speed = 60 + Math.random() * 160; + const dx = Math.cos(angle) * speed; + const dy = Math.sin(angle) * speed - 100; + const duration = 1.0 + Math.random() * 1.0; + el.animate([ + { transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 }, + { transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 } + ], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' }); + document.body.appendChild(el); + setTimeout(() => el.remove(), duration * 1000 + 50); + } +} diff --git a/integrations/claude/skills/odysseus/SKILL.md b/integrations/claude/skills/odysseus/SKILL.md index 31b40ee01..c8bd94db3 100644 --- a/integrations/claude/skills/odysseus/SKILL.md +++ b/integrations/claude/skills/odysseus/SKILL.md @@ -100,6 +100,14 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory - `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. - `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. +## Chat history + +- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope. + +```bash +python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID +``` + ## Email draft + send - Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. diff --git a/integrations/claude/skills/odysseus/scripts/odysseus_api.py b/integrations/claude/skills/odysseus/scripts/odysseus_api.py index 8a22eb494..54df59fa1 100755 --- a/integrations/claude/skills/odysseus/scripts/odysseus_api.py +++ b/integrations/claude/skills/odysseus/scripts/odysseus_api.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Small Odysseus scoped API helper for Codex terminal sessions.""" +"""Small Odysseus scoped API helper for Claude Code terminal sessions.""" from __future__ import annotations @@ -44,7 +44,7 @@ def _config() -> tuple[str, str] | None: if not token: missing.append("ODYSSEUS_API_TOKEN") if missing: - print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr) + print(f"missing {', '.join(missing)}; create a Claude Agent token in Odysseus Settings", file=sys.stderr) return None return base_url, token diff --git a/integrations/codex/skills/odysseus/SKILL.md b/integrations/codex/skills/odysseus/SKILL.md index d4cbdf726..a939cac33 100644 --- a/integrations/codex/skills/odysseus/SKILL.md +++ b/integrations/codex/skills/odysseus/SKILL.md @@ -100,6 +100,14 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex - `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`. - `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`. +## Chat history + +- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope. + +```bash +python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID +``` + ## Email draft + send - Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. diff --git a/launch-windows.ps1 b/launch-windows.ps1 index ab0e3542b..31c45a5fb 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -131,7 +131,8 @@ if (-not (Test-Path $venvPy)) { # 3. Install / update dependencies Write-Step "Installing dependencies (first run can take a few minutes)" -& $venvPy -m pip install --upgrade pip --quiet +& $venvPy -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt +if ($LASTEXITCODE -ne 0) { Fail "TLS dependency bootstrap failed." } & $venvPy -m pip install -r requirements.txt if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed. Scroll up for the pip error." } diff --git a/mcp_servers/image_gen_server.py b/mcp_servers/image_gen_server.py index 6b68a27f8..697522e3d 100644 --- a/mcp_servers/image_gen_server.py +++ b/mcp_servers/image_gen_server.py @@ -139,6 +139,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: # Save to gallery try: from src.database import SessionLocal, GalleryImage + owner = str(arguments.get("_odysseus_owner") or "").strip() or None db = SessionLocal() db.add(GalleryImage( id=str(uuid.uuid4()), @@ -147,9 +148,16 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: model=model_id, size=size, quality=payload.get("quality", "medium"), + owner=owner, )) db.commit() db.close() + if owner: + try: + from src.generated_images import register_generated_image + register_generated_image(filename, owner) + except Exception: + pass except Exception: pass diff --git a/package-lock.json b/package-lock.json index 4d0b4b8d6..97fb6b572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "name": "odysseus", "devDependencies": { "@antithesishq/bombadil": "^0.7.0", - "@playwright/test": "^1.62.1" + "@playwright/test": "^1.62.1", + "acorn": "8.19.0" } }, "node_modules/@antithesishq/bombadil": { @@ -36,6 +37,19 @@ "node": ">=20" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/package.json b/package.json index 6f72684c8..aa454e017 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@antithesishq/bombadil": "^0.7.0", - "@playwright/test": "^1.62.1" + "@playwright/test": "^1.62.1", + "acorn": "8.19.0" } } diff --git a/requirements-bootstrap.txt b/requirements-bootstrap.txt new file mode 100644 index 000000000..da1d62220 --- /dev/null +++ b/requirements-bootstrap.txt @@ -0,0 +1,8 @@ +# Integrity-pinned bootstrap for the October 2026 eMudhra CA incident. +# Install with --require-hashes --only-binary=:all: --no-deps before other pip work. +# pip vendors its own certifi; updating the application bundle cannot update it. +# Direct universal wheels avoid index resolution through the old trust store. +pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl \ + --hash=sha256:fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70 +certifi @ https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 diff --git a/requirements.txt b/requirements.txt index 7fabaae93..415b4b503 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,13 +3,16 @@ uvicorn python-multipart python-dotenv httpx +# HTTPS clients using certifi must distrust the compromised eMudhra CA paths. +certifi>=2026.10.23 httpcore>=1.0,<2.0 pydantic>=2.13.4 pydantic-settings>=2.14.1 SQLAlchemy pypdf pypdfium2 -Pillow +# Pillow 13 fixes known image-processing advisories; keep local embeddings compatible. +Pillow>=13.0.0 faster-whisper pdfplumber beautifulsoup4 @@ -21,7 +24,9 @@ numpy # chromadb-client is the lightweight HTTP client (talks to a standalone # ChromaDB service); fastembed runs local ONNX embeddings. chromadb-client -fastembed +# gRPC vendors independent roots; 1.84.1 removes the incident trust anchors. +grpcio>=1.84.1 +fastembed>=0.9.1 youtube-transcript-api yt-dlp # Markdown rendering for research reports (src/visual_report.py). diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 69ca3be56..1042437ac 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -15,6 +15,7 @@ from pathlib import Path from core.atomic_io import atomic_write_json, atomic_write_text from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR +from src.auth_helpers import _auth_disabled from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings from src.settings import ( @@ -129,6 +130,21 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: token = request.cookies.get(SESSION_COOKIE) return auth_manager.get_username_for_token(token) + def _require_admin_user(request: Request) -> Optional[str]: + """Resolve the current user, enforcing admin access. + + Honors AUTH_ENABLED=false the same way core.middleware.require_admin + does: when the operator has explicitly disabled auth (e.g. behind a + reverse proxy that handles login), there is no session user, and + admin-gated app configuration must stay reachable. + """ + user = _get_current_user(request) + if _auth_disabled(): + return user + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + return user + @router.post("/setup") async def first_run_setup(body: SetupRequest, request: Request): """Create initial admin account. Only works if no accounts exist.""" @@ -718,9 +734,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.post("/features") async def set_features(request: Request): """Admin only: update feature toggles.""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() current = _load_features() for key in current: @@ -738,16 +752,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: for keybinds + TTS prefs, so it stays callable without admin.""" user = _get_current_user(request) settings = without_retired_settings(_load_settings()) - if user and auth_manager.is_admin(user): + if _auth_disabled() or (user and auth_manager.is_admin(user)): return settings return scrub_settings(settings) @router.post("/settings") async def set_settings(request: Request): """Admin only: update app settings.""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() current = _load_settings() # Per-key validation for numeric settings: coerce to int and clamp to a @@ -780,9 +792,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: file: UploadFile = File(...), ): """Infer the general prose style from one user-supplied document.""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + user = _require_admin_user(request) filename = Path(file.filename or "sample.txt").name suffix = Path(filename).suffix.lower() allowed = { @@ -861,9 +871,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.get("/integrations") async def list_integrations_route(request: Request): """List all integrations (admin only, keys masked).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) items = load_integrations() # Mask API keys for frontend display safe = [mask_integration_secret(item) for item in items] @@ -877,9 +885,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.post("/integrations") async def create_integration(request: Request): """Create a new integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() item = add_integration(body) return {"ok": True, "integration": mask_integration_secret(item)} @@ -887,9 +893,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.put("/integrations/{integration_id}") async def update_integration_route(integration_id: str, request: Request): """Update an existing integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) body = await request.json() item = update_integration(integration_id, body) if not item: @@ -899,9 +903,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.delete("/integrations/{integration_id}") async def delete_integration_route(integration_id: str, request: Request): """Delete an integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) ok = delete_integration(integration_id) if not ok: raise HTTPException(404, "Integration not found") @@ -910,9 +912,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: @router.post("/integrations/{integration_id}/test") async def test_integration_route(integration_id: str, request: Request): """Test connectivity to an integration (admin only).""" - user = _get_current_user(request) - if not user or not auth_manager.is_admin(user): - raise HTTPException(403, "Admin only") + _require_admin_user(request) integ = get_integration(integration_id) if not integ: raise HTTPException(404, "Integration not found") diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 472128eaf..eb0bfc310 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -551,6 +551,7 @@ async def auto_name_session(session_manager, sess): try: from src.llm_core import llm_call_async from src.task_endpoint import resolve_task_endpoint + from src.utility_effort import effort_for_call # Find first user message first_msg = "" @@ -603,6 +604,7 @@ async def auto_name_session(session_manager, sess): max_tokens=64, headers=t_headers, timeout=15, + reasoning_effort=effort_for_call(t_url, t_model, owner, sess), ) title = title.strip().strip('"\'').strip() @@ -1010,6 +1012,7 @@ async def build_chat_context( chat_processor, message: str, session_id: str, + selected_notes_context: str | None = None, preset_id=None, att_ids: list = None, use_web=None, @@ -1182,6 +1185,23 @@ async def build_chat_context( if search_context and allow_tool_preprocessing and not casual_low_signal: preface.append(untrusted_context_message("prefetched search context", search_context)) + # Inject selected notes as context + if selected_notes_context: + selected_notes_context = ( + "The following selected notes and todos are reference context explicitly chosen " + "by the user. Use them directly when relevant to the user's request. Do not " + "ask for permission to use this context, and do not call note-management " + "tools just to retrieve the same notes; use those tools only for a requested " + "change or when this context is insufficient.\n\n" + + selected_notes_context + ) + preface.append( + untrusted_context_message( + "Selected notes and todos", + selected_notes_context, + ) + ) + # YouTube transcripts for transcript in preprocessed.youtube_transcripts: preface.append(untrusted_context_message("youtube transcript", transcript)) diff --git a/routes/chat_routes.py b/routes/chat_routes.py index e3f7bfa02..5a42d3351 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -58,7 +58,7 @@ from src.auth_helpers import ( from routes.session_routes import _verify_session_owner from routes.document_helpers import _owner_session_filter from core.database import SessionLocal, get_session_mode, set_session_mode -from core.database import Session as DBSession, ChatMessage as DBChatMessage +from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note from core.database import Document as DBDocument, ModelEndpoint from core.log_safety import redact_url from routes.research_routes import _resolve_research_endpoint @@ -2153,6 +2153,68 @@ def _set_user_time_from_request(request: Request) -> None: except Exception: pass +def _parse_selected_note_ids(raw) -> list[str]: + """Validate user-selected Note IDs before passing them to SQLAlchemy.""" + if raw is None or raw == "": + return [] + if not isinstance(raw, str) or len(raw) > 8192: + raise HTTPException(400, "Invalid selected_note_ids payload") + try: + parsed = json.loads(raw) + except (TypeError, ValueError) as exc: + raise HTTPException(400, "Invalid selected_note_ids JSON") from exc + if ( + not isinstance(parsed, list) + or len(parsed) > 50 + or any( + not isinstance(note_id, str) + or not note_id.strip() + or len(note_id) > 128 + for note_id in parsed + ) + ): + raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs") + return list(dict.fromkeys(parsed)) + + +def _format_selected_notes_context(notes) -> str: + """Format selected Notes/Todos as reference context for the model.""" + sections = [] + + for index, note in enumerate(notes, start=1): + lines = [ + f"--- Selected Note {index} ---", + f"Title: {note.title or '(untitled)'}", + f"Type: {note.note_type or 'note'}", + ] + + if note.content: + lines.extend([ + "Content:", + str(note.content), + ]) + + if note.items: + try: + items = json.loads(note.items) + except (TypeError, json.JSONDecodeError): + items = [] + + if isinstance(items, list): + lines.append("Items:") + for item in items: + if not isinstance(item, dict): + continue + + status = "done" if item.get("done") else "pending" + text = str(item.get("text") or "").strip() + + if text: + lines.append(f"- [{status}] {text}") + + sections.append("\n".join(lines)) + + return "\n\n".join(sections) def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager): """Use an explicit request override, then fall back to the active preset.""" @@ -2423,6 +2485,8 @@ def setup_chat_routes( form_data = await request.form() message = form_data.get("message") session = form_data.get("session") + selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids")) + attachments = form_data.get("attachments") use_web = form_data.get("use_web") use_research = form_data.get("use_research") @@ -2730,6 +2794,22 @@ def setup_chat_routes( current_rejected=workspace_rejected, ) owner = effective_user(request) + selected_notes = [] + if selected_note_ids: + notes_db = SessionLocal() + try: + selected_notes = ( + notes_db.query(Note) + .filter( + Note.id.in_(selected_note_ids), + Note.owner == owner, + ) + .all() + ) + finally: + notes_db.close() + selected_notes_context = _format_selected_notes_context(selected_notes) + if tool_approval_id: _reject_delegated_tool_approval(request) from src.agent_runtime.authority import require_user_approval_request @@ -3001,6 +3081,7 @@ def setup_chat_routes( sess, request, chat_handler, chat_processor, message=message, session_id=session, + selected_notes_context=selected_notes_context, preset_id=preset_id, att_ids=att_ids, use_web=use_web, diff --git a/routes/codex_routes.py b/routes/codex_routes.py index f42c6b632..542e44d49 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -35,6 +35,7 @@ CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"} CALENDAR_WRITE_SCOPES = {"calendar:write"} DOCS_READ_SCOPES = {"documents:read", "documents:write"} DOCS_WRITE_SCOPES = {"documents:write"} +CHAT_READ_SCOPES = {"chat"} WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"} @@ -161,6 +162,7 @@ def setup_codex_routes( memory_router: APIRouter | None = None, calendar_router: APIRouter | None = None, document_router: APIRouter | None = None, + history_router: APIRouter | None = None, ) -> APIRouter: router = APIRouter(prefix="/api/codex", tags=["codex"]) email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list") @@ -174,6 +176,7 @@ def setup_codex_routes( documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library") documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}") documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document") + chat_history_endpoint = _find_endpoint(history_router, "GET", "/api/history/{session_id}") @router.get("/capabilities") def capabilities(request: Request): @@ -219,6 +222,11 @@ def setup_codex_routes( "launch": scoped(COOKBOOK_LAUNCH_SCOPES), "actions": ["tasks", "servers", "output", "serve", "stop"], }, + "chat": { + "read": scoped(CHAT_READ_SCOPES), + "actions": ["read"], + "available": chat_history_endpoint is not None, + }, }, "safety": { "email_send_requires_confirmation": True, @@ -226,6 +234,33 @@ def setup_codex_routes( }, } + @router.get("/chat/{session_id}") + async def read_chat( + request: Request, + session_id: str, + offset: int = 0, + limit: int = 100, + ): + _scope_owner(request, CHAT_READ_SCOPES) + if chat_history_endpoint is None: + raise HTTPException(503, "chat history unavailable") + offset, limit = _clamp_pagination( + offset, + limit, + default_limit=100, + max_limit=100, + ) + result = await chat_history_endpoint(request, session_id, limit, offset) + return { + "session_id": session_id, + "name": result["name"], + "model": result["model"], + "messages": result["history"], + "total": result["total"], + "offset": result["offset"], + "limit": result["limit"], + } + @router.get("/plugin.zip") def plugin_zip(request: Request): require_authenticated_request(request) diff --git a/routes/email/email_helpers.py b/routes/email/email_helpers.py index 163132b85..d697b2fe7 100644 --- a/routes/email/email_helpers.py +++ b/routes/email/email_helpers.py @@ -443,9 +443,11 @@ async def _generate_email_summary( headers: dict | None = None, max_tokens: int = 8192, timeout: int = 180, + owner: str | None = None, ) -> str: """Generate an interactive email summary through the shared LLM adapter.""" from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call raw = await llm_call_async( url=url, @@ -456,6 +458,7 @@ async def _generate_email_summary( headers=headers, timeout=timeout, workload="foreground", + reasoning_effort=effort_for_call(url, model, owner), ) return _normalize_email_summary(raw) diff --git a/routes/email/email_pollers.py b/routes/email/email_pollers.py index 9229c0796..ec9d6a8fa 100644 --- a/routes/email/email_pollers.py +++ b/routes/email/email_pollers.py @@ -1031,6 +1031,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None fallback_url=url, fallback_model=model, fallback_headers=headers, owner=account_owner or None, temperature=0.7, max_tokens=1024, timeout=90, + reasoning_effort="", ) reply = _apply_email_style_mechanics(_extract_reply(reply or "")) if reply: diff --git a/routes/email/email_routes.py b/routes/email/email_routes.py index 0b71116d6..b8329eef2 100644 --- a/routes/email/email_routes.py +++ b/routes/email/email_routes.py @@ -25,13 +25,16 @@ import json import re import html import io +import tempfile import zipfile -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse from html.parser import HTMLParser as _HTMLParser import logging import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path +from email.message import EmailMessage +from email import policy as email_policy from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -81,6 +84,18 @@ _SERVER_OWNED_OAUTH_FIELDS = { "oauth_refresh_token", "oauth_token_expiry", } +_CACHE_HOOKS: dict = {"invalidate_list": None} + + +def _check_mail_host(host, owner: str, label: str) -> str | None: + """Reject unsafe or unresolved mail hosts before saving or testing them.""" + from src.url_safety import check_outbound_host + ok, reason = check_outbound_host(host, block_private=_mail_private_blocked(owner)) + if ok: + return None + logger.warning("Rejected %s mail host for owner=%r: %s", label, owner, reason) + # Resolver and exception details belong in server logs, never the response. + return f"Rejected {label} host: server address is not allowed or does not resolve" def _normalized_mail_host(value) -> str: @@ -572,6 +587,23 @@ def _uid_exists(conn, uid: str, *, strict: bool = False) -> bool: return False +def _uid_has_deleted_flag(conn, uid: str) -> bool: + r"""Return True if message already carries \Deleted flag.""" + try: + status, data = conn.uid("FETCH", _uid_bytes(uid), "(FLAGS)") + if status == "OK" and data: + for part in data: + if not part: + continue + meta = part[0] if isinstance(part, tuple) else part + meta_b = meta if isinstance(meta, bytes) else str(meta).encode() + if rb"\Deleted" in meta_b: + return True + except Exception: + pass + return False + + def _resolve_current_email_uid(conn, uid: str, message_id: str | None = None) -> str: """Resolve a stale cached UID by the message's stable RFC Message-ID.""" uid = str(uid or "").strip() @@ -618,10 +650,15 @@ def _message_id_chain(*values: str) -> list[str]: def _uid_from_fetch_meta(meta_b: bytes) -> str: - m = re.search(rb"\bUID\s+(\d+)\b", meta_b) + m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE) return m.group(1).decode() if m else "" +def _size_from_fetch_meta(meta_b: bytes) -> int | None: + m = re.search(rb"\bRFC822\.SIZE\s+(\d+)\b", meta_b, re.IGNORECASE) + return int(m.group(1)) if m else None + + def _parse_list_unsubscribe_header(value: str | None) -> list[dict]: """Parse RFC List-Unsubscribe entries into safe reviewable actions. @@ -1831,6 +1868,12 @@ def setup_email_routes(): delete, move) so the UI doesn't show stale read/unread counts for up to the 8s TTL after a manual flag change. With no args, clears everything. """ + hook = _CACHE_HOOKS.get("invalidate_list") + if hook: + try: + hook(account_id, folder) + except Exception: + pass if account_id is None and folder is None: _LIST_CACHE.clear() return @@ -4161,6 +4204,414 @@ def setup_email_routes(): headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, ) + @router.post("/messages-download-bulk") + async def download_bulk_messages(request: Request, owner: str = Depends(require_owner)): + """Export selected original email messages as .eml files in a ZIP.""" + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + messages = payload.get("messages") if isinstance(payload, dict) else None + if not isinstance(messages, list) or not messages: + raise HTTPException(status_code=400, detail="No emails selected") + if len(messages) > 250: + raise HTTPException(status_code=400, detail="Too many emails selected") + + category = str(payload.get("category") or "selected").strip().lower() + category = re.sub(r"[^a-z0-9_-]+", "-", category).strip("-") or "selected" + selected_dates = sorted({ + str(row.get("date") or "").strip() + for row in messages if isinstance(row, dict) + and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(row.get("date") or "").strip()) + }) + date_span = "" + if selected_dates: + date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}" + export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "") + + # 512 MiB safety cap prevents unbounded memory growth when exporting + # many large messages. + max_total_bytes = 512 * 1024 * 1024 + max_message_bytes = 64 * 1024 * 1024 + spool_threshold_bytes = 32 * 1024 * 1024 + used_names: set[str] = set() + exported = 0 + skipped = 0 + total_bytes = 0 + validated_accounts: set[str] = set() + + def _email_filename(row: dict, uid: str) -> str: + date = str(row.get("date") or "").strip() + date = date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else "" + subject = str(row.get("subject") or "email").strip() + subject = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]+', "_", subject) + subject = re.sub(r"\s+", " ", subject).strip(" .")[:100] or "email" + safe_uid = re.sub(r"[^A-Za-z0-9_-]+", "_", uid).strip("_") or "message" + prefix = f"{date} - " if date else "" + return _safe_attachment_zip_name(f"{prefix}{subject} - {safe_uid}.eml", "email.eml") + + def _fixture_message_bytes(fixture: dict, uid: str, folder: str) -> bytes: + msg = EmailMessage() + sender_name = str(fixture.get("from_name") or "").strip() + sender_address = str(fixture.get("from_address") or "").strip() + sender = email.utils.formataddr((sender_name, sender_address)) if sender_address else sender_name + for key, value in ( + ("From", sender), + ("To", fixture.get("to") or owner), + ("Subject", fixture.get("subject") or "(no subject)"), + ("Message-ID", fixture.get("message_id") or f""), + ): + clean_value = re.sub(r"[\r\n]+", " ", str(value or "")).strip() + if clean_value: + msg[key] = clean_value + raw_date = str(fixture.get("date") or "").strip() + if raw_date: + try: + parsed_date = datetime.fromisoformat(raw_date.replace("Z", "+00:00")) + if parsed_date.tzinfo is None: + parsed_date = parsed_date.replace(tzinfo=timezone.utc) + except Exception: + try: + parsed_date = email.utils.parsedate_to_datetime(raw_date) + except Exception: + parsed_date = None + if parsed_date is not None: + msg["Date"] = email.utils.format_datetime(parsed_date) + msg.set_content(str(fixture.get("body") or "")) + for attachment in fixture.get("attachments") or []: + try: + index = int(attachment.get("index")) + source = _fixture_attachment_source(uid, index, owner, folder) + if not source: + continue + _row, raw_attachment = source + content_type = str(raw_attachment.get("content_type") or "application/octet-stream") + maintype, _, subtype = content_type.partition("/") + if not subtype: + maintype, subtype = "application", "octet-stream" + msg.add_attachment( + str(raw_attachment.get("content") or "").encode("utf-8"), + maintype=maintype, + subtype=subtype, + filename=str(raw_attachment.get("filename") or attachment.get("filename") or f"attachment-{index}"), + ) + except Exception: + logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True) + return msg.as_bytes(policy=email_policy.SMTP) + + # Optimization: single message exports return raw .eml directly without ZIP compression. + if len(messages) == 1: + row = messages[0] + if not isinstance(row, dict): + raise HTTPException(status_code=404, detail="No selected emails could be exported") + uid = str(row.get("uid") or "").strip() + if not uid: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id: + _assert_owns_account(account_id, owner) + + filename = _email_filename(row, uid) + message_bytes: bytes | None = None + + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + message_bytes = _fixture_message_bytes(fixture, uid, folder) + elif re.fullmatch(r"\d+", uid): + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + uid_b = uid.encode() + st_sz, sz_data = None, None + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)") + except Exception as sz_err: + logger.warning("Single message size preflight query error for uid=%s: %s", uid, sz_err) + + if st_sz != "OK": + logger.warning("Single message size preflight status=%s for uid=%s", st_sz, uid) + raise HTTPException(status_code=502, detail="Unable to verify email size safety") + + if not sz_data: + # Message does not exist in mailbox (missing mail) + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + sz: int | None = None + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + if u == uid: + sz = _size_from_fetch_meta(meta) + break + + if sz is None or sz < 0: + logger.warning("Single message size preflight returned invalid/malformed size for uid=%s", uid) + raise HTTPException(status_code=502, detail="Invalid email size metadata from server") + + if sz > max_message_bytes or sz > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + # Preflight size safety established; perform RFC822 fetch. + st, msg_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822)") + if st == "OK" and msg_data: + for meta, raw in _group_uid_fetch_records(msg_data): + if _uid_from_fetch_meta(meta) == uid and raw: + message_bytes = bytes(raw) + break + except HTTPException: + raise + except Exception as exc: + logger.warning("Single email export failed for uid=%s folder=%s: %s", uid, folder, exc) + + if not message_bytes: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + if len(message_bytes) > max_message_bytes or len(message_bytes) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + return StreamingResponse( + io.BytesIO(message_bytes), + media_type="message/rfc822", + headers={ + "Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}", + "X-Exported-Emails": "1", + "X-Skipped-Emails": "0", + }, + ) + + seen_items: set[tuple[str | None, str, str]] = set() + remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {} + spooled_file = tempfile.SpooledTemporaryFile(max_size=spool_threshold_bytes, mode="w+b") + response_started = False + try: + with zipfile.ZipFile(spooled_file, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for row in messages: + if not isinstance(row, dict): + skipped += 1 + continue + uid = str(row.get("uid") or "").strip() + if not uid: + skipped += 1 + continue + folder = str(row.get("folder") or "INBOX").strip() or "INBOX" + account_id = str(row.get("account_id") or row.get("account") or "").strip() or None + if account_id and account_id not in validated_accounts: + _assert_owns_account(account_id, owner) + validated_accounts.add(account_id) + + item_key = (account_id, folder, uid) + if item_key in seen_items: + skipped += 1 + continue + seen_items.add(item_key) + + fixture = _fixture_email_read(uid, folder, owner) + if fixture is not None and not fixture.get("error"): + filename = _email_filename(row, uid) + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = _fixture_message_bytes(fixture, uid, folder) + if len(message_bytes) > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + total_bytes += len(message_bytes) + if total_bytes > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + try: + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + except Exception as zip_err: + logger.error("ZIP entry write failed for fixture: %s", zip_err) + raise HTTPException(status_code=500, detail="Failed to create export archive") + exported += 1 + continue + if not re.fullmatch(r"\d+", uid): + skipped += 1 + continue + group = remote_groups.setdefault((account_id, folder), {}) + group.setdefault(uid, (_email_filename(row, uid), row)) + + preflight_error: HTTPException | None = None + + # Fetch original RFC822 data in batches and store it byte-for-byte. + # Note on IMAP memory-safety: Python's standard imaplib buffers socket responses into + # memory before returning literals to application code. Preflight verification ensures + # we do not intentionally fetch messages exceeding limits, but an untrusted or non-compliant + # IMAP server sending unexpectedly large literals cannot be bounded at the transport level + # without custom socket streaming. Post-fetch checks guard application-level storage. + for (account_id, folder), rows_by_uid in remote_groups.items(): + rows = list(rows_by_uid.items()) + remaining_rows = len(rows) + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + + # Size preflight query to catch oversized exports early and bound batch sizes. + uid_set_all = ",".join(uid for uid, _ in rows).encode() + st_sz, sz_data = None, None + try: + st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)") + except Exception as sz_err: + logger.warning("IMAP size preflight query failed for folder=%s: %s", folder, sz_err) + + if st_sz != "OK": + logger.warning("IMAP size preflight status=%s for folder=%s account=%s", st_sz, folder, account_id) + preflight_error = HTTPException(status_code=502, detail="Unable to verify email size safety") + skipped += remaining_rows + remaining_rows = 0 + continue + + if not sz_data: + # All requested UIDs in this mailbox are missing on server + skipped += remaining_rows + remaining_rows = 0 + continue + + reported_sizes: dict[str, int | None] = {} + for item in sz_data: + meta = item[0] if isinstance(item, tuple) else item + if isinstance(meta, bytes): + u = _uid_from_fetch_meta(meta) + if u: + reported_sizes[u] = _size_from_fetch_meta(meta) + + # Categorize requested UIDs + malformed_uids = [ + uid for uid, _ in rows + if uid in reported_sizes and (reported_sizes[uid] is None or reported_sizes[uid] < 0) + ] + if malformed_uids: + logger.warning("IMAP size preflight returned invalid/malformed size for uids=%s in folder=%s", malformed_uids, folder) + preflight_error = HTTPException(status_code=502, detail="Invalid email size metadata from server") + skipped += remaining_rows + remaining_rows = 0 + continue + + # Identify missing mail (requested UIDs not returned in preflight) + missing_uids = {uid for uid, _ in rows if uid not in reported_sizes} + if missing_uids: + skipped += len(missing_uids) + remaining_rows -= len(missing_uids) + rows = [item for item in rows if item[0] not in missing_uids] + + if not rows: + continue + + size_by_uid: dict[str, int] = { + uid: reported_sizes[uid] for uid, _ in rows + } + + for u, s in size_by_uid.items(): + if s > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + if total_bytes + sum(size_by_uid.values()) > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + + # Form batches with at most 20 messages and capped at 10 MiB payload. + batches: list[list[tuple[str, tuple[str, dict]]]] = [] + cur_batch: list[tuple[str, tuple[str, dict]]] = [] + cur_batch_bytes = 0 + max_batch_bytes = 10 * 1024 * 1024 + for item in rows: + uid_val = item[0] + sz = size_by_uid[uid_val] + if cur_batch and ( + len(cur_batch) >= 20 or + (cur_batch_bytes + sz > max_batch_bytes) + ): + batches.append(cur_batch) + cur_batch = [] + cur_batch_bytes = 0 + cur_batch.append(item) + cur_batch_bytes += sz + if cur_batch: + batches.append(cur_batch) + + for batch in batches: + wanted = {uid: filename for uid, (filename, _row) in batch} + uid_set = ",".join(wanted).encode() + status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)") + if status != "OK" or not msg_data: + skipped += len(batch) + remaining_rows -= len(batch) + continue + found: set[str] = set() + for meta, raw_message in _group_uid_fetch_records(msg_data): + uid = _uid_from_fetch_meta(meta) + if not uid or uid not in wanted or not raw_message: + continue + if uid in found: + # P2: Reject duplicate IMAP response records for an already-processed UID + logger.debug("Ignoring duplicate IMAP FETCH record for uid=%s in folder=%s", uid, folder) + continue + filename = wanted[uid] + unique_name = filename + suffix = 2 + while unique_name.lower() in used_names: + unique_name = f"{Path(filename).stem}-{suffix}.eml" + suffix += 1 + used_names.add(unique_name.lower()) + message_bytes = bytes(raw_message) + if len(message_bytes) > max_message_bytes: + raise HTTPException(status_code=413, detail="Export too large") + total_bytes += len(message_bytes) + if total_bytes > max_total_bytes: + raise HTTPException(status_code=413, detail="Export too large") + try: + zf.writestr(f"{export_folder}/{unique_name}", message_bytes) + except Exception as zip_err: + logger.error("ZIP entry write failed for remote message: %s", zip_err) + raise HTTPException(status_code=500, detail="Failed to create export archive") + found.add(uid) + exported += 1 + remaining_rows -= 1 + missing = len(batch) - len(found) + skipped += len(batch) - len(found) + remaining_rows -= missing + except HTTPException: + raise + except Exception as exc: + skipped += remaining_rows + logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc) + + if preflight_error is not None: + raise preflight_error + + if exported <= 0: + raise HTTPException(status_code=404, detail="No selected emails could be exported") + + spooled_file.seek(0) + zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip") + logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped) + + def _iter_spooled(f, chunk_size=64 * 1024): + try: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk + finally: + f.close() + + response_started = True + return StreamingResponse( + _iter_spooled(spooled_file), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_name}"', + "X-Exported-Emails": str(exported), + "X-Skipped-Emails": str(skipped), + }, + ) + finally: + if not response_started: + spooled_file.close() + @router.get("/inline-image/{uid}") async def inline_image( uid: str, @@ -4764,6 +5215,7 @@ def setup_email_routes(): return {"success": False, "error": "No email UIDs provided", "deleted_uids": [], "failed_uids": []} deleted_uids = [] failed_uids = [] + trash_folder = None try: with _imap(account_id, owner=owner) as conn: select_status, _ = conn.select(_q(folder), readonly=False) @@ -4779,7 +5231,8 @@ def setup_email_routes(): trash_folder = "Trash" except Exception: pass - pending_expunge = False + pending_expunge = [] + preexisting_deleted = set() for uid in uids: try: if not _uid_exists(conn, uid): @@ -4789,32 +5242,75 @@ def setup_email_routes(): if status != "OK": copy_status, _ = conn.uid("COPY", _uid_bytes(uid), _q(trash_folder)) if copy_status != "OK": - # Keep deletion reliable even when this IMAP - # server has no writable Trash folder. - store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") - if store_status != "OK": - failed_uids.append(uid) - continue - pending_expunge = True - else: - store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") - if store_status != "OK": - failed_uids.append(uid) - continue - pending_expunge = True + # Bulk Delete means move to Trash. Never remove + # the original without a recoverable Trash copy. + failed_uids.append(uid) + continue + if _uid_has_deleted_flag(conn, uid): + preexisting_deleted.add(uid) + store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") + if store_status != "OK": + failed_uids.append(uid) + continue + # Keep the local index until the server confirms + # that the copied originals have been removed. + pending_expunge.append(uid) + continue deleted_uids.append(uid) _email_index_delete(owner, account_id, folder, uid) except Exception: failed_uids.append(uid) logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True) if pending_expunge: - conn.expunge() - if deleted_uids: - _invalidate_list_cache(account_id, folder) - return {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids} + expunge_ok = False + # Attempt selective RFC 4315 UID EXPUNGE so unrelated messages + # carrying \Deleted in the selected mailbox are never expunged. + # Mailbox-wide EXPUNGE is strictly avoided to eliminate TOCTOU races + # where concurrent clients mark unrelated messages \Deleted. + try: + uid_set_bytes = _uid_bytes(",".join(pending_expunge)) + uid_exp_status, _ = conn.uid("EXPUNGE", uid_set_bytes) + if uid_exp_status == "OK": + expunge_ok = True + except Exception: + logger.debug("Selective UID EXPUNGE failed", exc_info=True) + expunge_ok = False + + if expunge_ok: + for uid in pending_expunge: + deleted_uids.append(uid) + _email_index_delete(owner, account_id, folder, uid) + else: + # Prioritize preservation over cleanup: unflag \Deleted from our + # copied messages so future EXPUNGE operations do not destroy them. + # Do not remove a pre-existing \Deleted flag present before this operation. + for uid in pending_expunge: + if uid not in preexisting_deleted: + try: + conn.uid("STORE", _uid_bytes(uid), "-FLAGS", "\\Deleted") + except Exception: + pass + failed_uids.extend(pending_expunge) + if not deleted_uids: + return {"success": False, "error": "Failed to delete requested emails", "deleted_uids": [], "failed_uids": failed_uids} + res = {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids} + if failed_uids: + res["error"] = "Mail operation partially failed" + return res except Exception as e: logger.error(f"Failed to bulk delete emails: {e}") - return {"success": False, "error": "Mail operation failed", "deleted_uids": deleted_uids, "failed_uids": [u for u in uids if u not in deleted_uids]} + failed_all = [u for u in uids if u not in deleted_uids] + return { + "success": bool(deleted_uids), + "error": "Mail operation failed" if not deleted_uids else "Mail operation partially failed", + "deleted_uids": deleted_uids, + "failed_uids": failed_all, + } + finally: + if deleted_uids: + _invalidate_list_cache(account_id, folder) + if trash_folder: + _invalidate_list_cache(account_id, trash_folder) @router.delete("/odysseus/reminders") async def delete_odysseus_reminder_emails( @@ -5920,7 +6416,12 @@ def setup_email_routes(): }, ] - style = await llm_call_async(url, model, messages, headers=headers, max_tokens=2048) + from src.utility_effort import effort_for_call + + style = await llm_call_async( + url, model, messages, headers=headers, max_tokens=2048, + reasoning_effort=effort_for_call(url, model, owner), + ) style = _strip_think(style or "") if not style: return {"success": False, "error": "LLM failed to generate style description"} @@ -6004,6 +6505,7 @@ def setup_email_routes(): headers=req_headers, max_tokens=8192, timeout=180, + owner=owner, ) except Exception as e: logger.warning( @@ -6125,6 +6627,8 @@ def setup_email_routes(): if not candidates: return {"success": False, "error": "No LLM endpoint configured"} + from src.utility_effort import candidate_effort_factory + content = await llm_call_async_with_fallback( candidates, messages=[ @@ -6152,6 +6656,7 @@ def setup_email_routes(): temperature=0.2, max_tokens=8192, timeout=180, + candidate_request_factory=candidate_effort_factory(owner), ) model = candidates[0][1] if candidates else "" content = (content or "").strip() @@ -6645,6 +7150,12 @@ def setup_email_routes(): if account_id: _assert_owns_account(account_id, owner) + for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")): + if data.get(field): + host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label) + if host_error: + return {"ok": False, "error": host_error} + # Non-reply automation flags stay global. Away/auto-reply settings are # account-scoped when an account_id is supplied by the Email Settings UI. settings = _load_settings() @@ -6785,6 +7296,11 @@ def setup_email_routes(): smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465) if port_err: return {"ok": False, "error": port_err} + for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")): + if data.get(field): + host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label) + if host_error: + return {"ok": False, "error": host_error} db = SessionLocal() try: _lock_email_account_owner_mutation(db, owner) @@ -6830,6 +7346,11 @@ def setup_email_routes(): """Update an email account. Passwords only overwrite if non-empty.""" # Path param account_id — dep validated via Query, re-check the path-param value. _assert_owns_account(account_id, owner) + for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")): + if data.get(field): + host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label) + if host_error: + return {"ok": False, "error": host_error} from core.database import SessionLocal, EmailAccount db = SessionLocal() try: diff --git a/routes/history/history_routes.py b/routes/history/history_routes.py index 890927c53..a14be5908 100644 --- a/routes/history/history_routes.py +++ b/routes/history/history_routes.py @@ -237,6 +237,18 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int | return None +def _effort_mode(model: str, raw_effort: Any) -> str: + """Session ``thinking_mode`` for a picked effort: ``effort:`` or ``off``. + + Validated like the chat routes do, so llama-swap advertised efforts persist + as well as ChatGPT subscription levels. + """ + from src.chatgpt_subscription import validate_reasoning_effort + + effort = validate_reasoning_effort(model, raw_effort) + return f"effort:{effort}" if effort else "off" + + def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: router = APIRouter( tags=["history"], @@ -391,26 +403,53 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: async def truncate_session(request: Request, session_id: str): _verify_session_owner(request, session_id) try: - body = await request.json() - keep_count = int(body.get("keep_count", 0)) - before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip() + try: + body = await request.json() + except json.JSONDecodeError: + raise HTTPException(400, "Request body must be valid JSON") + if not isinstance(body, dict): + raise HTTPException(400, "Request body must be a JSON object") + # Resolve before_msg_id / message_id alias with explicit presence checks. + # None (JSON null) and empty string are both treated as "not provided here" + # and fall through to the alias, preserving the original OR-chain semantics. + # Any other non-string value (False, 0, True, 123, list, …) is rejected + # immediately so falsy non-strings cannot bypass type validation. + raw_msg_id = body.get("before_msg_id") if "before_msg_id" in body else None + if raw_msg_id is None or raw_msg_id == "": + raw_msg_id = body.get("message_id") + if raw_msg_id is not None and not isinstance(raw_msg_id, str): + raise HTTPException(400, "Message ID must be a string") + before_msg_id = (raw_msg_id or "").strip() + if "keep_count" not in body and not before_msg_id: + raise HTTPException(400, "keep_count or before_msg_id required") + raw_count = body.get("keep_count", 0) + # Keep integer-string clients working, but do not silently convert + # booleans or fractional numbers into destructive message counts. + if type(raw_count) not in (int, str): + raise HTTPException(400, "keep_count must be a non-negative integer") + try: + keep_count = int(raw_count) + except ValueError: + raise HTTPException(400, "keep_count must be a non-negative integer") + if keep_count < 0: + raise HTTPException(400, "keep_count must be a non-negative integer") deleted_sft_pairs: list[dict[str, str]] = [] - if keep_count >= 0: - db = SessionLocal() - try: - all_db_messages = db.query(DbChatMessage).filter( - DbChatMessage.session_id == session_id - ).order_by(DbChatMessage.timestamp).all() - if before_msg_id: - resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id) - if resolved_keep_count is None: - raise HTTPException(404, "Message not found") - keep_count = resolved_keep_count - for pos, row in enumerate(all_db_messages): - row._will_delete_for_sft = pos >= keep_count - deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages) - finally: - db.close() + # keep_count is always >= 0 here; the block is unconditional. + db = SessionLocal() + try: + all_db_messages = db.query(DbChatMessage).filter( + DbChatMessage.session_id == session_id + ).order_by(DbChatMessage.timestamp).all() + if before_msg_id: + resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id) + if resolved_keep_count is None: + raise HTTPException(404, "Message not found") + keep_count = resolved_keep_count + for pos, row in enumerate(all_db_messages): + row._will_delete_for_sft = pos >= keep_count + deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages) + finally: + db.close() result = session_manager.truncate_messages(session_id, keep_count) _remove_deleted_sft_trace_rows( owner=effective_user(request), @@ -1031,26 +1070,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: mode = getattr(session, "thinking_mode", "off") or "off" raw_effort = body.get("reasoning_effort") if raw_effort is not None: - clean_effort = str(raw_effort).strip().lower() - if clean_effort in {"", "default"}: - mode = "off" - else: - from src.chatgpt_subscription import get_chatgpt_model_metadata - meta = get_chatgpt_model_metadata(session.model) - if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: - mode = f"effort:{clean_effort}" - else: - mode = "off" + mode = _effort_mode(session.model, raw_effort) elif "thinking_mode" in body: raw_mode = str(body.get("thinking_mode") or "").strip().lower() if raw_mode.startswith("effort:"): - clean_effort = raw_mode[7:].strip() - from src.chatgpt_subscription import get_chatgpt_model_metadata - meta = get_chatgpt_model_metadata(session.model) - if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]: - mode = f"effort:{clean_effort}" - else: - mode = "off" + mode = _effort_mode(session.model, raw_mode[7:]) elif raw_mode in {"", "on", "off"}: mode = raw_mode from src.model_profiles import supports_user_thinking_toggle @@ -1129,7 +1153,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: ) # Use utility model if available - util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None) + from src.utility_effort import effort_for_call + util_url, util_model, util_headers = resolve_endpoint( + "utility", + fallback_url=getattr(session, "endpoint_url", None), + fallback_model=getattr(session, "model", None), + fallback_headers=getattr(session, "headers", None), + owner=owner or None, + ) compact_url = util_url or session.endpoint_url compact_model = util_model or session.model compact_headers = util_headers if util_url else session.headers @@ -1145,6 +1176,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: ], temperature=0.2, max_tokens=1024, headers=compact_headers, timeout=30, + reasoning_effort=effort_for_call(compact_url, compact_model, owner, session), ) summary = normalize_compaction_summary(summary) diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index 0e2b30276..5d14ba8c3 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -114,6 +114,18 @@ def _mcp_oauth_redirect_uri() -> str: return REDIRECT_URI +def _is_legacy_google_flow(identifier: str) -> bool: + """Determine whether an identifier is the server_id of a configured Google OAuth MCP server.""" + if not identifier: + return False + db = SessionLocal() + try: + srv = db.query(McpServer).filter(McpServer.id == identifier).first() + return bool(srv and srv.oauth_config) + finally: + db.close() + + def setup_mcp_routes(mcp_manager: McpManager): """Setup MCP routes with the provided manager.""" @@ -487,10 +499,14 @@ def setup_mcp_routes(mcp_manager: McpManager): db.close() @router.get("/oauth/callback") - async def oauth_callback(code: str, state: str, request: Request): + async def oauth_callback(code: str = "", state: str = "", request: Request = None): """Handle OAuth callback. Generic MCP OAuth flows resolve via the pending-state registry; Google flows fall through to the legacy path.""" - require_admin(request) + if not state: + return HTMLResponse(_oauth_result_page("Error", "No OAuth state parameter found in the callback URL."), status_code=400) + if not code: + return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400) + from src.mcp_oauth import resolve_pending if resolve_pending(state, code): return HTMLResponse(_oauth_result_page( @@ -498,8 +514,16 @@ def setup_mcp_routes(mcp_manager: McpManager): "The MCP server is connecting. You can close this window and return to Odysseus.", success=True, )) - # Legacy Google path: state is the server_id - return await _exchange_and_connect(state, code, request) + + # Check if this callback belongs to a legacy Google flow vs expired generic flow + if _is_legacy_google_flow(state): + require_admin(request) + return await _exchange_and_connect(state, code, request) + + return HTMLResponse(_oauth_result_page( + "Authorization Expired", + "This authorization round has expired. Press Reconnect in Odysseus and try again.", + ), status_code=400) @router.post("/oauth/exchange/{server_id}") async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)): @@ -514,18 +538,28 @@ def setup_mcp_routes(mcp_manager: McpManager): except Exception: return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400) + state = params.get("state", [None])[0] + if not state: + return HTMLResponse(_oauth_result_page("Error", "No OAuth state parameter found in the callback URL."), status_code=400) + # Generic MCP OAuth: if the pasted URL carries a state we are waiting on, # resolve it directly (the background connect finishes the handshake). - state = params.get("state", [None])[0] from src.mcp_oauth import resolve_pending - if state and resolve_pending(state, code): + if resolve_pending(state, code): return HTMLResponse(_oauth_result_page( "Authorization Successful", "The MCP server is connecting. You can close this window and return to Odysseus.", success=True, )) - return await _exchange_and_connect(server_id, code, request) + # Check if this exchange belongs to a legacy Google flow vs expired generic flow + if _is_legacy_google_flow(server_id): + return await _exchange_and_connect(server_id, code, request) + + return HTMLResponse(_oauth_result_page( + "Authorization Expired", + "This authorization round has expired. Press Reconnect in Odysseus and try again.", + ), status_code=400) async def _exchange_and_connect(server_id: str, code: str, request: Request): """Exchange auth code for tokens and connect the MCP server.""" diff --git a/routes/memory/memory_routes.py b/routes/memory/memory_routes.py index c4232bec4..7e163433d 100644 --- a/routes/memory/memory_routes.py +++ b/routes/memory/memory_routes.py @@ -261,6 +261,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM sess.endpoint_url, sess.model, sess.headers, owner=_owner(request) ) + from src.utility_effort import effort_for_call + try: suggestion_text = await llm_call_async( t_url, @@ -269,6 +271,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM temperature=0.2, max_tokens=500, headers=t_headers, + reasoning_effort=effort_for_call(t_url, t_model, _owner(request), sess), ) try: suggestions = json.loads(suggestion_text) @@ -349,6 +352,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM endpoint_url = None model = None headers = {} + sess = None user = _owner(request) @@ -453,6 +457,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM "Return ONLY valid JSON, no markdown fences." ) + from src.utility_effort import effort_for_call + try: raw = await llm_call_async( endpoint_url, @@ -464,6 +470,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM temperature=0.2, max_tokens=2000, headers=headers, + reasoning_effort=effort_for_call(endpoint_url, model, user, sess), ) # Parse JSON diff --git a/routes/model_routes.py b/routes/model_routes.py index 3466d4b9b..6aec3b9d9 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -47,6 +47,7 @@ from src.endpoint_resolver import ( build_headers, ) from src.auth_helpers import _auth_disabled, effective_user, owner_filter +from src.url_safety import check_outbound_url logger = logging.getLogger(__name__) @@ -2163,6 +2164,19 @@ def setup_model_routes(model_discovery): # keep those container-local when the frontend marks them as such. base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local)) + # SSRF hardening: validate the user-supplied URL before any outbound + # request. Local-first means loopback/LAN endpoints are allowed by + # default; non-HTTP(S) schemes and the cloud metadata range are always + # rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown. + # Note: the outbound fetches rely on httpx's default + # follow_redirects=False — a redirect target is never dereferenced. + ok, reason = check_outbound_url( + base_url, + block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") + is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" # Auto-generate name from URL if not provided if not name.strip(): @@ -2399,6 +2413,16 @@ def setup_model_routes(model_discovery): from src.endpoint_resolver import resolve_url base_url = resolve_url(base_url) base_url = _rewrite_loopback_for_docker(base_url) + # SSRF hardening: validate the user-supplied URL before any outbound + # request. Local-first means loopback/LAN endpoints are allowed by + # default; non-HTTP(S) schemes and the cloud metadata range are always + # rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown. + ok, reason = check_outbound_url( + base_url, + block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") requested_kind = _normalize_endpoint_kind(endpoint_kind) is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" if is_featherless and requested_kind == "auto": @@ -2889,6 +2913,16 @@ def setup_model_routes(model_discovery): _new_base = _new_base[: -len(_suffix)].rstrip("/") _new_base = _normalize_base(_new_base) if _new_base: + # SSRF hardening: PATCH rewrites the stored base_url, so + # it needs the same guard as POST create/test — no + # bypass through the update path. Follow-up fetches + # rely on httpx's default follow_redirects=False. + ok, reason = check_outbound_url( + _new_base, + block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true", + ) + if not ok: + raise HTTPException(400, f"Rejected endpoint URL: {reason}") ep.base_url = _new_base else: ep.is_enabled = not ep.is_enabled diff --git a/routes/session_routes.py b/routes/session_routes.py index fa687ee14..9f6cc3659 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -509,6 +509,7 @@ def setup_session_routes( "has_documents": s.id in doc_session_ids, "has_images": s.id in img_session_ids, "mode": s.mode, + "thinking_mode": s.thinking_mode or "off", "message_count": s.message_count or 0, }) finally: @@ -1042,6 +1043,7 @@ def setup_session_routes( "id": s.id, "name": s.name, "model": s.model, + "thinking_mode": s.thinking_mode or "off", "message_count": s.message_count or 0, "created_at": s.created_at.isoformat() if s.created_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None, @@ -1238,9 +1240,16 @@ def setup_session_routes( from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT from src.endpoint_resolver import resolve_endpoint from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call owner = getattr(session, "owner", None) or effective_user(request) - url, model, headers = resolve_endpoint("utility", owner=owner) + url, model, headers = resolve_endpoint( + "utility", + fallback_url=getattr(session, "endpoint_url", None), + fallback_model=getattr(session, "model", None), + fallback_headers=getattr(session, "headers", None), + owner=owner, + ) if not url or not model: url, model, headers = session.endpoint_url, session.model, session.headers if not url or not model: @@ -1268,6 +1277,7 @@ def setup_session_routes( max_tokens=1024, headers=headers, timeout=60, + reasoning_effort=effort_for_call(url, model, owner, session), ) except Exception as e: logger.error("Manual compaction failed: %s", e) @@ -1311,6 +1321,7 @@ def setup_session_routes( users can clean junk without spending tokens. """ from src.llm_core import llm_call + from src.utility_effort import effort_for_call user = effective_user(request) single_user_mode = not user and _auth_disabled() user_sessions = session_manager.get_sessions_for_user(user) @@ -1483,7 +1494,8 @@ def setup_session_routes( # reasoning model spends tokens thinking first — 4096 truncated the # JSON mid-output, so it never parsed ("invalid JSON for auto-sort"). raw = llm_call(url, model, [{"role": "user", "content": prompt}], - temperature=0.3, max_tokens=16384, headers=headers, timeout=120) + temperature=0.3, max_tokens=16384, headers=headers, timeout=120, + reasoning_effort=effort_for_call(url, model, user)) logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}") # Extract JSON from response — handle markdown fences, leading text, # reasoning-model blocks, and trailing commas. diff --git a/routes/skills_routes.py b/routes/skills_routes.py index 33b55e3c2..f469fc305 100644 --- a/routes/skills_routes.py +++ b/routes/skills_routes.py @@ -8,7 +8,6 @@ The on-disk format is SKILL.md (frontmatter + structured body) under """ import logging -import re from typing import List, Optional import httpx @@ -18,43 +17,25 @@ from pydantic import BaseModel, Field from services.memory.skills import SkillsManager from src.auth_helpers import get_current_user -from src.prompt_security import untrusted_context_message -from src.text_helpers import strip_closed_think_blocks +from services.skills import evaluation as _evaluation +from services.skills import agent_runner as _agent_runner +from services.skills import audit_workflow as _audit_workflow +from services.skills import model_selection as _model_selection +from services.skills import prompts as _prompts +from services.skills import publication as _publication +from services.skills import job_coordinator as _job_coordinator +from services.skills.dependencies import AuditDependencies +from src.utility_effort import effort_for_call from core.middleware import require_admin logger = logging.getLogger(__name__) -# Last-resort verdict extraction from a teacher/verifier model's prose (run when -# JSON parsing fails). `["\'\s:]*` already consumes whitespace, so the original -# trailing `\s*` made two adjacent \s-matching quantifiers that backtrack O(n^2) -# on a `verdict` + whitespace flood in untrusted model output (CodeQL -# py/polynomial-redos). Without it a single unbounded quantifier remains — the -# matched text is identical, and the scan is linear. -_VERDICT_PROSE_RE = re.compile( - r'verdict["\'\s:]*["\']?(pass|needs_work|fail|inconclusive)', re.I -) +_VERDICT_PROSE_RE = _evaluation._VERDICT_PROSE_RE +_verdict_efficiency = _evaluation.verdict_efficiency -def _verdict_efficiency(verdict: Optional[dict]) -> dict: - if not isinstance(verdict, dict): - return {} - out = {"audit_summary": str(verdict.get("summary") or "")[:2000]} - for key in ("saved_turns", "saved_tool_calls"): - if key not in verdict: - continue - try: - out[key] = int(verdict.get(key)) - except (TypeError, ValueError): - pass - baseline = str(verdict.get("baseline_verdict") or "").lower().strip() - if baseline in {"better", "same", "worse", "unknown"}: - out["baseline_verdict"] = baseline - if "usefulness" in verdict: - try: - out["usefulness"] = max(0.0, min(1.0, float(verdict.get("usefulness")))) - except (TypeError, ValueError): - pass - return out +# Compatibility bindings keep existing imports and offline patch targets. +# Implementations receive current collaborators through _audit_dependencies. class SkillAddRequest(BaseModel): @@ -114,54 +95,18 @@ class SkillUpdateRequest(BaseModel): steps: Optional[List[str]] = None -def _skill_test_task(skill: dict) -> str: - """Build the one shared task used by both sides of an audit comparison.""" - if not isinstance(skill, dict): - skill = {} - ctx = (skill.get("when_to_use") or skill.get("description") or skill.get("name") or "").strip() - return ( - "Complete this task end-to-end. Use this exact task context and do not ask " - "the user for more input. If a harmless fixture is needed, create the " - "smallest realistic one that satisfies the context, state it explicitly, " - "then complete and verify the result. Do not perform destructive or " - "externally visible actions. Task context: " + (ctx or "(general)") - ) +_skill_test_task = _prompts._skill_test_task def _skill_baseline_task(skill: dict) -> str: - """Backward-compatible alias; both audit arms must receive identical text.""" + """Compatibility alias using the currently bound shared task builder.""" return _skill_test_task(skill) -def _skill_test_messages(md: str, task: str) -> list[dict]: - """Keep user-editable skill text out of the trusted system role.""" - return [ - { - "role": "system", - "content": ( - "You are TESTING a skill. Follow the supplied reusable procedure " - "to complete the user's task for real, using available tools step " - "by step. If the skill is wrong, unclear, or references tools that " - "do not exist, do your best; the problems will be reviewed afterward." - ), - }, - untrusted_context_message("skill under test", md), - {"role": "user", "content": task}, - ] +_skill_test_messages = _prompts._skill_test_messages -def _skill_baseline_messages(task: str) -> list[dict]: - return [ - { - "role": "system", - "content": ( - "You are completing a baseline audit run. Do not use any saved " - "skill text. Solve the user's task using only your normal tools " - "and reasoning." - ), - }, - {"role": "user", "content": task}, - ] +_skill_baseline_messages = _prompts._skill_baseline_messages async def _eval_skill_run(skill_md: str, task: str, transcript: str, @@ -169,356 +114,39 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str, baseline_transcript: str = "", skill_stats: Optional[dict] = None, baseline_stats: Optional[dict] = None, - workload: str = "foreground") -> dict: - """LLM-as-judge: grade a skill test run from its transcript. Advisory only. - - Robust against local reasoning models (strips , lenient JSON, - generous token budget) — same defensive parsing used elsewhere. - """ - import json as _json - import re as _re - from src.llm_core import llm_call_async - - sys_prompt = ( - "You are a strict QA reviewer judging whether an AI 'skill' (a reusable " - "procedure) actually works. You are given the SKILL, the TASK it was tested " - "on, and the TRANSCRIPT of the agent's run.\n\n" - "Judge honestly:\n" - "- The functional verdict judges ONLY whether the SKILL RUN completed the " - "task correctly and whether the skill procedure is usable. A correct skill " - "run is pass even when the baseline is equally good. Record comparative " - "value separately in baseline_verdict/usefulness.\n" - "- Did following the skill accomplish the task accurately?\n" - "- Are the steps clear, correct, and reproducible?\n" - "- Did it reference tools/commands that don't exist or that errored?\n" - "- Separately, compared with the baseline run WITHOUT the skill, did the skill make " - "the agent more accurate, use fewer turns/tool calls, or avoid avoidable " - "thrashing?\n" - "- Do NOT penalize a skill merely for being broad or generic. A broad " - "skill is useful if it makes the agent finish correctly in fewer turns " - "or with fewer tools than baseline.\n" - "- METADATA: do the frontmatter fields match what the skill actually does? " - "Flag wrong/misleading/missing tags, a wrong category, a when_to_use that " - "doesn't describe the real trigger, or a description that oversells or " - "mismatches the body. List each metadata problem in 'issues' (prefix it " - "with 'metadata:'). Metadata problems alone do NOT make the verdict 'fail' " - "if the procedure works — note them as issues on an otherwise-passing run.\n\n" - "Never use inconclusive merely because the baseline was the same or better. " - "IMPORTANT — fairness rule: if the SKILL RUN could NOT proceed because it lacked " - "an input or target the test never provided (e.g. there was no document/" - "email/data to act on, so the agent reasonably asked for it), that is NOT " - "the skill's fault. Return verdict \"inconclusive\" — do NOT mark it fail " - "or needs_work. Only judge the skill's PROCEDURE; reserve fail/needs_work " - "for when the steps themselves are wrong, vague, or reference missing tools.\n\n" - "If you need to reason, do it inside FIRST. Then output " - "ONLY this JSON (no fences):\n" - 'Set baseline_verdict to how the SKILL RUN compares to the BASELINE RUN.\n\n' - '{"verdict": "pass" | "needs_work" | "fail" | "inconclusive", ' - '"confidence": 0.0-1.0, "summary": "one short sentence", ' - '"issues": ["short issue", ...], ' - '"baseline_verdict": "better" | "same" | "worse" | "unknown", ' - '"usefulness": 0.0-1.0, "saved_turns": integer, "saved_tool_calls": integer}' + workload: str = "foreground", owner: Optional[str] = None) -> dict: + return await _evaluation.evaluate_run( + skill_md, task, transcript, url, model, headers, baseline_transcript, + skill_stats, baseline_stats, workload, owner, _effort_for_call=effort_for_call, ) - # Give the judge plenty of transcript, and when it must trim, keep the TAIL - # (the final result lives at the end) plus a bit of the head — truncating to - # a short prefix made the judge wrongly call complete runs "incomplete / - # missing sections" because it never saw the end. - def _clip(t: str, limit: int = 24000) -> str: - t = (t or "").strip() or "(no output produced)" - if len(t) <= limit: - return t - head = limit // 4 - return t[:head] + "\n\n…[transcript trimmed for length]…\n\n" + t[-(limit - head):] - skill_stats = skill_stats or {} - baseline_stats = baseline_stats or {} - user_msg = ( - f"=== SKILL ===\n{(skill_md or '')[:4000]}\n\n" - f"=== TASK ===\n{task}\n\n" - f"=== SKILL RUN STATS ===\n" - f"turns={skill_stats.get('turns', 'unknown')} " - f"tool_calls={skill_stats.get('tool_calls', 'unknown')}\n\n" - f"=== SKILL RUN TRANSCRIPT ===\n{_clip(transcript)}\n\n" - f"=== BASELINE RUN WITHOUT SKILL STATS ===\n" - f"turns={baseline_stats.get('turns', 'unknown')} " - f"tool_calls={baseline_stats.get('tool_calls', 'unknown')}\n\n" - f"=== BASELINE RUN WITHOUT SKILL TRANSCRIPT ===\n{_clip(baseline_transcript)}" - ) - _VERDICTS = ("pass", "needs_work", "fail", "inconclusive") - - def _parse(raw: str): - """Return a final result dict on success, or None if unparseable.""" - text = (raw or '') - # Strip closed think blocks. If a was opened but never closed - # (the model ran out of budget mid-reasoning), drop everything from it - # onward so its stray braces don't poison JSON extraction. - text = strip_closed_think_blocks(text) - text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I).strip() - - def _coerce(d): - return d if (isinstance(d, dict) and "verdict" in d) else None - - data = None - # Scan every balanced {...} candidate and keep the LAST one that parses - # and carries a "verdict" — the transcript is full of JSON API bodies, - # so a naive first-brace/last-brace span almost never parses. - for m in _re.finditer(r'\{[\s\S]*?\}', text): - frag = m.group(0) - for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): - try: - d = _coerce(_json.loads(cand)) - except Exception: - d = None - if d is not None: - data = d - # Fallback to the greedy outermost span (handles nested objects the - # non-greedy scan above splits apart). - if data is None: - a, b = text.find('{'), text.rfind('}') - if a >= 0 and b > a: - frag = text[a:b + 1] - for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): - try: - d = _coerce(_json.loads(cand)) - except Exception: - d = None - if d is not None: - data = d - break - - v = str(data.get("verdict", "")).lower().strip() if isinstance(data, dict) else None - # Last resort: pull the verdict keyword straight out of the prose so a - # clearly-decided run isn't thrown away as "unparseable". - if v not in _VERDICTS: - km = _VERDICT_PROSE_RE.search(text) - if km: - v = km.group(1).lower() - if data is None: - data = {} - if not isinstance(data, dict) or v not in _VERDICTS: - return None - try: - conf = float(data.get("confidence", 0)) - except (TypeError, ValueError): - conf = 0 - try: - usefulness = float(data.get("usefulness", 0.0)) - except (TypeError, ValueError): - usefulness = 0.0 - try: - saved_turns = int(data.get("saved_turns", 0)) - except (TypeError, ValueError): - saved_turns = 0 - try: - saved_tool_calls = int(data.get("saved_tool_calls", 0)) - except (TypeError, ValueError): - saved_tool_calls = 0 - return { - "verdict": v, - "confidence": max(0.0, min(1.0, conf)), - "summary": str(data.get("summary", ""))[:400], - "issues": [str(x)[:200] for x in (data.get("issues") or []) if str(x).strip()][:8], - "baseline_verdict": ( - str(data.get("baseline_verdict", "unknown")).lower().strip() - if str(data.get("baseline_verdict", "unknown")).lower().strip() in {"better", "same", "worse", "unknown"} - else "unknown" - ), - "usefulness": max(0.0, min(1.0, usefulness)), - "saved_turns": saved_turns, - "saved_tool_calls": saved_tool_calls, - } - - # Two attempts: the first lets the judge reason; if a heavy reasoning model - # burns its budget inside and never emits the JSON, the second - # forbids thinking and demands the JSON immediately. - last_text = "" - last_err = None - for attempt in range(2): - msgs = [{"role": "system", "content": sys_prompt}, - {"role": "user", "content": user_msg}] - if attempt == 1: - msgs[0]["content"] = ( - sys_prompt + "\n\nDO NOT use or any reasoning. Your reply " - "must START with '{' and be ONLY the JSON object, nothing else." - ) - try: - raw = await llm_call_async( - # Generous budget so a heavy reasoner can think AND still have - # room to emit the JSON afterwards (reasoning tokens come out of - # this same cap; the server clamps to its own max). - url, model, msgs, - temperature=0.1, max_tokens=32768, headers=headers, timeout=180, - workload=workload, - ) - except Exception as e: - # Don't give up on a transient first-attempt error — let the second - # (no-think) attempt run before reporting failure. - last_err = e - continue - last_text = (raw or '') - parsed = _parse(raw) - if parsed is not None: - return parsed - - if last_err is not None and not last_text: - return {"verdict": "unknown", "confidence": 0, "summary": f"Review call failed: {last_err}", "issues": []} - return {"verdict": "unknown", "confidence": 0, - "summary": "Reviewer returned unparseable output.", "issues": [], "raw": last_text[:300]} async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: str, headers: Optional[dict], - workload: str = "foreground") -> Optional[dict]: - """Advisory judge: is this skill worth keeping, or is it redundant / trivially - unnecessary? Sees the OTHER skills' names+descriptions so it can spot - duplicates. Returns {necessary, redundant_with, reason} or None. Never acts — - purely a flag the UI surfaces.""" - import json as _json - import re as _re - from src.llm_core import llm_call_async - - catalog = "\n".join(f"- {o.get('name')}: {o.get('description', '')}" for o in others) or "(no other skills)" - sys_prompt = ( - "You assess whether a reusable AI 'skill' (a saved procedure) is worth keeping. " - "A skill is UNNECESSARY if it essentially duplicates another skill in the library. " - "Do NOT call a skill unnecessary merely because it is broad or generic; broad " - "skills can be worth keeping when they make a capable assistant finish accurately " - "with fewer turns or fewer tool calls than it would without the skill. A skill IS " - "necessary if it captures a reusable trigger, procedure, tool sequence, or " - "hard-won detail that could improve future runs.\n\n" - "Be conservative: only call it unnecessary when you're confident. Reason in " - " first if needed, then output ONLY this JSON:\n" - '{"necessary": true|false, "redundant_with": ["skill-name", ...], ' - '"reason": "one short sentence"}' + workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]: + return await _evaluation.evaluate_necessity( + skill_md, others, url, model, headers, workload, owner, + _effort_for_call=effort_for_call, ) - user_msg = ( - f"=== SKILL UNDER REVIEW ===\n{(skill_md or '')[:3000]}\n\n" - f"=== OTHER SKILLS IN THE LIBRARY ===\n{catalog[:4000]}" - ) - try: - raw = await llm_call_async( - url, model, - [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}], - temperature=0.1, max_tokens=8192, headers=headers, timeout=120, - workload=workload, - ) - except Exception as e: - logger.warning(f"Necessity check failed: {e}") - return None - text = strip_closed_think_blocks(raw or '') - text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I).strip() - data = None - a, b = text.find('{'), text.rfind('}') - if a >= 0 and b > a: - frag = text[a:b + 1] - for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): - try: - data = _json.loads(cand) - break - except Exception: - continue - if not isinstance(data, dict) or "necessary" not in data: - return None - return { - "necessary": bool(data.get("necessary", True)), - "redundant_with": [str(x)[:80] for x in (data.get("redundant_with") or []) if str(x).strip()][:6], - "reason": str(data.get("reason", ""))[:200], - } -def _should_check_retrieval_precision(skill: dict) -> bool: - """Cheap prefilter for the expensive retrieval-precision judge. - - Skills with broad tags like "network" or "document" are the ones most likely - to over-inject. Narrow command/vendor tags alone are fine. - """ - broad = { - "arch", "arch linux", "linux", "network", "networking", "wifi", - "installation", "install", "system", "ssh", "document", "documents", - "search", "email", "calendar", "gpu", "server", "python", - } - if not isinstance(skill, dict): - return False - tags = {str(t or "").strip().lower() for t in (skill.get("tags") or [])} - if tags & broad: - return True - text = " ".join([ - str(skill.get("name") or ""), - str(skill.get("description") or ""), - str(skill.get("when_to_use") or ""), - ]).lower() - return sum(1 for t in broad if t in text) >= 2 +_should_check_retrieval_precision = _evaluation.should_check_retrieval_precision async def _eval_skill_retrieval_precision(skill_md: str, others: list, url: str, model: str, headers: Optional[dict], - workload: str = "foreground") -> Optional[dict]: - """Advisory judge: would this skill's metadata make retrieval over-select it? - - This is distinct from "does the procedure work?". It asks whether tags, - description, and when_to_use are specific enough that the skill won't be - injected into adjacent but wrong tasks. - """ - import json as _json - import re as _re - from src.llm_core import llm_call_async - - catalog = "\n".join(f"- {o.get('name')}: {o.get('description', '')}" for o in others[:80]) or "(no other skills)" - sys_prompt = ( - "You are auditing retrieval metadata for a reusable AI skill. The app selects " - "skills by matching user requests against the skill name, description, tags, " - "when_to_use, and procedure text. Judge whether this skill is likely to be " - "over-selected for nearby but wrong requests.\n\n" - "Focus ONLY on metadata/retrieval precision, not whether the procedure works. " - "Flag broad tags such as network, installation, system, document, search, ssh, " - "python, gpu, or server when they would cause this narrow skill to match too " - "many adjacent tasks. Recommend narrower tags/when_to_use wording. Compare " - "against the other skills to spot boundaries.\n\n" - "Return ok=true only when the trigger metadata is narrow enough. If not ok, " - "issues MUST start with 'metadata: retrieval:' and be actionable. Output ONLY JSON:\n" - '{"ok": true|false, "summary": "one short sentence", "issues": ["metadata: retrieval: ..."]}' + workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]: + return await _evaluation.evaluate_retrieval_precision( + skill_md, others, url, model, headers, workload, owner, + _effort_for_call=effort_for_call, ) - user_msg = ( - f"=== SKILL UNDER REVIEW ===\n{(skill_md or '')[:5000]}\n\n" - f"=== OTHER SKILLS IN LIBRARY ===\n{catalog[:5000]}\n\n" - "Decide if this skill's retrieval metadata should be narrowed so it only " - "fires for its intended scenario and not for adjacent skills above." - ) - try: - raw = await llm_call_async( - url, model, - [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}], - temperature=0.1, max_tokens=4096, headers=headers, timeout=90, - workload=workload, - ) - except Exception as e: - logger.warning(f"Retrieval precision check failed: {e}") - return None - text = strip_closed_think_blocks(raw or '') - text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I).strip() - data = None - a, b = text.find('{'), text.rfind('}') - if a >= 0 and b > a: - frag = text[a:b + 1] - for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): - try: - data = _json.loads(cand) - break - except Exception: - continue - if not isinstance(data, dict) or "ok" not in data: - return None - return { - "ok": bool(data.get("ok")), - "summary": str(data.get("summary", ""))[:300], - "issues": [str(x)[:220] for x in (data.get("issues") or []) if str(x).strip()][:6], - } # In-memory skill-test jobs, keyed by (owner, skill_name). Runs server-side so # the test survives the modal being closed; the UI polls /test-status. (Not # persisted across restart — it's a "come back in a bit" convenience.) -_skill_test_jobs: dict = {} +_skill_test_jobs = _agent_runner._skill_test_jobs async def _run_skill_test_job( @@ -537,1160 +165,125 @@ async def _run_skill_test_job( exact_approval=None, request_authority=None, ): - """Background coroutine: run the skill in an agent loop, capture a condensed - log + transcript, then have the judge grade it. Writes into _skill_test_jobs.""" - import json as _json - from src.agent_loop import stream_agent_loop - from src.agent_runtime.authority import RequestAuthority - - job = _skill_test_jobs.get(key) - if job is None: - return - log = job["log"] - transcript = transcript if isinstance(transcript, list) else [] - say_buf = [] - skill_stats = {"turns": 0, "tool_calls": 0} - - def _flush_say(): - if say_buf: - log.append({"type": "say", "text": "".join(say_buf)}) - say_buf.clear() - - messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task) - try: - async for chunk in stream_agent_loop( - url, model, messages, headers=headers, - temperature=0.3, max_tokens=0, max_rounds=8, owner=owner, - exact_approval=exact_approval, - request_authority=(request_authority or ( - exact_approval.pending.request_authority if exact_approval is not None else None - ) or RequestAuthority.empty(owner=owner)), - ): - if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]": - continue - try: - d = _json.loads(chunk[6:]) - except Exception: - continue - if d.get("delta"): - say_buf.append(d["delta"]); transcript.append(d["delta"]) - elif d.get("type") == "tool_start": - _flush_say() - skill_stats["tool_calls"] += 1 - cmd = str(d.get("command") or d.get("args") or "")[:300] - log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd}) - transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n") - elif d.get("type") == "tool_output": - _flush_say() - out = str(d.get("output") or "")[:600] - tool_log = {"type": "tool_output", "output": out} - approval = d.get("ask_user") - if isinstance(approval, dict): - tool_log["ask_user"] = approval - log.append(tool_log) - transcript.append(f"[output] {out}\n") - if ( - isinstance(approval, dict) - and approval.get("kind") == "tool_approval" - and approval.get("approval_id") - ): - # Manual skill tests have their own polling UI instead of a - # chat session. Pause the run and retain only server-side - # continuation state until the same owner approves/denies - # this exact sealed action. - job["status"] = "awaiting_approval" - job["approval"] = approval - job["_transcript"] = transcript - return - elif d.get("type") == "agent_step": - _flush_say() - try: - skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0)) - except (TypeError, ValueError): - pass - log.append({"type": "agent_step", "round": d.get("round")}) - transcript.append(f"\n--- round {d.get('round')} ---\n") - elif d.get("type") == "metrics": - data = d.get("data") or {} - try: - skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0)) - except (TypeError, ValueError): - pass - try: - skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0)) - except (TypeError, ValueError): - pass - if len(log) > 600: - del log[0:len(log) - 600] - _flush_say() - except Exception as e: - _flush_say() - log.append({"type": "error", "error": str(e)}) - - job.pop("approval", None) - job.pop("_transcript", None) - job.pop("_run", None) - log.append({"type": "evaluating"}) - try: - baseline_task = task - log.append({"type": "agent_step", "round": "baseline"}) - baseline_transcript, baseline_stats, baseline_approval = await _run_skill_audit_arm( - _skill_baseline_messages(baseline_task), - url, - model, - headers, - owner, - ) - if baseline_approval is not None: - try: - from src.tool_approvals import tool_approval_store - tool_approval_store.consume( - baseline_approval.get("approval_id"), - decision="deny", - owner=owner, - session_id=None, - ) - except Exception: - logger.debug("Could not retire manual-test baseline approval", exc_info=True) - job["verdict"] = await _eval_skill_run( - md, - task, - "".join(transcript), - url, - model, - headers, - baseline_transcript=baseline_transcript, - skill_stats=skill_stats, - baseline_stats=baseline_stats, - ) - except Exception as e: - job["verdict"] = {"verdict": "unknown", "confidence": 0, "summary": f"Eval failed: {e}", "issues": []} - # Record the result so the card shows a 'verified' check (a manual test - # never involves the teacher) and nudge the confidence score to match the - # verdict — same scale as Audit-all's pass=0.95. inconclusive/unknown leave - # the score alone (missing-fixture or parse failures shouldn't punish it). - if skills_manager is not None: - v = (job["verdict"] or {}).get("verdict") or "unknown" - try: - skills_manager.set_audit( - name, - v, - by_teacher=False, - worker_model=model, - owner=owner, - **_verdict_efficiency(job.get("verdict")), - ) - except Exception: - pass - conf = {"pass": 0.95, "needs_work": 0.6, "fail": 0.4}.get(v) - if conf is not None: - try: - skills_manager.update_skill(name, {"confidence": conf}, owner=owner) - except Exception: - pass - job["status"] = "done" + return await _agent_runner.run_test_job( + key, name, md, task, url, model, headers, owner, skills_manager, + messages=messages, transcript=transcript, exact_approval=exact_approval, + request_authority=request_authority, deps=_audit_dependencies(), + ) # ── Autonomous skill audit: test → judge → self-edit → retry → teacher → flag ── -_skill_audit_jobs: dict = {} +_skill_audit_jobs = _audit_workflow._skill_audit_jobs -def _audit_auto_publish_policy(owner) -> tuple[bool, float]: - """Return (auto_publish_enabled, minimum_confidence) for audit finalization.""" - try: - from routes.prefs_routes import _load_for_user - prefs = _load_for_user(owner) or {} - except Exception: - prefs = {} - try: - from src.settings import get_setting - default_min = get_setting("skill_autosave_min_confidence", 0.85) - except Exception: - default_min = 0.85 - enabled = bool(prefs.get("auto_approve_skills", True)) - try: - min_conf = float(prefs.get("skill_min_confidence", default_min)) - except (TypeError, ValueError): - min_conf = 0.85 - return enabled, max(0.0, min(1.0, min_conf)) +_audit_auto_publish_policy = _publication.auto_publish_policy -def _skill_duplicate_blocker(skills_manager, name: str, owner) -> Optional[str]: - """Cheap duplicate guard matching the UI's duplicate grouping. - - The LLM necessity check catches semantic redundancy, but the UI also has a - cheap similarity pass. Use the same broad signal before auto-publishing so - a high-scoring lower-priority duplicate stays draft. - """ - import re as _re - - def _tokens(sk: dict) -> set[str]: - text = " ".join([ - str(sk.get("name") or ""), - str(sk.get("description") or ""), - str(sk.get("when_to_use") or ""), - " ".join(sk.get("procedure") or []), - " ".join(sk.get("tags") or []), - ]).lower() - text = _re.sub(r"-\d+\b", "", text) - return { - t for t in _re.split(r"[^a-z0-9]+", text) - if len(t) > 2 and t not in {"the", "and", "with", "for", "from", "using"} - } - - def _sim(a: dict, b: dict) -> float: - A, B = _tokens(a), _tokens(b) - if not A or not B: - return 0.0 - return len(A & B) / max(1, len(A | B)) - - def _base(n: str) -> str: - return _re.sub(r"-\d+$", "", str(n or "")) - - def _score(sk: dict) -> float: - return ( - (100000 if (sk.get("status") == "published") else 0) - + int(sk.get("uses") or 0) * 100 - + round(float(sk.get("confidence") or 0) * 100) - + (-5 if sk.get("audit_by_teacher") else 0) - - (len(str(sk.get("name") or "")) / 1000) - ) - - skills = [s for s in skills_manager.load(owner=owner) if s.get("status") != "binned"] - current = next((s for s in skills if (s.get("name") or s.get("id")) == name), None) - if not current: - return None - duplicates = [] - cur_name = current.get("name") or current.get("id") or name - for other in skills: - other_name = other.get("name") or other.get("id") - if not other_name or other_name == cur_name: - continue - if _base(cur_name) == _base(other_name) or _sim(current, other) >= 0.38: - duplicates.append(other) - if not duplicates: - return None - keeper = sorted([current, *duplicates], key=_score, reverse=True)[0] - keeper_name = keeper.get("name") or keeper.get("id") or "" - if keeper_name and keeper_name != cur_name: - try: - skills_manager.set_necessity( - cur_name, - False, - [keeper_name], - f"Lower-priority duplicate of {keeper_name}", - owner=owner, - ) - except Exception: - pass - return keeper_name - return None +_skill_duplicate_blocker = _publication.duplicate_blocker def _finalize_audit_batch(skills_manager, results: list[dict], owner, log) -> None: - """Draft audited failures, bin duplicate losers, and publish the best copy. - - Binned skills remain on disk for recovery and inspection, but the Skills - manager excludes them from retrieval. Only skills actually processed by - this audit job are changed here; an unrelated existing skill is never - moved just because it resembles an audited one. - """ - import re as _re - - auto_publish, min_conf = _audit_auto_publish_policy(owner) - current = [ - s for s in skills_manager.load(owner=owner) - if s.get("source") != "builtin" and s.get("status") != "binned" - ] - by_name = {s.get("name"): s for s in current if s.get("name")} - processed = {str(r.get("skill")) for r in results if r.get("skill")} - protected = { - str(r.get("skill")) for r in results - if r.get("skill") and r.get("result") == "approval_required" - } - - def tokens(sk: dict) -> set[str]: - text = " ".join([ - str(sk.get("name") or ""), str(sk.get("description") or ""), - str(sk.get("when_to_use") or ""), " ".join(sk.get("procedure") or []), - " ".join(sk.get("tags") or []), - ]).lower() - text = _re.sub(r"-\d+\b", "", text) - return { - t for t in _re.split(r"[^a-z0-9]+", text) - if len(t) > 2 and t not in {"the", "and", "with", "for", "from", "using"} - } - - def similar(a: dict, b: dict) -> float: - left, right = tokens(a), tokens(b) - return len(left & right) / max(1, len(left | right)) if left and right else 0.0 - - def base(name: str) -> str: - return _re.sub(r"-\d+$", "", str(name or "")) - - def score(sk: dict) -> float: - try: - confidence = float(sk.get("confidence") or 0) - except (TypeError, ValueError): - confidence = 0.0 - return ( - (100000 if sk.get("status") == "published" else 0) - + int(sk.get("uses") or 0) * 100 - + round(confidence * 100) - + (-5 if sk.get("audit_by_teacher") else 0) - - len(str(sk.get("name") or "")) / 1000 - ) - - # Anything that does not clear the configured policy stays a draft. Drafts - # are excluded from retrieval/injection by SkillsManager.index_for(). - for name in processed - protected: - skill = by_name.get(name) - if not skill: - continue - verdict = str(skill.get("audit_verdict") or "").lower() - try: - confidence = float(skill.get("confidence") or 0) - except (TypeError, ValueError): - confidence = 0.0 - if verdict in {"needs_work", "fail"} or ( - verdict == "pass" and confidence < min_conf - ): - try: - skills_manager.update_skill(name, {"status": "draft"}, owner=owner) - log(f"{name}: kept as draft after audit") - except Exception: - logger.warning("Could not bin audited skill %s", name, exc_info=True) - - # Build the same connected duplicate groups shown by the UI. - parent = {s["name"]: s["name"] for s in current} - - def find(name: str) -> str: - while parent[name] != name: - parent[name] = parent[parent[name]] - name = parent[name] - return name - - def unite(left: str, right: str) -> None: - left, right = find(left), find(right) - if left != right: - parent[right] = left - - for index, left in enumerate(current): - for right in current[index + 1:]: - if base(left["name"]) == base(right["name"]) or similar(left, right) >= 0.38: - unite(left["name"], right["name"]) - groups: dict[str, list[dict]] = {} - for skill in current: - groups.setdefault(find(skill["name"]), []).append(skill) - - for group in groups.values(): - if len(group) < 2: - continue - passing = [] - for skill in group: - if skill["name"] not in processed or skill["name"] in protected: - continue - if str(skill.get("audit_verdict") or "").lower() != "pass": - continue - try: - confidence = float(skill.get("confidence") or 0) - except (TypeError, ValueError): - confidence = 0.0 - if confidence >= min_conf: - passing.append(skill) - if not passing: - continue - keeper = max(passing, key=score) - if auto_publish: - try: - skills_manager.update_skill(keeper["name"], {"status": "published"}, owner=owner) - log(f"{keeper['name']}: auto-approved as best passing duplicate") - except Exception: - logger.warning("Could not auto-approve skill %s", keeper["name"], exc_info=True) - for skill in group: - name = skill["name"] - if name == keeper["name"] or name not in processed or name in protected: - continue - try: - skills_manager.update_skill(name, {"status": "binned"}, owner=owner) - log(f"{name}: moved to bin as duplicate of {keeper['name']}") - except Exception: - logger.warning("Could not bin duplicate skill %s", name, exc_info=True) - - -def _audit_flag_text(*parts) -> str: - text_parts = [] - for part in parts: - if isinstance(part, dict): - text_parts.extend(str(v or "") for v in part.values()) - elif isinstance(part, (list, tuple, set)): - text_parts.extend(str(v or "") for v in part) - else: - text_parts.append(str(part or "")) - return " ".join(text_parts).lower() - - -def _audit_utility_blocker(skill: Optional[dict], necessity: Optional[dict], - verdict_data: Optional[dict]) -> Optional[str]: - """Return a short reason when a passing skill still should stay draft. - - Broad/generic wording is not a blocker by itself. The blocker is whether - the skill failed to improve the agent versus a no-skill baseline, or whether - it duplicates another skill. - """ - generic_re = re.compile( - r"\b(duplicat\w*|redundan\w*|overlap\w*|same skill|same procedure)\b", - re.I, + return _publication.finalize_batch( + skills_manager, results, owner, log, deps=_audit_dependencies(), ) - if isinstance(necessity, dict): - reason = str(necessity.get("reason") or "") - if necessity.get("necessary") is False and generic_re.search(reason): - return reason or "Duplicate or redundant skill" - if isinstance(verdict_data, dict): - baseline_verdict = str(verdict_data.get("baseline_verdict") or "unknown").lower() - try: - usefulness = float(verdict_data.get("usefulness", 0.0) or 0.0) - except (TypeError, ValueError): - usefulness = 0.0 - try: - saved_turns = int(verdict_data.get("saved_turns", 0) or 0) - except (TypeError, ValueError): - saved_turns = 0 - try: - saved_tool_calls = int(verdict_data.get("saved_tool_calls", 0) or 0) - except (TypeError, ValueError): - saved_tool_calls = 0 - if baseline_verdict == "worse": - return "Skill performed worse than the no-skill baseline" - verdict_text = _audit_flag_text( - verdict_data.get("summary"), - verdict_data.get("issues") or [], - ) - if generic_re.search(verdict_text): - return "Audit flagged the skill as duplicate or redundant" - return None + +_audit_flag_text = _publication.audit_flag_text + + +_audit_utility_blocker = _publication.utility_blocker def _audit_finalize_status(skills_manager, name: str, owner, verdict: str, confidence: Optional[float], necessity: Optional[dict] = None, verdict_data: Optional[dict] = None) -> str: - """Apply the user's audit publishing policy. - - Audit is the final pass: skills that pass at/above the threshold are - published; failing or unnecessary/redundant skills are returned to draft. - Inconclusive runs preserve the existing state because they provide no - evidence either way. The completed batch moves duplicate losers to the bin. - """ - auto_publish, min_conf = _audit_auto_publish_policy(owner) - necessary = True - current = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) - if verdict in {"inconclusive", "unknown"}: - return (current or {}).get("status") or "draft" - utility_reason = _audit_utility_blocker(current, necessity, verdict_data) - if ( - isinstance(necessity, dict) - and necessity.get("necessary") is False - and necessity.get("redundant_with") - ): - necessary = False - if utility_reason: - necessary = False - try: - skills_manager.set_necessity(name, False, [], utility_reason, owner=owner) - except Exception: - pass - duplicate_of = _skill_duplicate_blocker(skills_manager, name, owner) if verdict == "pass" else None - if duplicate_of: - necessary = False - c = float(confidence or 0.0) - status = "published" if (auto_publish and necessary and verdict == "pass" and c >= min_conf) else "draft" - try: - skills_manager.update_skill(name, {"status": status}, owner=owner) - except Exception: - pass - return status + return _publication.finalize_status( + skills_manager, name, owner, verdict, confidence, necessity, verdict_data, + deps=_audit_dependencies(), + ) -def _apply_skill_md(skills_manager, name: str, md: str, owner) -> bool: - """Parse + persist an edited SKILL.md. Returns True on success.""" - try: - from services.memory.skill_format import Skill, slugify - sk = Skill.from_markdown(md) - # Pin the identity: the audit's fixer is now allowed to edit frontmatter - # (tags/category/when_to_use/description), but it must NEVER rename the - # skill — a changed `name` would move the dir and orphan the usage/audit - # sidecar entries that the caller keeps writing under the original name. - sk.name = name - return bool(skills_manager.update_skill(name, { - "name": sk.name, "description": sk.description, "version": sk.version, - "category": sk.category, "tags": sk.tags, "platforms": sk.platforms, - "requires_toolsets": sk.requires_toolsets, "fallback_for_toolsets": sk.fallback_for_toolsets, - "status": sk.status, "confidence": sk.confidence, "source": sk.source, - "teacher_model": sk.teacher_model, "owner": sk.owner or owner, - "when_to_use": sk.when_to_use, "procedure": sk.procedure, - "pitfalls": sk.pitfalls, "verification": sk.verification, "body_extra": sk.body_extra, - }, owner=owner)) - except Exception as e: - logger.warning(f"Audit: could not save edited skill {name}: {e}") - return False +_apply_skill_md = _publication.apply_markdown -class SkillAuditUnavailable(RuntimeError): - """The test infrastructure failed; this is not evidence about a skill.""" +SkillAuditUnavailable = _agent_runner.SkillAuditUnavailable async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner, workload: str = "foreground") -> tuple[str, dict, Optional[dict]]: - """Run one audit arm in the agent loop; return transcript, stats, approval.""" - import json as _json - from src.agent_loop import stream_agent_loop - from src.agent_runtime.authority import RequestAuthority, active_request_authority - transcript = [] - approval_required = None - stats = {"turns": 0, "tool_calls": 0} - try: - # max_tokens explicitly set: passing 0 lets some upstreams (Ollama, - # OpenAI-compat) generate an empty completion, which manifested as - # the skill test returning nothing while chat (which carries its - # preset's max_tokens) worked. 4096 matches the chat default. - async for chunk in stream_agent_loop( - url, model, messages, headers=headers, - temperature=0.3, max_tokens=4096, max_rounds=8, - owner=owner, workload=workload, suppress_skills=True, - request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)), - ): - # Streams can include an SSE event line before the data line, - # notably `event: error`. Do not silently discard those failures. - payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None) - if payload is None or payload == "[DONE]": - continue - try: - d = _json.loads(payload) - except Exception: - continue - if d.get("error") or d.get("type") == "error": - raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed")) - if d.get("delta"): - transcript.append(d["delta"]) - elif d.get("type") == "tool_start": - stats["tool_calls"] += 1 - transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n") - elif d.get("type") == "tool_output": - transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n") - approval = d.get("ask_user") - if ( - isinstance(approval, dict) - and approval.get("kind") == "tool_approval" - ): - approval_required = approval - break - elif d.get("type") == "agent_step": - try: - stats["turns"] = max(stats["turns"], int(d.get("round") or 0)) - except (TypeError, ValueError): - pass - transcript.append(f"\n--- round {d.get('round')} ---\n") - elif d.get("type") == "metrics": - data = d.get("data") or {} - try: - stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0)) - except (TypeError, ValueError): - pass - try: - stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0)) - except (TypeError, ValueError): - pass - except SkillAuditUnavailable: - raise - except Exception as e: - raise SkillAuditUnavailable(str(e)) from e - return "".join(transcript), stats, approval_required + return await _agent_runner.run_audit_arm(messages, url, model, headers, owner, workload) async def _run_skill_test_once(md: str, task: str, url, model, headers, owner, workload: str = "foreground") -> tuple: - """Run the skill once in the agent loop; return (transcript, verdict).""" - messages = _skill_test_messages(md, task) - text, stats, approval_required = await _run_skill_audit_arm( - messages, url, model, headers, owner, workload=workload, + return await _agent_runner.run_test_once( + md, task, url, model, headers, owner, workload, deps=_audit_dependencies(), ) - if approval_required is not None: - # Unattended audits have no authority to approve and no UI that could - # resume this record. Destructively deny it now instead of leaving a - # reusable opaque grant pending until TTL/cap eviction. - try: - from src.tool_approvals import tool_approval_store - tool_approval_store.consume( - approval_required.get("approval_id"), - decision="deny", - owner=owner, - session_id=None, - ) - except Exception: - logger.debug("Could not retire unattended skill approval", exc_info=True) - return text, { - "verdict": "inconclusive", - "confidence": 1.0, - "summary": ( - "This automated audit reached an exact action that requires " - "a human approval; no action was executed." - ), - "issues": [ - "Run this skill's manual test and review the sealed action." - ], - "approval_required": True, - } - verdict = await _eval_skill_run( - md, - task, - text, - url, - model, - headers, - skill_stats=stats, - workload=workload, - ) - return text, verdict async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url, model, headers, - workload: str = "foreground"): - """Have a model rewrite SKILL.md to fix the reviewer's issues. Returns the - corrected markdown, or None if it couldn't produce a usable change.""" - import re as _re - from src.llm_core import llm_call_async - issues = "\n".join("- " + str(i) for i in (verdict.get("issues") or [])) - sys_prompt = ( - "You are improving a reusable AI SKILL written in Markdown (frontmatter + body). " - "A QA reviewer found problems after a test run. Rewrite the SKILL.md to fix them: " - "make vague steps concrete, correct or remove references to tools that don't exist, " - "ensure the procedure is reproducible. " - "Keep the `name` field EXACTLY as-is (it is the skill's identity / filename). You MAY " - "correct the OTHER frontmatter — tags, category, when_to_use, description — when the " - "reviewer flagged them (issues prefixed 'metadata:') or they don't match the body; keep " - "retrieval metadata narrow: remove broad tags that would over-select the skill, and make " - "`when_to_use` say when NOT to use the skill if adjacent tasks are easy to confuse. Keep " - "valid frontmatter structure. Do NOT invent capabilities the agent lacks. Reason in " - " first if needed, then output ONLY the full corrected SKILL.md (no " - "fences, no commentary)." + workload: str = "foreground", owner: Optional[str] = None): + return await _evaluation.improve_markdown( + skill_md, verdict, transcript, url, model, headers, workload, owner, + _effort_for_call=effort_for_call, ) - user_msg = ( - f"=== CURRENT SKILL.md ===\n{skill_md}\n\n" - f"=== REVIEWER VERDICT ===\n{verdict.get('summary', '')}\nIssues:\n{issues}\n\n" - f"=== TEST TRANSCRIPT ===\n{(transcript or '')[:6000]}" - ) - try: - raw = await llm_call_async(url, model, - [{"role": "system", "content": sys_prompt}, - {"role": "user", "content": user_msg}], - temperature=0.2, max_tokens=16384, headers=headers, timeout=180, - workload=workload) - except Exception as e: - logger.warning(f"Audit: improve call failed: {e}") - return None - text = strip_closed_think_blocks(raw or '') - text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I) - text = _re.sub(r'', '', text, flags=_re.I).strip() - if text.startswith("```"): - text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - # Some reasoning models still prepend analysis or echo the old skill before - # the final corrected document. Keep only the last complete-looking - # frontmatter document so reviewer prose never gets persisted into SKILL.md. - starts = list(_re.finditer(r'(?m)^---\s*\n(?=[\s\S]*?^name\s*:)', text)) - if starts: - text = text[starts[-1].start():].strip() - return text or None async def _audit_one_skill(skills_manager, skill, url, model, headers, teacher, owner, log, workload: str = "foreground") -> dict: - """Test → judge → self-edit+retry → (teacher edit+retry) → flag. Never deletes; - a skill the teacher still can't fix is demoted to draft for manual review. - `teacher` is (url, model, headers) or None. `log(msg)` records progress.""" - name = skill.get("name") - - # Reflect the audit outcome in the skill's confidence so the main list - # updates: a clean pass earns high confidence; a pass that needed fixing - # earns a bit less; a skill that still fails is marked low. - def _set_conf(c): - try: - skills_manager.update_skill(name, {"confidence": c}, owner=owner) - except Exception: - pass - - md = skills_manager.read_skill_md(name, owner=owner) - if not md: - log(f"{name}: no source — skipped") - return {"skill": name, "result": "skipped"} - - # Cheap deterministic cleanup first. If this is an obvious lower-priority - # duplicate, do not spend LLM turns on necessity, retrieval precision, - # skill-vs-baseline testing, self-edit, or teacher escalation. - duplicate_of = _skill_duplicate_blocker(skills_manager, name, owner) - if duplicate_of: - reason = f"Lower-priority duplicate of {duplicate_of}" - try: - skills_manager.update_skill(name, {"status": "draft", "confidence": 0.35}, owner=owner) - skills_manager.set_audit(name, "skipped", by_teacher=False, worker_model=model, owner=owner) - skills_manager.set_necessity(name, False, [duplicate_of], reason, owner=owner) - except Exception: - pass - log(f"{name}: draft — skipped audit ({reason[:100]})") - return {"skill": name, "result": "skipped_duplicate", "reason": reason, "confidence": 0.35, "status": "draft"} - - # Advisory necessity/redundancy check — runs once, independent of the test - # outcome, and only records a flag the UI surfaces (never deletes/demotes). - others = [] - nec = None - try: - # Only compare against skills the SAME owner can see, so the necessity - # judge never sees (or flags "redundant_with") another user's skills. - sk_owner = skill.get("owner") - others = [ - {"name": s.get("name"), "description": s.get("description", "")} - for s in skills_manager.load(owner=owner) - if s.get("name") and s.get("name") != name - and (not sk_owner or not s.get("owner") or s.get("owner") == sk_owner) - ] - nec = await _eval_skill_necessity( - md, others, url, model, headers, workload=workload, - ) - if nec is not None: - skills_manager.set_necessity(name, nec.get("necessary", True), - nec.get("redundant_with"), nec.get("reason"), - owner=owner) - if not nec.get("necessary", True): - log(f"{name}: possibly unnecessary — {nec.get('reason', '')[:80]}") - except Exception as e: - log(f"{name}: necessity check skipped — {e}") - - # Retrieval precision check: if broad tags/trigger text would make this - # narrow skill over-inject, fix only metadata before the functional test. - try: - if _should_check_retrieval_precision(skill): - rp = await _eval_skill_retrieval_precision( - md, others, url, model, headers, workload=workload, - ) - if rp and not rp.get("ok"): - issues = rp.get("issues") or ["metadata: retrieval: narrow tags and when_to_use to the intended trigger"] - log(f"{name}: narrowing retrieval metadata — {(rp.get('summary') or issues[0])[:80]}") - fixed = await _improve_skill_md(md, { - "verdict": "pass", - "confidence": 1.0, - "summary": rp.get("summary") or "Retrieval metadata is too broad.", - "issues": issues, - }, "Retrieval audit only: the procedure may work, but matching metadata is too broad.", - url, model, headers, workload=workload) - if fixed and fixed.strip() != md.strip() and _apply_skill_md(skills_manager, name, fixed, owner): - md = fixed - refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) - if refreshed: - skill = refreshed - except Exception as e: - log(f"{name}: retrieval precision check skipped — {e}") - - task = _skill_test_task(skill) - log(f"{name}: testing…") - skill_messages = _skill_test_messages(md, task) - transcript, skill_stats, approval_required = await _run_skill_audit_arm( - skill_messages, - url, - model, - headers, - owner, - workload=workload, + return await _audit_workflow.audit_one_skill( + skills_manager, skill, url, model, headers, teacher, owner, log, workload, + deps=_audit_dependencies(), ) - if approval_required is not None: - try: - from src.tool_approvals import tool_approval_store - tool_approval_store.consume( - approval_required.get("approval_id"), - decision="deny", - owner=owner, - session_id=None, - ) - except Exception: - logger.debug("Could not retire unattended skill approval", exc_info=True) - verdict = { - "verdict": "inconclusive", - "confidence": 1.0, - "summary": ( - "This automated audit reached an exact action that requires " - "a human approval; no action was executed." - ), - "issues": [ - "Run this skill's manual test and review the sealed action." - ], - "approval_required": True, - } - else: - baseline_task = task - log(f"{name}: running no-skill baseline…") - baseline_transcript, baseline_stats, baseline_approval = await _run_skill_audit_arm( - _skill_baseline_messages(baseline_task), - url, - model, - headers, - owner, - workload=workload, - ) - if baseline_approval is not None: - try: - from src.tool_approvals import tool_approval_store - tool_approval_store.consume( - baseline_approval.get("approval_id"), - decision="deny", - owner=owner, - session_id=None, - ) - except Exception: - logger.debug("Could not retire unattended baseline approval", exc_info=True) - verdict = await _eval_skill_run( - md, - task, - transcript, - url, - model, - headers, - baseline_transcript=baseline_transcript, - skill_stats=skill_stats, - baseline_stats=baseline_stats, - workload=workload, - ) - v = verdict.get("verdict") - log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})") - if verdict.get("approval_required"): - # An unattended audit is not authority for an action influenced by the - # skill under test. Preserve the skill's current publication/confidence - # state and route the exact action to the manual test UI instead of - # letting a safety pause demote, rewrite, or auto-publish the skill. - skills_manager.set_audit( - name, - "inconclusive", - by_teacher=False, - worker_model=model, - owner=owner, - audit_summary=verdict.get("summary") or "The test requires approval for an external action.", - ) - status = skill.get("status") or "draft" - log(f"{name}: {status} unchanged — exact action needs manual approval") - return { - "skill": name, - "result": "approval_required", - "verdict": verdict, - "status": status, - } - if v == "pass": - # Procedure works. If the reviewer still flagged metadata (tags/category/ - # when_to_use/description), do ONE fixer pass to correct the frontmatter - # without re-testing — a metadata-only fix can't break a passing run. - meta_issues = [i for i in (verdict.get("issues") or []) if str(i).lower().lstrip().startswith("metadata:")] - if meta_issues: - log(f"{name}: pass, but fixing {len(meta_issues)} metadata issue(s)…") - fixed = await _improve_skill_md( - md, verdict, transcript, url, model, headers, workload=workload, - ) - if fixed and fixed.strip() != md.strip(): - _apply_skill_md(skills_manager, name, fixed, owner) - _set_conf(0.95) - skills_manager.set_audit( - name, - "pass", - by_teacher=False, - worker_model=model, - owner=owner, - **_verdict_efficiency(verdict), - ) - refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) - status = _audit_finalize_status(skills_manager, name, owner, "pass", 0.95, (refreshed or {}).get("necessity"), verdict) - log(f"{name}: {status} — confidence 95%") - return {"skill": name, "result": "pass", "verdict": verdict, "confidence": 0.95, "status": status} - if v in ("unknown", "inconclusive"): - skills_manager.set_audit( - name, - "inconclusive", - by_teacher=False, - worker_model=model, - owner=owner, - **_verdict_efficiency(verdict), - ) - status = _audit_finalize_status(skills_manager, name, owner, "inconclusive", skill.get("confidence") or 0.0, skill.get("necessity")) - log(f"{name}: {status} — inconclusive") - return {"skill": name, "result": "inconclusive", "verdict": verdict, "status": status} - - # Self-edit + retry. - log(f"{name}: self-editing to fix issues…") - new_md = await _improve_skill_md( - md, verdict, transcript, url, model, headers, workload=workload, - ) - if new_md and new_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, new_md, owner): - md = new_md - transcript, verdict = await _run_skill_test_once( - md, task, url, model, headers, owner, workload=workload, - ) - v = verdict.get("verdict") - log(f"{name}: retry (self) = {v}") - if v == "pass": - _set_conf(0.85) - skills_manager.set_audit( - name, - "pass", - by_teacher=False, - worker_model=model, - owner=owner, - **_verdict_efficiency(verdict), - ) - refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) - status = _audit_finalize_status(skills_manager, name, owner, "pass", 0.85, (refreshed or {}).get("necessity"), verdict) - log(f"{name}: {status} — confidence 85% after self-edit") - return {"skill": name, "result": "pass_after_self_edit", "verdict": verdict, "confidence": 0.85, "status": status} - - # Teacher escalation (if a distinct teacher model is configured). The - # teacher only REWRITES the skill — it does NOT run the test. The point is - # to verify the regular (student) model can now succeed with the teacher's - # improved procedure, so the retry runs on the worker model, not the teacher. - teacher_ran = False - if teacher and teacher[0] and teacher[1] and (teacher[1] != model or teacher[0] != url): - teacher_ran = True - t_url, t_model, t_headers = teacher - log(f"{name}: teacher {t_model} rewriting the skill…") - t_md = await _improve_skill_md( - md, verdict, transcript, t_url, t_model, t_headers, workload=workload, - ) - if t_md and t_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, t_md, owner): - md = t_md - # Re-test with the STUDENT model (the model the skill runs under in use). - transcript, verdict = await _run_skill_test_once( - md, task, url, model, headers, owner, workload=workload, - ) - v = verdict.get("verdict") - log(f"{name}: retry on student after teacher rewrite = {v}") - if v == "pass": - _set_conf(0.8) - skills_manager.set_audit( - name, - "pass", - by_teacher=True, - worker_model=model, - teacher_model=t_model, - owner=owner, - **_verdict_efficiency(verdict), - ) - refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) - status = _audit_finalize_status(skills_manager, name, owner, "pass", 0.8, (refreshed or {}).get("necessity"), verdict) - log(f"{name}: {status} — confidence 80% after teacher rewrite") - return {"skill": name, "result": "pass_after_teacher", "verdict": verdict, "confidence": 0.8, "status": status} - - # Still failing → demote to draft + low confidence + flag (do NOT delete). - try: - skills_manager.update_skill(name, {"status": "draft", "confidence": 0.35}, owner=owner) - except Exception: - pass - skills_manager.set_audit( - name, v or "fail", by_teacher=teacher_ran, - worker_model=model, - teacher_model=(teacher[1] if teacher_ran and teacher else ""), - owner=owner, - **_verdict_efficiency(verdict), - ) - log(f"{name}: flagged — confidence lowered, kept as draft for manual review") - return {"skill": name, "result": "flagged", "verdict": verdict, "confidence": 0.35} async def _run_audit_all_job(key, skills_manager, names, url, model, headers, teacher, owner, workload: str = "foreground"): - """Background: audit each named skill in sequence, recording progress.""" - import asyncio as _asyncio - import time as _time - - job = _skill_audit_jobs.get(key) - if job is None: - return - - def log(msg): - job["log"].append(msg) - if len(job["log"]) > 1000: - del job["log"][0:len(job["log"]) - 1000] - - cancelled = False - try: - for nm in names: - if job.get("cancel"): - cancelled = True - log("(cancelled)") - break - job["current"] = nm - skills = skills_manager.load(owner=owner) - sk = next((s for s in skills if s.get("name") == nm), None) - if not sk: - continue - try: - res = await _audit_one_skill( - skills_manager, sk, url, model, headers, teacher, owner, log, - workload=workload, - ) - except _asyncio.CancelledError: - cancelled = True - job["cancel"] = True - log("(cancelled)") - raise - except SkillAuditUnavailable as e: - job["unavailable"] = str(e) - log(f"Audit paused: {e}. Skill verdicts unchanged; retry when the model is available.") - break - except Exception as e: - log(f"{nm}: error — {e}") - res = {"skill": nm, "result": "error"} - skills_manager.set_audit(nm, "inconclusive", worker_model=model, owner=owner) - try: - refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == nm), None) - if refreshed: - res["skill_state"] = { - "name": refreshed.get("name"), - "status": refreshed.get("status"), - "confidence": refreshed.get("confidence"), - "audit_verdict": refreshed.get("audit_verdict"), - "audit_by_teacher": refreshed.get("audit_by_teacher"), - "audit_worker_model": refreshed.get("audit_worker_model"), - "audit_teacher_model": refreshed.get("audit_teacher_model"), - "audited_at": refreshed.get("audited_at"), - "saved_turns": refreshed.get("saved_turns"), - "saved_tool_calls": refreshed.get("saved_tool_calls"), - "baseline_verdict": refreshed.get("baseline_verdict"), - "usefulness": refreshed.get("usefulness"), - "necessity": refreshed.get("necessity"), - } - except Exception: - pass - job["results"].append(res) - job["done"] = len(job["results"]) - except _asyncio.CancelledError: - cancelled = True - finally: - if not cancelled and not job.get("cancel") and not job.get("unavailable"): - try: - _finalize_audit_batch(skills_manager, job.get("results") or [], owner, log) - except Exception: - logger.warning("Could not finalize skills audit batch", exc_info=True) - job["current"] = None - job["status"] = "cancelled" if cancelled or job.get("cancel") else "error" if job.get("unavailable") else "done" - job["finished"] = _time.time() - job.pop("task", None) + return await _audit_workflow.run_audit_batch( + key, skills_manager, names, url, model, headers, teacher, owner, workload, + deps=_audit_dependencies(), + ) def _resolve_audit_models(owner=None, model_spec=None, endpoint_url=None): - """Resolve (url, model, headers, teacher) for an audit run from Settings. - - Worker = Utility model (falling back to Default, normalized to a served - model id); teacher = the optional Settings → Teacher Model config. Shared - by the manual /audit-all route and scheduled/event audits. Raises - ValueError if no worker model. - """ - from src.endpoint_resolver import resolve_endpoint, resolve_utility_fallback_candidates - if model_spec and endpoint_url: - # Scheduled tasks store the endpoint URL and model separately. Resolve - # the endpoint directly so an explicit task choice cannot be replaced - # by the global Utility setting. - from src.endpoint_resolver import build_headers, resolve_endpoint_runtime - from src.database import ModelEndpoint, SessionLocal - from src.endpoint_resolver import normalize_base, same_endpoint_base - from src.auth_helpers import owner_filter - url = endpoint_url - model = model_spec - headers = {} - db = SessionLocal() - try: - query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) - for ep in owner_filter(query, ModelEndpoint, owner).all(): - base = normalize_base(getattr(ep, "base_url", "") or "") - if same_endpoint_base(url, base): - runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner) - headers = build_headers(api_key, runtime_base or base) - break - finally: - db.close() - elif model_spec: - from src.ai_interaction import _resolve_model - url, model, headers = _resolve_model(str(model_spec), owner=owner) - else: - url, model, headers = resolve_endpoint("utility", owner=owner) - if not url or not model: - # Utility fallbacks are an explicit part of the user's model - # configuration. Audits must use the same chain as other background - # work instead of treating an empty primary Utility slot as fatal. - for fallback_url, fallback_model, fallback_headers in resolve_utility_fallback_candidates(owner=owner): - url, model, headers = fallback_url, fallback_model, fallback_headers - break - if not url or not model: - raise ValueError("No model configured — set a Default or Utility model in Settings.") - try: - from src.llm_core import list_model_ids - import os as _os - _avail = list_model_ids(url, headers=headers) - if _avail and model not in _avail: - _base = _os.path.basename((model or "").rstrip("/")) - model = next((a for a in _avail if _os.path.basename(a.rstrip("/")) == _base), None) or _avail[0] - except Exception: - pass - - teacher = None - try: - from src.settings import get_setting - if get_setting("teacher_enabled", False): - spec = (get_setting("teacher_model", "") or "").strip() - if spec: - from src.ai_interaction import _resolve_model - t_url, t_model, t_headers = _resolve_model(spec, owner=owner) - if t_url and t_model: - teacher = (t_url, t_model, t_headers) - except Exception as e: - logger.warning(f"Audit teacher resolve failed: {e}") - return url, model, headers, teacher + return _model_selection.resolve_audit_models(owner, model_spec, endpoint_url) async def run_scheduled_skill_audit(skills_manager: SkillsManager, owner: Optional[str] = None, max_skills: int = 8) -> dict: - """Nightly audit pass. Audits the LEAST-recently-audited skills first and - caps the batch so it rotates through the library over successive nights - instead of re-checking the same ones every run. Reuses the same job store - the manual 'Audit all' button uses, so its progress shows in the UI too.""" - import time as _time + return await _audit_workflow.run_scheduled_skill_audit( + skills_manager, owner, max_skills, deps=_audit_dependencies(), + ) - key = (owner or "",) - existing = _skill_audit_jobs.get(key) - if existing and existing.get("status") == "running": - logger.info("Scheduled skill audit skipped — a run is already active.") - return {"status": "running", "skipped": True} - try: - url, model, headers, teacher = _resolve_audit_models(owner=owner) - except ValueError as e: - logger.info(f"Scheduled skill audit skipped — {e}") - return {"status": "skipped", "reason": str(e)} - - from services.memory.skill_lifecycle import automatic_audit_candidates - skills = automatic_audit_candidates(skills_manager.load(owner=owner), limit=max_skills) - names = [s["name"] for s in skills] - if not names: - return {"status": "done", "total": 0} - - _skill_audit_jobs[key] = { - "status": "running", "scope": "scheduled", "model": model, - "teacher": teacher[1] if teacher else None, - "total": len(names), "done": 0, "current": None, - "results": [], "log": [f"Nightly audit of {len(names)} least-recently-checked skill(s) with {model}" - + (f"; teacher {teacher[1]}" if teacher else "")], - "started": _time.time(), "cancel": False, - } - logger.info(f"Scheduled skill audit starting: {len(names)} skill(s) (owner={owner or 'all'})") - await _run_audit_all_job( - key, skills_manager, names, url, model, headers, teacher, owner, - workload="background", +def _audit_dependencies() -> AuditDependencies: + """Bind current compatibility symbols without shared mutable rebinding.""" + return AuditDependencies( + test_jobs=_skill_test_jobs, + audit_jobs=_skill_audit_jobs, + test_task=_skill_test_task, + test_messages=_skill_test_messages, + baseline_messages=_skill_baseline_messages, + evaluate_run=_eval_skill_run, + evaluate_necessity=_eval_skill_necessity, + evaluate_retrieval_precision=_eval_skill_retrieval_precision, + should_check_retrieval_precision=_should_check_retrieval_precision, + improve_markdown=_improve_skill_md, + apply_markdown=_apply_skill_md, + duplicate_blocker=_skill_duplicate_blocker, + finalize_status=_audit_finalize_status, + finalize_batch=_finalize_audit_batch, + auto_publish_policy=_audit_auto_publish_policy, + utility_blocker=_audit_utility_blocker, + verdict_efficiency=_verdict_efficiency, + run_test_job=_run_skill_test_job, + run_arm=_run_skill_audit_arm, + run_test_once=_run_skill_test_once, + audit_one=_audit_one_skill, + run_batch=_run_audit_all_job, + resolve_models=_resolve_audit_models, ) - job = _skill_audit_jobs.get(key, {}) - return {"status": "done", "total": len(names), "results": job.get("results", [])} def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: @@ -2002,8 +595,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: On completion it records the verdict and nudges the skill's confidence to match (pass→0.95, needs_work→0.6, fail→0.4; inconclusive/unknown leave it untouched). It never changes the skill's published/draft STATUS.""" - import time as _time - import asyncio as _asyncio from core.database import SessionLocal from src.endpoint_resolver import ( build_chat_url, build_headers, resolve_endpoint, @@ -2059,46 +650,14 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: except Exception as _e: logger.warning(f"Skill-test model resolve failed: {_e}") - key = (user or "", name) - previous_job = _skill_test_jobs.get(key) or {} - previous_approval = previous_job.get("approval") or {} - if previous_approval.get("approval_id"): - try: - from src.tool_approvals import tool_approval_store - tool_approval_store.consume( - previous_approval["approval_id"], - decision="deny", - owner=user, - session_id=None, - ) - except Exception: - logger.debug("Could not retire replaced skill approval", exc_info=True) - _skill_test_jobs[key] = { - "status": "running", - "task": task, - "model": model, - "skill": name, - "started": _time.time(), - "log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}], - "verdict": None, - "_run": { - "md": md, - "url": url, - "model": model, - "headers": headers, - "owner": user, - "request_authority": request_authority, - }, - } - _asyncio.create_task(_run_skill_test_job( - key, name, md, task, url, model, headers, user, skills_manager, - request_authority=request_authority)) - return {"ok": True, "status": "running", "skill": name, "model": model} + return _job_coordinator.start_test_job( + name, md, task, url, model, headers, user, skills_manager, + request_authority, deps=_audit_dependencies(), + ) @router.post("/{skill_id}/test-approval") async def approve_skill_test_action(request: Request, skill_id: str): """Resume a manual skill test with one exact server-sealed action.""" - import asyncio as _asyncio from src.tool_approvals import tool_approval_store from src.agent_runtime.authority import require_user_approval_request require_user_approval_request(request) @@ -2149,74 +708,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: if decision == "approve" and exact_approval is None: raise HTTPException(409, "This tool approval could not be consumed.") - job.pop("approval", None) - if decision == "deny": - job.pop("_transcript", None) - job.pop("_run", None) - job["log"].append({ - "type": "approval_denied", - "text": "Exact action denied; the skill test stopped without executing it.", - }) - job["verdict"] = { - "verdict": "inconclusive", - "confidence": 1.0, - "summary": "The test stopped because its exact action was denied.", - "issues": [], - } - job["status"] = "done" - return {"ok": True, "status": "done", "decision": "deny"} - - run = job.get("_run") or {} - transcript = job.pop("_transcript", []) - # stream_agent_loop owns its per-round message list internally. Rebuild - # continuation context from the original untrusted skill plus the - # accumulated transcript so repeated approvals do not lose earlier - # approved results, while keeping every transcript byte tainted. - messages = _skill_test_messages( - run.get("md", ""), - job.get("task", ""), + return _job_coordinator.continue_test_job( + job, key, name, decision, expected, exact_approval, + skills_manager, deps=_audit_dependencies(), ) - if transcript: - messages.append(untrusted_context_message( - "skill test transcript", - "".join(str(item) for item in transcript), - )) - messages.extend([ - { - "role": "assistant", - "content": str(expected.get("question") or "Allow this exact action once?"), - }, - { - "role": "user", - "content": ( - f"Approved the exact {exact_approval.pending.tool_name} " - "action shown above once." - ), - }, - ]) - job["status"] = "running" - job["log"].append({ - "type": "approval_granted", - "text": ( - f"Approved exact {exact_approval.pending.tool_name} action once; " - "resuming test." - ), - }) - _asyncio.create_task(_run_skill_test_job( - key, - name, - run.get("md", ""), - job.get("task", ""), - run.get("url"), - run.get("model"), - run.get("headers"), - run.get("owner"), - skills_manager, - messages=messages, - transcript=transcript, - exact_approval=exact_approval, - )) - return {"ok": True, "status": "running", "decision": "approve"} @router.get("/{skill_id}/test-status") async def test_skill_status(request: Request, skill_id: str): @@ -2225,17 +720,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: skills = skills_manager.load(owner=user) match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None) name = (match or {}).get("name", skill_id) - job = _skill_test_jobs.get((user or "", name)) - if not job: - return {"status": "none"} - return { - "status": job["status"], - "task": job.get("task"), - "model": job.get("model"), - "log": job.get("log", []), - "verdict": job.get("verdict"), - "approval": job.get("approval"), - } + return _job_coordinator.test_status(user, name, deps=_audit_dependencies()) @router.post("/audit-all") async def audit_all_skills(request: Request): @@ -2246,8 +731,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: {scope: 'drafts'|'unchecked'|'all', names?: [...], skip_audited?: bool}. Default 'all' means every visible skill, including already-published skills, so audit can publish or demote according to the confidence threshold.""" - import asyncio as _asyncio - import time as _time user = _owner(request) body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {} @@ -2270,78 +753,23 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter: except ValueError as e: raise HTTPException(400, str(e)) - skills = skills_manager.load(owner=user) - # Built-ins are tracked, pre-approved application procedures. They do - # not consume audit turns and cannot be demoted by an audit result. - auditable_skills = [s for s in skills if s.get("source") != "builtin"] - by_name = {s.get("name"): s for s in auditable_skills if s.get("name")} - if isinstance(requested_names, list): - names = [] - seen = set() - for raw in requested_names: - nm = str(raw or "").strip() - if not nm or nm in seen or nm not in by_name: - continue - if scope not in ("all", "selected") and (by_name[nm].get("status") or "draft") == "published": - continue - if skip_audited and by_name[nm].get("audit_verdict"): - continue - names.append(nm) - seen.add(nm) - scope = "selected" if requested_names else scope - elif scope == "all": - names = [ - s.get("name") for s in auditable_skills - if s.get("name") and (not skip_audited or not s.get("audit_verdict")) - ] - else: - scope = "unchecked" if scope == "drafts" else scope - names = [ - s.get("name") for s in auditable_skills - if s.get("name") - and (s.get("status") or "draft") != "published" - and not s.get("audit_verdict") - ] - if not names: - return {"ok": True, "status": "done", "total": 0, "results": [], "log": ["No skills to audit."]} - - _skill_audit_jobs[key] = { - "status": "running", "scope": scope, "model": model, - "teacher": teacher[1] if teacher else None, - "total": len(names), "done": 0, "current": None, - "results": [], "log": [f"Auditing {len(names)} skill(s) with {model}" + (f"; teacher {teacher[1]}" if teacher else "")], - "started": _time.time(), "cancel": False, - } - task = _asyncio.create_task(_run_audit_all_job(key, skills_manager, names, url, model, headers, teacher, user)) - _skill_audit_jobs[key]["task"] = task - return {"ok": True, "status": "running", "total": len(names), "model": model} + names, scope = _job_coordinator.select_audit_names( + skills_manager, user, scope, requested_names, skip_audited, + ) + return _job_coordinator.start_audit_job( + key, skills_manager, names, url, model, headers, teacher, user, + scope, deps=_audit_dependencies(), + ) @router.get("/audit-all/status") async def audit_status(request: Request): user = _owner(request) - job = _skill_audit_jobs.get((user or "",)) - if not job: - return {"status": "none"} - return { - "status": job["status"], "scope": job.get("scope"), - "total": job.get("total", 0), "done": job.get("done", 0), - "current": job.get("current"), "model": job.get("model"), "teacher": job.get("teacher"), - "results": job.get("results", []), "log": job.get("log", []), - "started": job.get("started"), "finished": job.get("finished"), - } + return _job_coordinator.audit_status(user, deps=_audit_dependencies()) @router.post("/audit-all/cancel") async def audit_cancel(request: Request): user = _owner(request) - job = _skill_audit_jobs.get((user or "",)) - if job: - job["cancel"] = True - job["status"] = "cancelled" - job["current"] = None - task = job.get("task") - if task and not task.done(): - task.cancel() - return {"ok": True, "status": "cancelled" if job else "none"} + return _job_coordinator.cancel_audit(user, deps=_audit_dependencies()) @router.post("/{skill_id}/markdown") async def save_skill_markdown(request: Request, skill_id: str): diff --git a/routes/upload_routes.py b/routes/upload_routes.py index 0978f2217..347da487e 100644 --- a/routes/upload_routes.py +++ b/routes/upload_routes.py @@ -21,7 +21,8 @@ from core.database import ( Note, Session as DbSession, ) -from src.auth_helpers import effective_user +from src.auth_helpers import effective_user, is_delegated_credential +from src.owner_identity import auth_disabled, is_request_sentinel_owner from src.attachment_refs import attachment_refs_from_metadata from src.constants import GENERATED_IMAGES_DIR from src.path_confinement import is_inside @@ -34,6 +35,8 @@ from src.upload_handler import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/upload", tags=["upload"]) +UPLOAD_READ_SCOPES = frozenset({"chat", "documents:read", "documents:write"}) +UPLOAD_WRITE_SCOPES = frozenset({"chat", "documents:write"}) UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"} def _upload_ids_from_persisted_text(value: object) -> set[str]: @@ -178,6 +181,52 @@ def setup_upload_routes(upload_handler): raise HTTPException(404, "File not found") + def _verify_upload_access( + request: Request, + file_owner: str | None, + *, + write: bool = False, + ) -> None: + if auth_disabled(): + return + + auth_mgr = getattr(request.app.state, "auth_manager", None) + if auth_mgr is None: + raise HTTPException(503, "Authentication service unavailable") + + if not getattr(auth_mgr, "is_configured", False): + raise HTTPException(401, "Setup required") + + current_user = effective_user(request) + if not current_user or is_request_sentinel_owner(current_user): + raise HTTPException(403, "Access denied") + + # Delegated credentials (bearer API tokens): + # Establish token owner identity but NEVER inherit interactive administrator privileges. + if is_delegated_credential(request): + token_owner = getattr(request.state, "api_token_owner", None) + if not token_owner or is_request_sentinel_owner(token_owner): + raise HTTPException(403, "API token has no valid owner") + + token_scopes = set(getattr(request.state, "api_token_scopes", []) or []) + required_scopes = UPLOAD_WRITE_SCOPES if write else UPLOAD_READ_SCOPES + if not token_scopes.intersection(required_scopes): + raise HTTPException(403, f"API token missing required scope: {' or '.join(sorted(required_scopes))}") + + # Non-owners and legacy ownerless uploads are strictly forbidden for delegated tokens. + if file_owner is None or current_user != file_owner: + raise HTTPException(404, "File not found") + return + + # Interactive session (cookie-authenticated human): + # Owner access + if file_owner is not None and current_user == file_owner: + return + + # Non-owner or legacy ownerless upload: administrator only + if not (hasattr(auth_mgr, "is_admin") and auth_mgr.is_admin(current_user)): + raise HTTPException(404, "File not found") + def _valid_session_id_for_owner(db, session_id: str | None, owner: str | None) -> str | None: if not session_id: return None @@ -280,6 +329,25 @@ def setup_upload_routes(upload_handler): session_id = None if not files: raise HTTPException(400, "No files uploaded") + + if not auth_disabled(): + app = getattr(request, "app", None) + if app is not None: + auth_mgr = getattr(getattr(app, "state", None), "auth_manager", None) + if auth_mgr is None: + raise HTTPException(503, "Authentication service unavailable") + if not getattr(auth_mgr, "is_configured", False): + raise HTTPException(401, "Setup required") + current_user = effective_user(request) + if not current_user or is_request_sentinel_owner(current_user): + raise HTTPException(403, "Access denied") + if is_delegated_credential(request): + token_owner = getattr(request.state, "api_token_owner", None) + if not token_owner or is_request_sentinel_owner(token_owner): + raise HTTPException(403, "API token has no valid owner") + token_scopes = set(getattr(request.state, "api_token_scopes", []) or []) + if not token_scopes.intersection(UPLOAD_WRITE_SCOPES): + raise HTTPException(403, f"API token missing required scope: {' or '.join(sorted(UPLOAD_WRITE_SCOPES))}") client_ip = request.client.host if request.client else "unknown" out = [] @@ -303,6 +371,8 @@ def setup_upload_routes(upload_handler): for u in files: try: owner = effective_user(request) + if is_request_sentinel_owner(owner): + owner = None meta = upload_handler.save_upload(u, client_ip, owner=owner) promoted_gallery_id = _promote_chat_image_to_gallery(meta, owner, session_id, gallery_id) item = { @@ -382,15 +452,8 @@ def setup_upload_routes(upload_handler): info = next((fi for fi in db.values() if fi.get("id") == file_id), None) if info: original_name = info.get("name", file_id) - auth_mgr = getattr(request.app.state, "auth_manager", None) - auth_configured = bool(auth_mgr and auth_mgr.is_configured) - current_user = effective_user(request) file_owner = info.get("owner") if info else None - if auth_configured: - if not current_user: - raise HTTPException(403, "Access denied") - if file_owner != current_user and not auth_mgr.is_admin(current_user): - raise HTTPException(404, "File not found") + _verify_upload_access(request, file_owner) path = _resolve_upload_path(file_id) mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream" from fastapi.responses import FileResponse @@ -471,15 +534,9 @@ def setup_upload_routes(upload_handler): if not upload_handler.validate_upload_id(file_id): raise HTTPException(400, "Invalid file ID") info = _load_upload_info(file_id) - auth_mgr = getattr(request.app.state, "auth_manager", None) - auth_configured = bool(auth_mgr and auth_mgr.is_configured) - current_user = effective_user(request) file_owner = info.get("owner") if info else None - if auth_configured: - if not current_user: - raise HTTPException(403, "Access denied") - if file_owner != current_user and not auth_mgr.is_admin(current_user): - raise HTTPException(404, "File not found") + _verify_upload_access(request, file_owner) + current_user = effective_user(request) path = _resolve_upload_path(file_id) import mimetypes as _mt mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "" @@ -517,15 +574,9 @@ def setup_upload_routes(upload_handler): info = _load_upload_info(file_id) if not info: raise HTTPException(404, "File not found") - auth_mgr = getattr(request.app.state, "auth_manager", None) - auth_configured = bool(auth_mgr and auth_mgr.is_configured) - current_user = effective_user(request) file_owner = info.get("owner") - if auth_configured: - if not current_user: - raise HTTPException(403, "Access denied") - if file_owner != current_user and not auth_mgr.is_admin(current_user): - raise HTTPException(404, "File not found") + _verify_upload_access(request, file_owner, write=True) + current_user = effective_user(request) _resolve_upload_path(file_id) try: body = await request.json() diff --git a/scripts/build_frontend.py b/scripts/build_frontend.py new file mode 100644 index 000000000..371715783 --- /dev/null +++ b/scripts/build_frontend.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Publish explicitly owned native ES modules; never run at application startup.""" + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] + + +def module_path(base: Path, relative: str) -> Path: + """Accept canonical JS paths only, contained within the declared tree.""" + if not isinstance(relative, str): + raise ValueError("module paths must be strings") + parts = PurePosixPath(relative) + if ( + parts.is_absolute() + or parts.as_posix() != relative + or ".." in parts.parts + or "\\" in relative + or "?" in relative + or "#" in relative + or parts.suffix != ".js" + ): + raise ValueError(f"invalid module path: {relative!r}") + path = base / relative + if not path.resolve().is_relative_to(base.resolve()): + raise ValueError(f"module path escapes its tree: {relative!r}") + if any(p.is_symlink() for p in (path, *path.parents)): + raise ValueError(f"symlink in module path: {relative!r}") + return path + + +def check_lf_attributes(root: Path, paths: set[Path]) -> None: + """Require portable, repository-owned LF attributes for every mapped path. + + Ask Git in an isolated temporary repository so global attributes, local + info/attributes and alternate Git directories cannot mask missing rules. + Copy only attribute files at ancestors of the manifest's source/output paths. + """ + git = shutil.which("git") + if git is None: + raise ValueError("Git is required for frontend publication attribute checks") + attributes = {root / ".gitattributes"} + for path in paths: + for parent in path.parents: + if parent == root: + break + attributes.add(parent / ".gitattributes") + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + relative = sorted(path.relative_to(root).as_posix() for path in paths) + with tempfile.TemporaryDirectory(prefix="ody-frontend-attributes-") as directory: + target = Path(directory) + for attribute in attributes: + if attribute.is_symlink(): + raise ValueError(f"symlink attribute file: {attribute.relative_to(root)}") + if attribute.is_file(): + copy = target / attribute.relative_to(root) + copy.parent.mkdir(parents=True, exist_ok=True) + copy.write_bytes(attribute.read_bytes()) + command = [git, "-c", f"core.attributesFile={os.devnull}", "-C", str(target)] + try: + subprocess.run(command + ["init", "--quiet", "--template="], env=env, check=True, capture_output=True, timeout=15) + result = subprocess.run( + command + ["check-attr", "-z", "--stdin", "text", "eol"], + input=("\0".join(relative) + "\0").encode("utf-8"), + env=env, check=True, capture_output=True, timeout=15, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise ValueError("Git frontend attribute check failed") from error + fields = result.stdout.decode("utf-8").split("\0")[:-1] + effective = {name: {} for name in relative} + for index in range(0, len(fields), 3): + name, attribute, value = fields[index:index + 3] + effective[name][attribute] = value + invalid = [ + f"{name} (text={values.get('text')}, eol={values.get('eol')})" + for name, values in effective.items() + if values != {"text": "set", "eol": "lf"} + ] + if invalid: + raise ValueError("manifest paths require repository-owned 'text eol=lf' Git attributes: " + ", ".join(invalid)) + + +def synchronize(root: Path = ROOT, *, check: bool = False) -> list[str]: + """Validate the entire plan before writing. Return stale deployment paths.""" + root = root.resolve() + source_root = root / "frontend" / "src" + static_root = root / "static" + manifest = json.loads((root / "frontend" / "modules.json").read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or manifest.get("version") != 1 or not isinstance(manifest.get("modules"), list): + raise ValueError("expected frontend module manifest version 1") + if not manifest["modules"]: + raise ValueError("module manifest must not be empty") + sources, outputs, plan = set(), set(), [] + for entry in manifest["modules"]: + source = module_path(source_root, entry["source"]) + output = module_path(static_root, entry["deployment"]) + if not entry["deployment"].startswith("js/"): + raise ValueError("deployment modules must be under static/js/") + if source in sources or output in outputs: + raise ValueError("each source and deployment path must be unique") + sources.add(source) + outputs.add(output) + content = source.read_bytes() + if output.exists() and not output.is_file(): + raise ValueError(f"deployment path is not a file: {output}") + plan.append((output, content)) + unlisted = set(source_root.rglob("*.js")) - sources + if unlisted: + raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}") + check_lf_attributes(root, sources | outputs) + stale = [] + for output, content in plan: + if not output.is_file() or output.read_bytes() != content: + stale.append(output.relative_to(root).as_posix()) + if not check: + for output, content in plan: + if output.relative_to(root).as_posix() in stale: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(content) + return stale + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail on invalid attributes or stale output; write no deployment files") + args = parser.parse_args() + try: + stale = synchronize(check=args.check) + except (OSError, ValueError, KeyError, TypeError) as error: + parser.exit(2, f"frontend: {error}\n") + if args.check and stale: + print("Stale frontend deployment modules: " + ", ".join(stale)) + print("Run python scripts/build_frontend.py and include the source and output changes together.") + return 1 + print(f"Frontend {'check passed' if args.check else 'published'} ({len(stale)} changed modules).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_ca_trust.py b/scripts/check_ca_trust.py new file mode 100644 index 000000000..d34fb53df --- /dev/null +++ b/scripts/check_ca_trust.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Reject the eMudhra incident's known CA certificates in PEM trust stores. + +This checks certificate contents, including OpenSSL's lazily loaded CA directory. +It does not model native OS certificate revocation or arbitrary custom clients. +Incident fingerprints: https://emudhra.we-have-a-problem.com/roots.json +and https://emudhra.we-have-a-problem.com/impacted-intermediates.json +Cross-signed issuer: https://emudhra.we-have-a-problem.com/chains.zip +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib +import importlib.util +import os +from pathlib import Path +import re +import ssl +import sys + + +BLOCKED_CERTIFICATES = { + "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3", + "d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services", + "7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3", + "f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3", + "3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (cross-signed)", + # Intermediates can themselves be operator-supplied trust anchors. Include + # both published signatures for each key, without parsing SPKI in stdlib. + "42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)", + "45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)", + "98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)", + "5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)", + "e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)", + "579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)", + "0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)", + "62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)", + "cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)", + "7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)", + "432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)", + "fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)", + "9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)", + "a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)", + "4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)", + "84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)", +} +OVERRIDE_ENV_VARS = ( + "LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", + "PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", +) + + +def check_bundle(path: Path) -> None: + """Raise on an unreadable, empty, malformed, or affected CA bundle.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(cafile=str(path)) + # A hashed OpenSSL directory can include valid non-CA certificates (for + # example Ubuntu's snakeoil server certificate). get_ca_certs() omits them; + # use the total X.509 count to distinguish these from an empty store. + if not context.cert_store_stats()["x509"]: + raise ValueError("no certificates") + certificates = context.get_ca_certs(binary_form=True) + blocked = { + BLOCKED_CERTIFICATES[fingerprint] + for certificate in certificates + if (fingerprint := hashlib.sha256(certificate).hexdigest()) in BLOCKED_CERTIFICATES + } + if blocked: + raise ValueError("affected CA certificates: " + ", ".join(sorted(blocked))) + + +def ca_directory_paths(directory: Path) -> list[Path]: + """Return the hashed certificate files consumed by OpenSSL's CApath.""" + return [ + path for path in directory.iterdir() + if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name) and path.is_file() + ] + + +def system_paths() -> list[Path]: + """Inspect both the effective OpenSSL CA file and hashed directory entries.""" + defaults = ssl.get_default_verify_paths() + paths = [Path(defaults.cafile)] if defaults.cafile else [] + # get_default_verify_paths() can report None for a colon-separated + # SSL_CERT_DIR even though OpenSSL consumes each directory in that list. + capath = os.environ.get(defaults.openssl_capath_env, defaults.capath) + if capath: + for directory in filter(None, capath.split(os.pathsep)): + paths.extend(ca_directory_paths(Path(directory))) + if not paths: + raise ValueError("no effective OpenSSL PEM CA store found") + return paths + + +def override_paths() -> list[Path]: + """Inspect every configured override, including client fallback settings.""" + paths = [] + for name in OVERRIDE_ENV_VARS: + value = os.environ.get(name, "") + if name == "LLM_CA_BUNDLE": + value = value.strip() # Match src/tls_overrides.py. + if not value: + continue + path = Path(value) + # Requests (also used by pip) accepts either a file or a hashed directory. + if name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT") and path.is_dir(): + entries = ca_directory_paths(path) + if not entries: + raise ValueError(f"{name}: no hashed certificates in {path}") + paths.extend(entries) + else: + paths.append(path) + return paths + + +def grpc_roots_path() -> Path: + """Select gRPC's override or its independent Python-wheel default bundle.""" + override = os.environ.get("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH") + if override: + # gRPC can fall back to its bundled roots when this file is unreadable. + # Check the configured file strictly instead of blessing that fallback. + return Path(override) + spec = importlib.util.find_spec("grpc") + if spec is None or spec.origin is None: + raise ImportError("grpcio is not installed; cannot check its default roots") + return Path(spec.origin).parent / "_cython" / "_credentials" / "roots.pem" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundles", nargs="*", type=Path) + parser.add_argument("--system", action="store_true", help="check effective OpenSSL PEM stores") + parser.add_argument("--python", action="store_true", help="check certifi and pip's separate vendored bundle") + parser.add_argument("--grpc", action="store_true", help="check gRPC's selected default CA file") + parser.add_argument("--overrides", action="store_true", help="check configured LLM/HTTPX/Requests/pip/Node/gRPC CA overrides") + parser.add_argument("--verbose", action="store_true", help="show the runtime identity, Python executable and selected CA paths") + args = parser.parse_args(argv) + if not (args.bundles or args.system or args.python or args.grpc or args.overrides): + parser.error("select --system, --python, --grpc, --overrides, or explicit PEM bundle paths") + if args.verbose: + print(f"Python executable: {sys.executable}") + print(f"Python prefix: {sys.prefix}") + print(f"User home: {Path.home()}") + if hasattr(os, "geteuid"): + print(f"Effective user: uid={os.geteuid()} gid={os.getegid()}") + try: + paths = list(args.bundles) + if args.system: + paths.extend(system_paths()) + if args.python: + for name in ("certifi", "pip._vendor.certifi"): + paths.append(Path(importlib.import_module(name).where())) + if args.grpc: + paths.append(grpc_roots_path()) + if args.overrides: + paths.extend(override_paths()) + paths = list(dict.fromkeys(path.resolve() for path in paths)) + if not paths: + raise ValueError("no CA bundle files selected; also select --system, --python, or an explicit path") + errors = [] + for path in paths: + if args.verbose: + print(f"CA bundle: {path}") + try: + check_bundle(path) + except (OSError, ValueError, ssl.SSLError) as exc: + errors.append(f"{path}: {exc}") + if errors: + raise ValueError("\n".join(errors)) + except (ImportError, OSError, ValueError, ssl.SSLError) as exc: + print(str(exc), file=sys.stderr) + return 1 + print(f"Validated {len(paths)} CA bundle file(s): no known eMudhra incident CA certificates") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_frontend.mjs b/scripts/check_frontend.mjs new file mode 100644 index 000000000..d9aa214a7 --- /dev/null +++ b/scripts/check_frontend.mjs @@ -0,0 +1,180 @@ +/** Architecture gates for maintained source. Legacy static modules stay under existing guards. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'acorn'; + +const layers = ['domain', 'application', 'infrastructure', 'ui']; +const platformIdentifiers = new Set(['window', 'document', 'globalThis', 'navigator', 'localStorage', 'sessionStorage', 'fetch', 'Notification', 'indexedDB', 'WebSocket', 'XMLHttpRequest', 'Worker', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'requestAnimationFrame', 'cancelAnimationFrame', 'performance', 'eval', 'Function']); + +function ownership(source) { + const parts = source.split('/'); + if (parts[0] === 'app' && parts.length >= 2) return { feature: 'app', layer: 'index' }; + if (parts[0] === 'shared') { + if (parts.length === 2) return { feature: 'shared', layer: 'domain' }; + const layer = { domain: 'domain', platform: 'infrastructure', ui: 'ui' }[parts[1]]; + if (layer) return { feature: 'shared', layer }; + } + if (parts[0] === 'features' && parts.length >= 3) { + if (['app', 'shared'].includes(parts[1])) throw new Error(`reserved feature name: ${parts[1]}`); + if (parts.length === 3 && parts[2] === 'index.js') return { feature: parts[1], layer: 'index' }; + if (layers.includes(parts[2]) && parts.length >= 4) return { feature: parts[1], layer: parts[2] }; + } + throw new Error(`unsupported source ownership: ${source}`); +} + +export function allowsDependency(from, to) { + const a = ownership(from), b = ownership(to); + if (b.feature === 'app') return a.feature === 'app'; + if (a.feature === 'app') return b.feature === 'shared' || b.layer === 'index'; + if (b.feature === 'shared') { + const shared = { domain: ['domain'], application: ['domain'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'infrastructure', 'ui'], index: layers }; + return shared[a.layer].includes(b.layer); + } + if (a.feature === 'shared') return false; + if (a.feature !== b.feature) return ['application', 'index'].includes(a.layer) && b.layer === 'index'; + const internal = { domain: ['domain'], application: ['domain', 'application'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'application', 'ui'], index: layers }; + return internal[a.layer].includes(b.layer); +} + +export function imports(source, { domain = false } = {}) { + const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true }); + const found = []; + function visit(node, parent, field) { + if (!node || typeof node !== 'object') return; + // Domain modules use explicit values/ports, including an injected clock. + // Keep platform vocabulary out even when a local binding shadows a global. + const propertyName = parent && !parent.computed && ( + (parent.type === 'MemberExpression' && field === 'property') || + (['Property', 'MethodDefinition'].includes(parent.type) && field === 'key' && !parent.shorthand) + ); + if (domain && !propertyName && node.type === 'Identifier' && platformIdentifiers.has(node.name)) { + throw new Error(`platform identifier in domain at line ${node.loc.start.line}: ${node.name}`); + } + if (domain && ((node.type === 'MemberExpression' && node.object.name === 'Date' && (node.property.name === 'now' || node.property.value === 'now')) || (['CallExpression', 'NewExpression'].includes(node.type) && node.callee.name === 'Date' && !node.arguments.length))) { + throw new Error(`ambient clock in domain at line ${node.loc.start.line}; inject time`); + } + if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(node.type) && node.source) { + if (node.source.type !== 'Literal' || typeof node.source.value !== 'string') { + throw new Error(`computed import at line ${node.loc.start.line}; use explicit import thunks`); + } + found.push({ specifier: node.source.value, line: node.loc.start.line, dynamic: node.type === 'ImportExpression' }); + } + for (const [key, value] of Object.entries(node)) { + if (Array.isArray(value)) value.forEach(item => visit(item, node, key)); + else if (value && typeof value === 'object' && value.type) visit(value, node, key); + } + } + visit(ast); + return found; +} + +export function checkArchitecture(root) { + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json'), 'utf8')); + const bySource = new Map(manifest.modules.map(m => [m.source, m])); + const byOutput = new Map(manifest.modules.map(m => [`/static/${m.deployment}`, m.source])); + const graph = new Map(); + const errors = []; + const sw = fs.readFileSync(path.join(root, 'static/sw.js'), 'utf8'); + // Parse the actual manifest arrays, not comments or unrelated strings. + const swAst = parse(sw, { ecmaVersion: 'latest', sourceType: 'script' }); + const precached = new Set(); + for (const statement of swAst.body) { + for (const declaration of statement.declarations || []) { + if (['PRECACHE', 'PANEL_PRECACHE'].includes(declaration.id.name)) { + for (const item of declaration.init.elements) { + if (item?.type === 'Literal' && typeof item.value === 'string') precached.add(item.value); + } + } + } + } + for (const module of manifest.modules) { + try { + const owner = ownership(module.source); + const deployed = `/static/${module.deployment}`; + if (!precached.has(deployed)) throw new Error(`deployment URL absent from explicit precache: ${deployed}`); + const dependencies = []; + const exceptions = module.legacyImports || []; + const used = new Set(); + for (const { specifier, line } of imports(fs.readFileSync(path.join(root, 'frontend/src', module.source), 'utf8'), { domain: owner.layer === 'domain' })) { + const fail = message => { throw new Error(`line ${line}: ${message}: ${specifier}`); }; + const runtime = new URL(specifier, `https://odysseus.invalid${deployed}`); + if (!runtime.pathname.startsWith('/static/js/') || !runtime.pathname.endsWith('.js') || runtime.pathname.includes('%')) fail('runtime imports must name canonical static JS paths'); + let target; + if (specifier.startsWith('.')) { + if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity'); + target = path.posix.normalize(path.posix.join(path.posix.dirname(module.source), specifier)); + if (!bySource.has(target)) fail('relative import must name maintained source'); + if (runtime.pathname !== `/static/${bySource.get(target).deployment}`) fail('source and deployment relative paths resolve differently'); + } else if (specifier.startsWith('/static/')) { + target = byOutput.get(runtime.pathname); + if (!target) { + const exception = exceptions.find(e => e.specifier === specifier && e.reason?.trim()); + if (!exception || !['index', 'infrastructure', 'ui'].includes(owner.layer) || owner.feature === 'shared') fail('legacy import requires an explicit module-scoped rationale at a composition, infrastructure or UI boundary'); + if (!fs.statSync(path.join(root, runtime.pathname.slice(1))).isFile()) fail('legacy target does not exist'); + used.add(exception); + continue; + } + } else fail('bare, external and source-tree runtime imports are unsupported'); + if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity'); + if (!allowsDependency(module.source, target)) fail(`forbidden dependency on ${target}`); + dependencies.push(target); + } + if (exceptions.some(e => !used.has(e))) throw new Error('unused legacy import exception'); + graph.set(module.source, dependencies); + } catch (error) { errors.push(`${module.source}: ${error.message}`); } + } + const visiting = new Set(), visited = new Set(); + function visit(source, trail = []) { + if (visiting.has(source)) throw new Error(`maintained-source dependency cycle: ${[...trail, source].join(' -> ')}`); + if (visited.has(source)) return; + visiting.add(source); + for (const target of graph.get(source) || []) visit(target, [...trail, source]); + visiting.delete(source); + visited.add(source); + } + try { for (const source of graph.keys()) visit(source); } + catch (error) { errors.push(error.message); } + if (errors.length) throw new Error(errors.join('\n')); + return graph.size; +} + +export function inventory(root) { + const staticRoot = path.join(root, 'static'); + const files = fs.readdirSync(staticRoot, { recursive: true }).map(p => p.split(path.sep).join('/')).filter(p => p.endsWith('.js') && !p.startsWith('lib/') && !p.startsWith('js/editor/build/')).sort(); + const graph = new Map(), fan = new Map(), missing = []; + let staticImports = 0, dynamicLiteralImports = 0; + for (const file of files) { + const targets = []; + for (const { specifier, dynamic } of imports(fs.readFileSync(path.join(staticRoot, file), 'utf8'))) { + if (!specifier.startsWith('.') && !specifier.startsWith('/static/')) continue; + const target = new URL(specifier, `https://odysseus.invalid/static/${file}`).pathname.slice('/static/'.length); + if (dynamic) dynamicLiteralImports++; + else { staticImports++; targets.push(target); } + fan.set(target, (fan.get(target) || 0) + 1); + if (!fs.existsSync(path.join(staticRoot, target))) missing.push({ file, specifier }); + } + graph.set(file, targets); + } + const indices = new Map(), low = new Map(), stack = [], active = new Set(), staticCycles = []; + function visit(v) { + indices.set(v, indices.size); low.set(v, indices.get(v)); stack.push(v); active.add(v); + for (const w of graph.get(v) || []) { + if (!indices.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); } + else if (active.has(w)) low.set(v, Math.min(low.get(v), indices.get(w))); + } + if (low.get(v) === indices.get(v)) { + const component = []; let w; + do { w = stack.pop(); active.delete(w); component.push(w); } while (w !== v); + if (component.length > 1) staticCycles.push(component.sort()); + } + } + for (const file of files) if (!indices.has(file)) visit(file); + return { files: files.length, staticImports, dynamicLiteralImports, missing, fanIn: [...fan].sort((a, b) => b[1] - a[1]).slice(0, 8), staticCycles }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + try { console.log(process.argv.includes('--inventory') ? JSON.stringify(inventory(root), null, 2) : `Frontend architecture passed (${checkArchitecture(root)} maintained modules).`); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/verify_frontend_runtime.py b/scripts/verify_frontend_runtime.py new file mode 100644 index 000000000..2a960c32a --- /dev/null +++ b/scripts/verify_frontend_runtime.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Verify the current Python runtime from an isolated distribution without frontend tooling.""" +import os +import json +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + +PROBE = """ +from pathlib import Path +import shutil +import json +import sys +from starlette.testclient import TestClient +from app import app +assert not Path('frontend').exists() +assert not Path('node_modules').exists() +assert shutil.which('node') is None +with TestClient(app) as client: + for output in json.loads(sys.argv[1]): + asset = client.get('/static/' + output) + assert asset.status_code == 200, (output, asset.status_code) + assert asset.content == Path('static', output).read_bytes(), output + assert asset.headers['content-type'].startswith('text/javascript'), output + assert asset.headers['cache-control'] == 'no-cache', output + response = client.get('/static/js/actionMenuOrder.js') + assert response.status_code == 200, response.status_code + assert response.content == Path('static/js/actionMenuOrder.js').read_bytes() + assert response.headers['content-type'].startswith('text/javascript'), response.headers + assert response.headers['cache-control'] == 'no-cache', response.headers + assert "script-src 'self' 'nonce-" in response.headers['content-security-policy'], response.headers + assert client.get('/frontend/src/shared/actionMenuOrder.js').status_code == 404 + assert client.get('/static/%2e%2e/app.py').status_code == 404 + shell = client.get('/') + assert shell.status_code == 200, shell.status_code + assert '{{CSP_NONCE}}' not in shell.text + assert '/static/app.js' in shell.text +print('PASS: isolated Python lifespan, shell, deployed bytes, MIME, revalidation, CSP and source-tree 404') +""" + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="ody-f1-distribution-") as directory, socket.socket() as chroma_guard: + target = Path(directory) + for name in ("app.py", "HARNESS_VERSION"): + shutil.copy2(ROOT / name, target / name) + for name in ("core", "routes", "src", "services", "resources", "mcp_servers", "companion", "config", "static", "scripts"): + shutil.copytree(ROOT / name, target / name, ignore=shutil.ignore_patterns("__pycache__", "node_modules", ".env", "secrets.env*", "*.db", "*.sqlite*")) + # Reserve a non-listening port so initialization cannot reach a real store. + chroma_guard.bind(("127.0.0.1", 0)) + env = dict(os.environ) + env.update( + ODYSSEUS_DATA_DIR=str(target / "data"), + DATABASE_URL="sqlite:///:memory:", + AUTH_ENABLED="false", + ODYSSEUS_STARTUP_WARMUPS="0", + ODYSSEUS_TOOL_INDEX_PREWARM="0", + CHROMADB_HOST="127.0.0.1", + CHROMADB_PORT=str(chroma_guard.getsockname()[1]), + FASTEMBED_CACHE_PATH=str(target / "fastembed"), + PATH=str(target / "no-development-tools"), + ) + # A caller's Python search path must not import the original checkout. + env.pop("PYTHONPATH", None) + outputs = [entry['deployment'] for entry in json.loads( + (ROOT / 'frontend/modules.json').read_text(encoding='utf-8') + )['modules']] + result = subprocess.run([sys.executable, "-c", PROBE, json.dumps(outputs)], cwd=target, env=env, capture_output=True, text=True, timeout=60) + print(result.stdout, end="") + if result.returncode: + print(result.stderr[-8000:], file=sys.stderr) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_notes_browser_runtime.py b/scripts/verify_notes_browser_runtime.py new file mode 100644 index 000000000..5fedbaf33 --- /dev/null +++ b/scripts/verify_notes_browser_runtime.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Run real Notes UI and API against a disposable native distribution and database.""" +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from urllib.request import urlopen + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + with tempfile.TemporaryDirectory(prefix="ody-notes-native-") as directory, socket.socket() as chroma_guard: + target = Path(directory) + for name in ("app.py", "HARNESS_VERSION"): + shutil.copy2(ROOT / name, target / name) + for name in ("core", "routes", "src", "services", "resources", "mcp_servers", "companion", "config", "static"): + shutil.copytree(ROOT / name, target / name, ignore=shutil.ignore_patterns("__pycache__", ".env", "secrets.env*", "*.db", "*.sqlite*")) + chroma_guard.bind(("127.0.0.1", 0)) + with socket.socket() as app_port: + app_port.bind(("127.0.0.1", 0)) + port = app_port.getsockname()[1] + env = dict(os.environ) + env.update( + ODYSSEUS_DATA_DIR=str(target / "data"), + DATABASE_URL="sqlite:///" + str(target / "notes.db"), AUTH_ENABLED="true", + ODYSSEUS_STARTUP_WARMUPS="0", ODYSSEUS_TOOL_INDEX_PREWARM="0", + CHROMADB_HOST="127.0.0.1", CHROMADB_PORT=str(chroma_guard.getsockname()[1]), + FASTEMBED_CACHE_PATH=str(target / "fastembed"), + ) + env.pop("PYTHONPATH", None) + server = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)], + cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + result = None + try: + origin = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if server.poll() is not None: + raise RuntimeError("Disposable Notes server exited during startup") + try: + with urlopen(origin + "/", timeout=1) as response: + if response.status == 200: + break + except OSError: + time.sleep(0.1) + else: + raise RuntimeError("Disposable Notes server did not become ready") + result = subprocess.run( + ["node", "tests/browser/notesNative.mjs"], cwd=ROOT, + env=dict(os.environ, ODYSSEUS_NOTES_RUNTIME_ORIGIN=origin, NOTES_TEST_PYTHON=sys.executable), + capture_output=True, text=True, timeout=120, + ) + print(result.stdout, end="") + if result.returncode: + print(result.stderr, file=sys.stderr) + return result.returncode + finally: + server.terminate() + try: + logs, _ = server.communicate(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + logs, _ = server.communicate() + if result is None or result.returncode: + print(logs[-8000:], file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/memory/memory_extractor.py b/services/memory/memory_extractor.py index 7d056cb40..6b911b4d5 100644 --- a/services/memory/memory_extractor.py +++ b/services/memory/memory_extractor.py @@ -331,6 +331,7 @@ async def extract_and_store( try: from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call # Get last N messages from session messages = session.get_context_messages() @@ -404,6 +405,9 @@ async def extract_and_store( # enough once thinking has room. max_tokens=4096, headers=headers, + reasoning_effort=effort_for_call( + endpoint_url, model, getattr(session, "owner", None), session + ), ) # Parse JSON, tolerating reasoning-model noise ( blocks, a @@ -559,6 +563,7 @@ async def update_persona_memory( try: from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call from src.text_helpers import strip_think custom = {} @@ -607,6 +612,9 @@ async def update_persona_memory( temperature=0.1, max_tokens=1200, headers=headers, + reasoning_effort=effort_for_call( + endpoint_url, model, getattr(session, "owner", None), session + ), ) updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip() @@ -643,6 +651,7 @@ async def audit_memories( """ try: from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call existing = memory_manager.load(owner=owner) if not existing: @@ -691,6 +700,7 @@ async def audit_memories( # Bound the call so the Tidy whirlpool can't spin indefinitely on a # slow/large generation. timeout=120, + reasoning_effort=effort_for_call(endpoint_url, model, owner), ) # Parse the JSON list, tolerating reasoning-model noise: blocks, diff --git a/services/memory/skill_extractor.py b/services/memory/skill_extractor.py index 18cc9014e..49e423706 100644 --- a/services/memory/skill_extractor.py +++ b/services/memory/skill_extractor.py @@ -149,6 +149,7 @@ async def maybe_extract_skill( try: from src.llm_core import llm_call_async + from src.utility_effort import effort_for_call # Get recent messages history = session.get_context_messages() @@ -204,6 +205,7 @@ async def maybe_extract_skill( ], headers=headers, timeout=30, + reasoning_effort=effort_for_call(endpoint_url, model, owner, session), ) logger.debug( "[skill-extract] LLM returned in %.1fs (len=%d, head=%r)", diff --git a/services/memory/skills.py b/services/memory/skills.py index 713ae2199..2d0ad7063 100644 --- a/services/memory/skills.py +++ b/services/memory/skills.py @@ -674,6 +674,7 @@ class SkillsManager: *, active_toolsets: Optional[List[str]] = None, platform: Optional[str] = None, + local_model: Optional[bool] = None, ) -> List[Dict]: """Return the `[{name, description, category, status}]` list the agent sees in its system prompt. @@ -710,6 +711,11 @@ class SkillsManager: # Platform gating if platform and s.get("platforms") and platform not in s["platforms"]: continue + # remote-only tag: hide from local/private-endpoint models. Big + # on-demand reference lookups overwhelm small local contexts. + # local_model=None (API listings, UI) never gates. + if local_model and "remote-only" in (s.get("tags") or []): + continue # requires_toolsets: hide unless every required toolset is active. # active_toolsets=None means the caller doesn't know the active # set (API listings, chat preface) — don't gate in that case; @@ -745,6 +751,7 @@ class SkillsManager: min_confidence: float = 0.0, available_toolsets: Optional[Iterable[str]] = None, platform: Optional[str] = None, + published_only: bool = False, ) -> List[Dict]: if skills is None: skills = self.load_all() @@ -775,9 +782,15 @@ class SkillsManager: if not skill.get("platforms") or platform in skill.get("platforms", []) ] # Prompt injection is fail-closed for user skills. Built-ins are - # shipped procedures; every other skill needs a passing audit and a - # confidence score at the user's current threshold. - if min_confidence > 0: + # shipped procedures. With Auto-approve OFF (published_only), the + # user's publish is the approval; otherwise a skill needs a passing + # audit and a confidence score at the user's current threshold. + if published_only: + skills = [ + s for s in skills + if s.get("source") == "builtin" or s.get("status") == "published" + ] + elif min_confidence > 0: def _passes(s): if s.get("source") == "builtin": return True diff --git a/services/skills/__init__.py b/services/skills/__init__.py new file mode 100644 index 000000000..1b6236117 --- /dev/null +++ b/services/skills/__init__.py @@ -0,0 +1 @@ +"""Skills domain operations; persistence and lifecycle remain in SkillsManager.""" diff --git a/services/skills/agent_runner.py b/services/skills/agent_runner.py new file mode 100644 index 000000000..bf81860f1 --- /dev/null +++ b/services/skills/agent_runner.py @@ -0,0 +1,308 @@ +"""Agent execution, transcripts and same-task stream cleanup.""" + +import logging +from contextlib import aclosing +from typing import Optional + +from .dependencies import AuditDependencies + +logger = logging.getLogger(__name__) + +_skill_test_jobs: dict = {} + + +class SkillAuditUnavailable(RuntimeError): + """The test infrastructure failed; this is not evidence about a skill.""" + + +async def run_test_job( + key, + name, + md, + task, + url, + model, + headers, + owner, + skills_manager=None, + *, + messages=None, + transcript=None, + exact_approval=None, + request_authority=None, + deps: AuditDependencies, +): + """Background coroutine: run the skill in an agent loop, capture a condensed + log + transcript, then have the judge grade it. Writes into deps.test_jobs.""" + import json as _json + from src.agent_loop import stream_agent_loop + from src.agent_runtime.authority import RequestAuthority + + job = deps.test_jobs.get(key) + if job is None: + return + log = job["log"] + transcript = transcript if isinstance(transcript, list) else [] + say_buf = [] + skill_stats = {"turns": 0, "tool_calls": 0} + + def _flush_say(): + if say_buf: + log.append({"type": "say", "text": "".join(say_buf)}) + say_buf.clear() + + messages = list(messages) if isinstance(messages, list) else deps.test_messages(md, task) + try: + # aclosing: pausing for an approval leaves this loop early, and the + # stream's ContextVar cleanup must run in this task, not in asyncio's + # finalizer (#6635). + async with aclosing(stream_agent_loop( + url, model, messages, headers=headers, + temperature=0.3, max_tokens=0, max_rounds=8, owner=owner, + exact_approval=exact_approval, + request_authority=(request_authority or ( + exact_approval.pending.request_authority if exact_approval is not None else None + ) or RequestAuthority.empty(owner=owner)), + )) as stream: + async for chunk in stream: + if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]": + continue + try: + d = _json.loads(chunk[6:]) + except Exception: + continue + if d.get("delta"): + say_buf.append(d["delta"]); transcript.append(d["delta"]) + elif d.get("type") == "tool_start": + _flush_say() + skill_stats["tool_calls"] += 1 + cmd = str(d.get("command") or d.get("args") or "")[:300] + log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd}) + transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n") + elif d.get("type") == "tool_output": + _flush_say() + out = str(d.get("output") or "")[:600] + tool_log = {"type": "tool_output", "output": out} + approval = d.get("ask_user") + if isinstance(approval, dict): + tool_log["ask_user"] = approval + log.append(tool_log) + transcript.append(f"[output] {out}\n") + if ( + isinstance(approval, dict) + and approval.get("kind") == "tool_approval" + and approval.get("approval_id") + ): + # Manual skill tests have their own polling UI instead of a + # chat session. Pause the run and retain only server-side + # continuation state until the same owner approves/denies + # this exact sealed action. + job["status"] = "awaiting_approval" + job["approval"] = approval + job["_transcript"] = transcript + return + elif d.get("type") == "agent_step": + _flush_say() + try: + skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0)) + except (TypeError, ValueError): + pass + log.append({"type": "agent_step", "round": d.get("round")}) + transcript.append(f"\n--- round {d.get('round')} ---\n") + elif d.get("type") == "metrics": + data = d.get("data") or {} + try: + skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0)) + except (TypeError, ValueError): + pass + try: + skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0)) + except (TypeError, ValueError): + pass + if len(log) > 600: + del log[0:len(log) - 600] + _flush_say() + except Exception as e: + _flush_say() + log.append({"type": "error", "error": str(e)}) + + job.pop("approval", None) + job.pop("_transcript", None) + job.pop("_run", None) + log.append({"type": "evaluating"}) + try: + baseline_task = task + log.append({"type": "agent_step", "round": "baseline"}) + baseline_transcript, baseline_stats, baseline_approval = await deps.run_arm( + deps.baseline_messages(baseline_task), + url, + model, + headers, + owner, + ) + if baseline_approval is not None: + try: + from src.tool_approvals import tool_approval_store + tool_approval_store.consume( + baseline_approval.get("approval_id"), + decision="deny", + owner=owner, + session_id=None, + ) + except Exception: + logger.debug("Could not retire manual-test baseline approval", exc_info=True) + job["verdict"] = await deps.evaluate_run( + md, + task, + "".join(transcript), + url, + model, + headers, + baseline_transcript=baseline_transcript, + skill_stats=skill_stats, + baseline_stats=baseline_stats, + owner=owner, + ) + except Exception as e: + job["verdict"] = {"verdict": "unknown", "confidence": 0, "summary": f"Eval failed: {e}", "issues": []} + # Record the result so the card shows a 'verified' check (a manual test + # never involves the teacher) and nudge the confidence score to match the + # verdict — same scale as Audit-all's pass=0.95. inconclusive/unknown leave + # the score alone (missing-fixture or parse failures shouldn't punish it). + if skills_manager is not None: + v = (job["verdict"] or {}).get("verdict") or "unknown" + try: + skills_manager.set_audit( + name, + v, + by_teacher=False, + worker_model=model, + owner=owner, + **deps.verdict_efficiency(job.get("verdict")), + ) + except Exception: + pass + conf = {"pass": 0.95, "needs_work": 0.6, "fail": 0.4}.get(v) + if conf is not None: + try: + skills_manager.update_skill(name, {"confidence": conf}, owner=owner) + except Exception: + pass + job["status"] = "done" + + +async def run_audit_arm(messages: list[dict], url, model, headers, owner, + workload: str = "foreground") -> tuple[str, dict, Optional[dict]]: + """Run one audit arm in the agent loop; return transcript, stats, approval.""" + import json as _json + from src.agent_loop import stream_agent_loop + from src.agent_runtime.authority import RequestAuthority, active_request_authority + transcript = [] + approval_required = None + stats = {"turns": 0, "tool_calls": 0} + try: + # max_tokens explicitly set: passing 0 lets some upstreams (Ollama, + # OpenAI-compat) generate an empty completion, which manifested as + # the skill test returning nothing while chat (which carries its + # preset's max_tokens) worked. 4096 matches the chat default. + # aclosing: stopping for an approval leaves this loop early (#6635). + async with aclosing(stream_agent_loop( + url, model, messages, headers=headers, + temperature=0.3, max_tokens=4096, max_rounds=8, + owner=owner, workload=workload, suppress_skills=True, + request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)), + )) as stream: + async for chunk in stream: + # Streams can include an SSE event line before the data line, + # notably `event: error`. Do not silently discard those failures. + payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None) + if payload is None or payload == "[DONE]": + continue + try: + d = _json.loads(payload) + except Exception: + continue + if d.get("error") or d.get("type") == "error": + raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed")) + if d.get("delta"): + transcript.append(d["delta"]) + elif d.get("type") == "tool_start": + stats["tool_calls"] += 1 + transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n") + elif d.get("type") == "tool_output": + transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n") + approval = d.get("ask_user") + if ( + isinstance(approval, dict) + and approval.get("kind") == "tool_approval" + ): + approval_required = approval + break + elif d.get("type") == "agent_step": + try: + stats["turns"] = max(stats["turns"], int(d.get("round") or 0)) + except (TypeError, ValueError): + pass + transcript.append(f"\n--- round {d.get('round')} ---\n") + elif d.get("type") == "metrics": + data = d.get("data") or {} + try: + stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0)) + except (TypeError, ValueError): + pass + try: + stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0)) + except (TypeError, ValueError): + pass + except SkillAuditUnavailable: + raise + except Exception as e: + raise SkillAuditUnavailable(str(e)) from e + return "".join(transcript), stats, approval_required + + +async def run_test_once(md: str, task: str, url, model, headers, owner, + workload: str = "foreground", *, deps: AuditDependencies) -> tuple: + """Run the skill once in the agent loop; return (transcript, verdict).""" + messages = deps.test_messages(md, task) + text, stats, approval_required = await deps.run_arm( + messages, url, model, headers, owner, workload=workload, + ) + if approval_required is not None: + # Unattended audits have no authority to approve and no UI that could + # resume this record. Destructively deny it now instead of leaving a + # reusable opaque grant pending until TTL/cap eviction. + try: + from src.tool_approvals import tool_approval_store + tool_approval_store.consume( + approval_required.get("approval_id"), + decision="deny", + owner=owner, + session_id=None, + ) + except Exception: + logger.debug("Could not retire unattended skill approval", exc_info=True) + return text, { + "verdict": "inconclusive", + "confidence": 1.0, + "summary": ( + "This automated audit reached an exact action that requires " + "a human approval; no action was executed." + ), + "issues": [ + "Run this skill's manual test and review the sealed action." + ], + "approval_required": True, + } + verdict = await deps.evaluate_run( + md, + task, + text, + url, + model, + headers, + skill_stats=stats, + workload=workload, + owner=owner, + ) + return text, verdict diff --git a/services/skills/audit_workflow.py b/services/skills/audit_workflow.py new file mode 100644 index 000000000..f7af6682f --- /dev/null +++ b/services/skills/audit_workflow.py @@ -0,0 +1,439 @@ +"""Sequential skill audit coordination and transient owner-scoped jobs.""" + +import logging +from typing import Optional + +from services.memory.skills import SkillsManager +from .dependencies import AuditDependencies + +from .agent_runner import SkillAuditUnavailable + +logger = logging.getLogger(__name__) + +_skill_audit_jobs: dict = {} + + +async def audit_one_skill(skills_manager, skill, url, model, headers, + teacher, owner, log, workload: str = "foreground", *, deps: AuditDependencies) -> dict: + """Test → judge → self-edit+retry → (teacher edit+retry) → flag. Never deletes; + a skill the teacher still can't fix is demoted to draft for manual review. + `teacher` is (url, model, headers) or None. `log(msg)` records progress.""" + name = skill.get("name") + + # Reflect the audit outcome in the skill's confidence so the main list + # updates: a clean pass earns high confidence; a pass that needed fixing + # earns a bit less; a skill that still fails is marked low. + def _set_conf(c): + try: + skills_manager.update_skill(name, {"confidence": c}, owner=owner) + except Exception: + pass + + md = skills_manager.read_skill_md(name, owner=owner) + if not md: + log(f"{name}: no source — skipped") + return {"skill": name, "result": "skipped"} + + # Cheap deterministic cleanup first. If this is an obvious lower-priority + # duplicate, do not spend LLM turns on necessity, retrieval precision, + # skill-vs-baseline testing, self-edit, or teacher escalation. + duplicate_of = deps.duplicate_blocker(skills_manager, name, owner) + if duplicate_of: + reason = f"Lower-priority duplicate of {duplicate_of}" + try: + skills_manager.update_skill(name, {"status": "draft", "confidence": 0.35}, owner=owner) + skills_manager.set_audit(name, "skipped", by_teacher=False, worker_model=model, owner=owner) + skills_manager.set_necessity(name, False, [duplicate_of], reason, owner=owner) + except Exception: + pass + log(f"{name}: draft — skipped audit ({reason[:100]})") + return {"skill": name, "result": "skipped_duplicate", "reason": reason, "confidence": 0.35, "status": "draft"} + + # Advisory necessity/redundancy check — runs once, independent of the test + # outcome, and only records a flag the UI surfaces (never deletes/demotes). + others = [] + nec = None + try: + # Only compare against skills the SAME owner can see, so the necessity + # judge never sees (or flags "redundant_with") another user's skills. + sk_owner = skill.get("owner") + others = [ + {"name": s.get("name"), "description": s.get("description", "")} + for s in skills_manager.load(owner=owner) + if s.get("name") and s.get("name") != name + and (not sk_owner or not s.get("owner") or s.get("owner") == sk_owner) + ] + nec = await deps.evaluate_necessity( + md, others, url, model, headers, workload=workload, + owner=owner, + ) + if nec is not None: + skills_manager.set_necessity(name, nec.get("necessary", True), + nec.get("redundant_with"), nec.get("reason"), + owner=owner) + if not nec.get("necessary", True): + log(f"{name}: possibly unnecessary — {nec.get('reason', '')[:80]}") + except Exception as e: + log(f"{name}: necessity check skipped — {e}") + + # Retrieval precision check: if broad tags/trigger text would make this + # narrow skill over-inject, fix only metadata before the functional test. + try: + if deps.should_check_retrieval_precision(skill): + rp = await deps.evaluate_retrieval_precision( + md, others, url, model, headers, workload=workload, + owner=owner, + ) + if rp and not rp.get("ok"): + issues = rp.get("issues") or ["metadata: retrieval: narrow tags and when_to_use to the intended trigger"] + log(f"{name}: narrowing retrieval metadata — {(rp.get('summary') or issues[0])[:80]}") + fixed = await deps.improve_markdown(md, { + "verdict": "pass", + "confidence": 1.0, + "summary": rp.get("summary") or "Retrieval metadata is too broad.", + "issues": issues, + }, "Retrieval audit only: the procedure may work, but matching metadata is too broad.", + url, model, headers, workload=workload, owner=owner) + if fixed and fixed.strip() != md.strip() and deps.apply_markdown(skills_manager, name, fixed, owner): + md = fixed + refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) + if refreshed: + skill = refreshed + except Exception as e: + log(f"{name}: retrieval precision check skipped — {e}") + + task = deps.test_task(skill) + log(f"{name}: testing…") + skill_messages = deps.test_messages(md, task) + transcript, skill_stats, approval_required = await deps.run_arm( + skill_messages, + url, + model, + headers, + owner, + workload=workload, + ) + if approval_required is not None: + try: + from src.tool_approvals import tool_approval_store + tool_approval_store.consume( + approval_required.get("approval_id"), + decision="deny", + owner=owner, + session_id=None, + ) + except Exception: + logger.debug("Could not retire unattended skill approval", exc_info=True) + verdict = { + "verdict": "inconclusive", + "confidence": 1.0, + "summary": ( + "This automated audit reached an exact action that requires " + "a human approval; no action was executed." + ), + "issues": [ + "Run this skill's manual test and review the sealed action." + ], + "approval_required": True, + } + else: + baseline_task = task + log(f"{name}: running no-skill baseline…") + baseline_transcript, baseline_stats, baseline_approval = await deps.run_arm( + deps.baseline_messages(baseline_task), + url, + model, + headers, + owner, + workload=workload, + ) + if baseline_approval is not None: + try: + from src.tool_approvals import tool_approval_store + tool_approval_store.consume( + baseline_approval.get("approval_id"), + decision="deny", + owner=owner, + session_id=None, + ) + except Exception: + logger.debug("Could not retire unattended baseline approval", exc_info=True) + verdict = await deps.evaluate_run( + md, + task, + transcript, + url, + model, + headers, + baseline_transcript=baseline_transcript, + skill_stats=skill_stats, + baseline_stats=baseline_stats, + workload=workload, + owner=owner, + ) + v = verdict.get("verdict") + log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})") + if verdict.get("approval_required"): + # An unattended audit is not authority for an action influenced by the + # skill under test. Preserve the skill's current publication/confidence + # state and route the exact action to the manual test UI instead of + # letting a safety pause demote, rewrite, or auto-publish the skill. + skills_manager.set_audit( + name, + "inconclusive", + by_teacher=False, + worker_model=model, + owner=owner, + audit_summary=verdict.get("summary") or "The test requires approval for an external action.", + ) + status = skill.get("status") or "draft" + log(f"{name}: {status} unchanged — exact action needs manual approval") + return { + "skill": name, + "result": "approval_required", + "verdict": verdict, + "status": status, + } + if v == "pass": + # Procedure works. If the reviewer still flagged metadata (tags/category/ + # when_to_use/description), do ONE fixer pass to correct the frontmatter + # without re-testing — a metadata-only fix can't break a passing run. + meta_issues = [i for i in (verdict.get("issues") or []) if str(i).lower().lstrip().startswith("metadata:")] + if meta_issues: + log(f"{name}: pass, but fixing {len(meta_issues)} metadata issue(s)…") + fixed = await deps.improve_markdown( + md, verdict, transcript, url, model, headers, workload=workload, + owner=owner, + ) + if fixed and fixed.strip() != md.strip(): + deps.apply_markdown(skills_manager, name, fixed, owner) + _set_conf(0.95) + skills_manager.set_audit( + name, + "pass", + by_teacher=False, + worker_model=model, + owner=owner, + **deps.verdict_efficiency(verdict), + ) + refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) + status = deps.finalize_status(skills_manager, name, owner, "pass", 0.95, (refreshed or {}).get("necessity"), verdict) + log(f"{name}: {status} — confidence 95%") + return {"skill": name, "result": "pass", "verdict": verdict, "confidence": 0.95, "status": status} + if v in ("unknown", "inconclusive"): + skills_manager.set_audit( + name, + "inconclusive", + by_teacher=False, + worker_model=model, + owner=owner, + **deps.verdict_efficiency(verdict), + ) + status = deps.finalize_status(skills_manager, name, owner, "inconclusive", skill.get("confidence") or 0.0, skill.get("necessity")) + log(f"{name}: {status} — inconclusive") + return {"skill": name, "result": "inconclusive", "verdict": verdict, "status": status} + + # Self-edit + retry. + log(f"{name}: self-editing to fix issues…") + new_md = await deps.improve_markdown( + md, verdict, transcript, url, model, headers, workload=workload, + owner=owner, + ) + if new_md and new_md.strip() != md.strip() and deps.apply_markdown(skills_manager, name, new_md, owner): + md = new_md + transcript, verdict = await deps.run_test_once( + md, task, url, model, headers, owner, workload=workload, + ) + v = verdict.get("verdict") + log(f"{name}: retry (self) = {v}") + if v == "pass": + _set_conf(0.85) + skills_manager.set_audit( + name, + "pass", + by_teacher=False, + worker_model=model, + owner=owner, + **deps.verdict_efficiency(verdict), + ) + refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) + status = deps.finalize_status(skills_manager, name, owner, "pass", 0.85, (refreshed or {}).get("necessity"), verdict) + log(f"{name}: {status} — confidence 85% after self-edit") + return {"skill": name, "result": "pass_after_self_edit", "verdict": verdict, "confidence": 0.85, "status": status} + + # Teacher escalation (if a distinct teacher model is configured). The + # teacher only REWRITES the skill — it does NOT run the test. The point is + # to verify the regular (student) model can now succeed with the teacher's + # improved procedure, so the retry runs on the worker model, not the teacher. + teacher_ran = False + if teacher and teacher[0] and teacher[1] and (teacher[1] != model or teacher[0] != url): + teacher_ran = True + t_url, t_model, t_headers = teacher + log(f"{name}: teacher {t_model} rewriting the skill…") + t_md = await deps.improve_markdown( + md, verdict, transcript, t_url, t_model, t_headers, workload=workload, + owner=owner, + ) + if t_md and t_md.strip() != md.strip() and deps.apply_markdown(skills_manager, name, t_md, owner): + md = t_md + # Re-test with the STUDENT model (the model the skill runs under in use). + transcript, verdict = await deps.run_test_once( + md, task, url, model, headers, owner, workload=workload, + ) + v = verdict.get("verdict") + log(f"{name}: retry on student after teacher rewrite = {v}") + if v == "pass": + _set_conf(0.8) + skills_manager.set_audit( + name, + "pass", + by_teacher=True, + worker_model=model, + teacher_model=t_model, + owner=owner, + **deps.verdict_efficiency(verdict), + ) + refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) + status = deps.finalize_status(skills_manager, name, owner, "pass", 0.8, (refreshed or {}).get("necessity"), verdict) + log(f"{name}: {status} — confidence 80% after teacher rewrite") + return {"skill": name, "result": "pass_after_teacher", "verdict": verdict, "confidence": 0.8, "status": status} + + # Still failing → demote to draft + low confidence + flag (do NOT delete). + try: + skills_manager.update_skill(name, {"status": "draft", "confidence": 0.35}, owner=owner) + except Exception: + pass + skills_manager.set_audit( + name, v or "fail", by_teacher=teacher_ran, + worker_model=model, + teacher_model=(teacher[1] if teacher_ran and teacher else ""), + owner=owner, + **deps.verdict_efficiency(verdict), + ) + log(f"{name}: flagged — confidence lowered, kept as draft for manual review") + return {"skill": name, "result": "flagged", "verdict": verdict, "confidence": 0.35} + + +async def run_audit_batch(key, skills_manager, names, url, model, headers, teacher, owner, + workload: str = "foreground", *, deps: AuditDependencies): + """Background: audit each named skill in sequence, recording progress.""" + import asyncio as _asyncio + import time as _time + + job = deps.audit_jobs.get(key) + if job is None: + return + + def log(msg): + job["log"].append(msg) + if len(job["log"]) > 1000: + del job["log"][0:len(job["log"]) - 1000] + + cancelled = False + try: + for nm in names: + if job.get("cancel"): + cancelled = True + log("(cancelled)") + break + job["current"] = nm + skills = skills_manager.load(owner=owner) + sk = next((s for s in skills if s.get("name") == nm), None) + if not sk: + continue + try: + res = await deps.audit_one( + skills_manager, sk, url, model, headers, teacher, owner, log, + workload=workload, + ) + except _asyncio.CancelledError: + cancelled = True + job["cancel"] = True + log("(cancelled)") + raise + except SkillAuditUnavailable as e: + job["unavailable"] = str(e) + log(f"Audit paused: {e}. Skill verdicts unchanged; retry when the model is available.") + break + except Exception as e: + log(f"{nm}: error — {e}") + res = {"skill": nm, "result": "error"} + skills_manager.set_audit(nm, "inconclusive", worker_model=model, owner=owner) + try: + refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == nm), None) + if refreshed: + res["skill_state"] = { + "name": refreshed.get("name"), + "status": refreshed.get("status"), + "confidence": refreshed.get("confidence"), + "audit_verdict": refreshed.get("audit_verdict"), + "audit_by_teacher": refreshed.get("audit_by_teacher"), + "audit_worker_model": refreshed.get("audit_worker_model"), + "audit_teacher_model": refreshed.get("audit_teacher_model"), + "audited_at": refreshed.get("audited_at"), + "saved_turns": refreshed.get("saved_turns"), + "saved_tool_calls": refreshed.get("saved_tool_calls"), + "baseline_verdict": refreshed.get("baseline_verdict"), + "usefulness": refreshed.get("usefulness"), + "necessity": refreshed.get("necessity"), + } + except Exception: + pass + job["results"].append(res) + job["done"] = len(job["results"]) + except _asyncio.CancelledError: + cancelled = True + finally: + if not cancelled and not job.get("cancel") and not job.get("unavailable"): + try: + deps.finalize_batch(skills_manager, job.get("results") or [], owner, log) + except Exception: + logger.warning("Could not finalize skills audit batch", exc_info=True) + job["current"] = None + job["status"] = "cancelled" if cancelled or job.get("cancel") else "error" if job.get("unavailable") else "done" + job["finished"] = _time.time() + job.pop("task", None) + + +async def run_scheduled_skill_audit(skills_manager: SkillsManager, + owner: Optional[str] = None, + max_skills: int = 8, *, deps: AuditDependencies) -> dict: + """Nightly audit pass. Audits the LEAST-recently-audited skills first and + caps the batch so it rotates through the library over successive nights + instead of re-checking the same ones every run. Reuses the same job store + the manual 'Audit all' button uses, so its progress shows in the UI too.""" + import time as _time + + key = (owner or "",) + existing = deps.audit_jobs.get(key) + if existing and existing.get("status") == "running": + logger.info("Scheduled skill audit skipped — a run is already active.") + return {"status": "running", "skipped": True} + + try: + url, model, headers, teacher = deps.resolve_models(owner=owner) + except ValueError as e: + logger.info(f"Scheduled skill audit skipped — {e}") + return {"status": "skipped", "reason": str(e)} + + from services.memory.skill_lifecycle import automatic_audit_candidates + skills = automatic_audit_candidates(skills_manager.load(owner=owner), limit=max_skills) + names = [s["name"] for s in skills] + if not names: + return {"status": "done", "total": 0} + + deps.audit_jobs[key] = { + "status": "running", "scope": "scheduled", "model": model, + "teacher": teacher[1] if teacher else None, + "total": len(names), "done": 0, "current": None, + "results": [], "log": [f"Nightly audit of {len(names)} least-recently-checked skill(s) with {model}" + + (f"; teacher {teacher[1]}" if teacher else "")], + "started": _time.time(), "cancel": False, + } + logger.info(f"Scheduled skill audit starting: {len(names)} skill(s) (owner={owner or 'all'})") + await deps.run_batch( + key, skills_manager, names, url, model, headers, teacher, owner, + workload="background", + ) + job = deps.audit_jobs.get(key, {}) + return {"status": "done", "total": len(names), "results": job.get("results", [])} diff --git a/services/skills/dependencies.py b/services/skills/dependencies.py new file mode 100644 index 000000000..5ff669a8b --- /dev/null +++ b/services/skills/dependencies.py @@ -0,0 +1,35 @@ +"""Explicit collaborators for execution and audit coordination. + +The transport compatibility bindings supply the original patchable functions. +Services never import the skills transport module or copy credentials into a +second state store. +""" +from dataclasses import dataclass +from typing import Callable + + +@dataclass(frozen=True) +class AuditDependencies: + test_jobs: dict + audit_jobs: dict + test_task: Callable + test_messages: Callable + baseline_messages: Callable + evaluate_run: Callable + evaluate_necessity: Callable + evaluate_retrieval_precision: Callable + should_check_retrieval_precision: Callable + improve_markdown: Callable + apply_markdown: Callable + duplicate_blocker: Callable + finalize_status: Callable + finalize_batch: Callable + auto_publish_policy: Callable + utility_blocker: Callable + verdict_efficiency: Callable + run_test_job: Callable + run_arm: Callable + run_test_once: Callable + audit_one: Callable + run_batch: Callable + resolve_models: Callable diff --git a/services/skills/evaluation.py b/services/skills/evaluation.py new file mode 100644 index 000000000..85ebeeadf --- /dev/null +++ b/services/skills/evaluation.py @@ -0,0 +1,464 @@ +"""Model grading and advisory skill assessment with explicit offline call seams.""" + +import logging +import re +from typing import Optional + +from src.text_helpers import strip_closed_think_blocks +from src.utility_effort import effort_for_call + +logger = logging.getLogger(__name__) + +# Last-resort verdict extraction from a teacher/verifier model's prose (run when +# JSON parsing fails). `["\'\s:]*` already consumes whitespace, so the original +# trailing `\s*` made two adjacent \s-matching quantifiers that backtrack O(n^2) +# on a `verdict` + whitespace flood in untrusted model output (CodeQL +# py/polynomial-redos). Without it a single unbounded quantifier remains — the +# matched text is identical, and the scan is linear. +_VERDICT_PROSE_RE = re.compile( + r'verdict["\'\s:]*["\']?(pass|needs_work|fail|inconclusive)', re.I +) + + +def verdict_efficiency(verdict: Optional[dict]) -> dict: + if not isinstance(verdict, dict): + return {} + out = {"audit_summary": str(verdict.get("summary") or "")[:2000]} + for key in ("saved_turns", "saved_tool_calls"): + if key not in verdict: + continue + try: + out[key] = int(verdict.get(key)) + except (TypeError, ValueError): + pass + baseline = str(verdict.get("baseline_verdict") or "").lower().strip() + if baseline in {"better", "same", "worse", "unknown"}: + out["baseline_verdict"] = baseline + if "usefulness" in verdict: + try: + out["usefulness"] = max(0.0, min(1.0, float(verdict.get("usefulness")))) + except (TypeError, ValueError): + pass + return out + + +def parse_run_verdict(raw: str): + """Return a final result dict on success, or None if unparseable.""" + import json as _json + import re as _re + _VERDICTS = ("pass", "needs_work", "fail", "inconclusive") + + text = (raw or '') + # Strip closed think blocks. If a was opened but never closed + # (the model ran out of budget mid-reasoning), drop everything from it + # onward so its stray braces don't poison JSON extraction. + text = strip_closed_think_blocks(text) + text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I).strip() + + def _coerce(d): + return d if (isinstance(d, dict) and "verdict" in d) else None + + data = None + # Scan every balanced {...} candidate and keep the LAST one that parses + # and carries a "verdict" — the transcript is full of JSON API bodies, + # so a naive first-brace/last-brace span almost never parses. + for m in _re.finditer(r'\{[\s\S]*?\}', text): + frag = m.group(0) + for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): + try: + d = _coerce(_json.loads(cand)) + except Exception: + d = None + if d is not None: + data = d + # Fallback to the greedy outermost span (handles nested objects the + # non-greedy scan above splits apart). + if data is None: + a, b = text.find('{'), text.rfind('}') + if a >= 0 and b > a: + frag = text[a:b + 1] + for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): + try: + d = _coerce(_json.loads(cand)) + except Exception: + d = None + if d is not None: + data = d + break + + v = str(data.get("verdict", "")).lower().strip() if isinstance(data, dict) else None + # Last resort: pull the verdict keyword straight out of the prose so a + # clearly-decided run isn't thrown away as "unparseable". + if v not in _VERDICTS: + km = _VERDICT_PROSE_RE.search(text) + if km: + v = km.group(1).lower() + if data is None: + data = {} + if not isinstance(data, dict) or v not in _VERDICTS: + return None + try: + conf = float(data.get("confidence", 0)) + except (TypeError, ValueError): + conf = 0 + try: + usefulness = float(data.get("usefulness", 0.0)) + except (TypeError, ValueError): + usefulness = 0.0 + try: + saved_turns = int(data.get("saved_turns", 0)) + except (TypeError, ValueError): + saved_turns = 0 + try: + saved_tool_calls = int(data.get("saved_tool_calls", 0)) + except (TypeError, ValueError): + saved_tool_calls = 0 + return { + "verdict": v, + "confidence": max(0.0, min(1.0, conf)), + "summary": str(data.get("summary", ""))[:400], + "issues": [str(x)[:200] for x in (data.get("issues") or []) if str(x).strip()][:8], + "baseline_verdict": ( + str(data.get("baseline_verdict", "unknown")).lower().strip() + if str(data.get("baseline_verdict", "unknown")).lower().strip() in {"better", "same", "worse", "unknown"} + else "unknown" + ), + "usefulness": max(0.0, min(1.0, usefulness)), + "saved_turns": saved_turns, + "saved_tool_calls": saved_tool_calls, + } + + +async def evaluate_run(skill_md: str, task: str, transcript: str, + url: str, model: str, headers: Optional[dict], + baseline_transcript: str = "", + skill_stats: Optional[dict] = None, + baseline_stats: Optional[dict] = None, + workload: str = "foreground", owner: Optional[str] = None, + *, _llm_call=None, _effort_for_call=None) -> dict: + """LLM-as-judge: grade a skill test run from its transcript. Advisory only. + + Robust against local reasoning models (strips , lenient JSON, + generous token budget) — same defensive parsing used elsewhere. + """ + if _llm_call is None: + from src.llm_core import llm_call_async + _llm_call = llm_call_async + _effort_for_call = _effort_for_call or effort_for_call + + sys_prompt = ( + "You are a strict QA reviewer judging whether an AI 'skill' (a reusable " + "procedure) actually works. You are given the SKILL, the TASK it was tested " + "on, and the TRANSCRIPT of the agent's run.\n\n" + "Judge honestly:\n" + "- The functional verdict judges ONLY whether the SKILL RUN completed the " + "task correctly and whether the skill procedure is usable. A correct skill " + "run is pass even when the baseline is equally good. Record comparative " + "value separately in baseline_verdict/usefulness.\n" + "- Did following the skill accomplish the task accurately?\n" + "- Are the steps clear, correct, and reproducible?\n" + "- Did it reference tools/commands that don't exist or that errored?\n" + "- Separately, compared with the baseline run WITHOUT the skill, did the skill make " + "the agent more accurate, use fewer turns/tool calls, or avoid avoidable " + "thrashing?\n" + "- Do NOT penalize a skill merely for being broad or generic. A broad " + "skill is useful if it makes the agent finish correctly in fewer turns " + "or with fewer tools than baseline.\n" + "- METADATA: do the frontmatter fields match what the skill actually does? " + "Flag wrong/misleading/missing tags, a wrong category, a when_to_use that " + "doesn't describe the real trigger, or a description that oversells or " + "mismatches the body. List each metadata problem in 'issues' (prefix it " + "with 'metadata:'). Metadata problems alone do NOT make the verdict 'fail' " + "if the procedure works — note them as issues on an otherwise-passing run.\n\n" + "Never use inconclusive merely because the baseline was the same or better. " + "IMPORTANT — fairness rule: if the SKILL RUN could NOT proceed because it lacked " + "an input or target the test never provided (e.g. there was no document/" + "email/data to act on, so the agent reasonably asked for it), that is NOT " + "the skill's fault. Return verdict \"inconclusive\" — do NOT mark it fail " + "or needs_work. Only judge the skill's PROCEDURE; reserve fail/needs_work " + "for when the steps themselves are wrong, vague, or reference missing tools.\n\n" + "If you need to reason, do it inside FIRST. Then output " + "ONLY this JSON (no fences):\n" + 'Set baseline_verdict to how the SKILL RUN compares to the BASELINE RUN.\n\n' + '{"verdict": "pass" | "needs_work" | "fail" | "inconclusive", ' + '"confidence": 0.0-1.0, "summary": "one short sentence", ' + '"issues": ["short issue", ...], ' + '"baseline_verdict": "better" | "same" | "worse" | "unknown", ' + '"usefulness": 0.0-1.0, "saved_turns": integer, "saved_tool_calls": integer}' + ) + # Give the judge plenty of transcript, and when it must trim, keep the TAIL + # (the final result lives at the end) plus a bit of the head — truncating to + # a short prefix made the judge wrongly call complete runs "incomplete / + # missing sections" because it never saw the end. + def _clip(t: str, limit: int = 24000) -> str: + t = (t or "").strip() or "(no output produced)" + if len(t) <= limit: + return t + head = limit // 4 + return t[:head] + "\n\n…[transcript trimmed for length]…\n\n" + t[-(limit - head):] + skill_stats = skill_stats or {} + baseline_stats = baseline_stats or {} + user_msg = ( + f"=== SKILL ===\n{(skill_md or '')[:4000]}\n\n" + f"=== TASK ===\n{task}\n\n" + f"=== SKILL RUN STATS ===\n" + f"turns={skill_stats.get('turns', 'unknown')} " + f"tool_calls={skill_stats.get('tool_calls', 'unknown')}\n\n" + f"=== SKILL RUN TRANSCRIPT ===\n{_clip(transcript)}\n\n" + f"=== BASELINE RUN WITHOUT SKILL STATS ===\n" + f"turns={baseline_stats.get('turns', 'unknown')} " + f"tool_calls={baseline_stats.get('tool_calls', 'unknown')}\n\n" + f"=== BASELINE RUN WITHOUT SKILL TRANSCRIPT ===\n{_clip(baseline_transcript)}" + ) + + # Two attempts: the first lets the judge reason; if a heavy reasoning model + # burns its budget inside and never emits the JSON, the second + # forbids thinking and demands the JSON immediately. + last_text = "" + last_err = None + for attempt in range(2): + msgs = [{"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_msg}] + if attempt == 1: + msgs[0]["content"] = ( + sys_prompt + "\n\nDO NOT use or any reasoning. Your reply " + "must START with '{' and be ONLY the JSON object, nothing else." + ) + try: + raw = await _llm_call( + # Generous budget so a heavy reasoner can think AND still have + # room to emit the JSON afterwards (reasoning tokens come out of + # this same cap; the server clamps to its own max). + url, model, msgs, + temperature=0.1, max_tokens=32768, headers=headers, timeout=180, + workload=workload, + reasoning_effort=_effort_for_call(url, model, owner), + ) + except Exception as e: + # Don't give up on a transient first-attempt error — let the second + # (no-think) attempt run before reporting failure. + last_err = e + continue + last_text = (raw or '') + parsed = parse_run_verdict(raw) + if parsed is not None: + return parsed + + if last_err is not None and not last_text: + return {"verdict": "unknown", "confidence": 0, "summary": f"Review call failed: {last_err}", "issues": []} + return {"verdict": "unknown", "confidence": 0, + "summary": "Reviewer returned unparseable output.", "issues": [], "raw": last_text[:300]} + + +async def evaluate_necessity(skill_md: str, others: list, url: str, model: str, + headers: Optional[dict], + workload: str = "foreground", owner: Optional[str] = None, + *, _llm_call=None, _effort_for_call=None) -> Optional[dict]: + """Advisory judge: is this skill worth keeping, or is it redundant / trivially + unnecessary? Sees the OTHER skills' names+descriptions so it can spot + duplicates. Returns {necessary, redundant_with, reason} or None. Never acts — + purely a flag the UI surfaces.""" + import json as _json + import re as _re + if _llm_call is None: + from src.llm_core import llm_call_async + _llm_call = llm_call_async + _effort_for_call = _effort_for_call or effort_for_call + + catalog = "\n".join(f"- {o.get('name')}: {o.get('description', '')}" for o in others) or "(no other skills)" + sys_prompt = ( + "You assess whether a reusable AI 'skill' (a saved procedure) is worth keeping. " + "A skill is UNNECESSARY if it essentially duplicates another skill in the library. " + "Do NOT call a skill unnecessary merely because it is broad or generic; broad " + "skills can be worth keeping when they make a capable assistant finish accurately " + "with fewer turns or fewer tool calls than it would without the skill. A skill IS " + "necessary if it captures a reusable trigger, procedure, tool sequence, or " + "hard-won detail that could improve future runs.\n\n" + "Be conservative: only call it unnecessary when you're confident. Reason in " + " first if needed, then output ONLY this JSON:\n" + '{"necessary": true|false, "redundant_with": ["skill-name", ...], ' + '"reason": "one short sentence"}' + ) + user_msg = ( + f"=== SKILL UNDER REVIEW ===\n{(skill_md or '')[:3000]}\n\n" + f"=== OTHER SKILLS IN THE LIBRARY ===\n{catalog[:4000]}" + ) + try: + raw = await _llm_call( + url, model, + [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}], + temperature=0.1, max_tokens=8192, headers=headers, timeout=120, + workload=workload, + reasoning_effort=_effort_for_call(url, model, owner), + ) + except Exception as e: + logger.warning(f"Necessity check failed: {e}") + return None + text = strip_closed_think_blocks(raw or '') + text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I).strip() + data = None + a, b = text.find('{'), text.rfind('}') + if a >= 0 and b > a: + frag = text[a:b + 1] + for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): + try: + data = _json.loads(cand) + break + except Exception: + continue + if not isinstance(data, dict) or "necessary" not in data: + return None + return { + "necessary": bool(data.get("necessary", True)), + "redundant_with": [str(x)[:80] for x in (data.get("redundant_with") or []) if str(x).strip()][:6], + "reason": str(data.get("reason", ""))[:200], + } + + +def should_check_retrieval_precision(skill: dict) -> bool: + """Cheap prefilter for the expensive retrieval-precision judge. + + Skills with broad tags like "network" or "document" are the ones most likely + to over-inject. Narrow command/vendor tags alone are fine. + """ + broad = { + "arch", "arch linux", "linux", "network", "networking", "wifi", + "installation", "install", "system", "ssh", "document", "documents", + "search", "email", "calendar", "gpu", "server", "python", + } + if not isinstance(skill, dict): + return False + tags = {str(t or "").strip().lower() for t in (skill.get("tags") or [])} + if tags & broad: + return True + text = " ".join([ + str(skill.get("name") or ""), + str(skill.get("description") or ""), + str(skill.get("when_to_use") or ""), + ]).lower() + return sum(1 for t in broad if t in text) >= 2 + + +async def evaluate_retrieval_precision(skill_md: str, others: list, + url: str, model: str, + headers: Optional[dict], + workload: str = "foreground", owner: Optional[str] = None, + *, _llm_call=None, _effort_for_call=None) -> Optional[dict]: + """Advisory judge: would this skill's metadata make retrieval over-select it? + + This is distinct from "does the procedure work?". It asks whether tags, + description, and when_to_use are specific enough that the skill won't be + injected into adjacent but wrong tasks. + """ + import json as _json + import re as _re + if _llm_call is None: + from src.llm_core import llm_call_async + _llm_call = llm_call_async + _effort_for_call = _effort_for_call or effort_for_call + + catalog = "\n".join(f"- {o.get('name')}: {o.get('description', '')}" for o in others[:80]) or "(no other skills)" + sys_prompt = ( + "You are auditing retrieval metadata for a reusable AI skill. The app selects " + "skills by matching user requests against the skill name, description, tags, " + "when_to_use, and procedure text. Judge whether this skill is likely to be " + "over-selected for nearby but wrong requests.\n\n" + "Focus ONLY on metadata/retrieval precision, not whether the procedure works. " + "Flag broad tags such as network, installation, system, document, search, ssh, " + "python, gpu, or server when they would cause this narrow skill to match too " + "many adjacent tasks. Recommend narrower tags/when_to_use wording. Compare " + "against the other skills to spot boundaries.\n\n" + "Return ok=true only when the trigger metadata is narrow enough. If not ok, " + "issues MUST start with 'metadata: retrieval:' and be actionable. Output ONLY JSON:\n" + '{"ok": true|false, "summary": "one short sentence", "issues": ["metadata: retrieval: ..."]}' + ) + user_msg = ( + f"=== SKILL UNDER REVIEW ===\n{(skill_md or '')[:5000]}\n\n" + f"=== OTHER SKILLS IN LIBRARY ===\n{catalog[:5000]}\n\n" + "Decide if this skill's retrieval metadata should be narrowed so it only " + "fires for its intended scenario and not for adjacent skills above." + ) + try: + raw = await _llm_call( + url, model, + [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}], + temperature=0.1, max_tokens=4096, headers=headers, timeout=90, + workload=workload, + reasoning_effort=_effort_for_call(url, model, owner), + ) + except Exception as e: + logger.warning(f"Retrieval precision check failed: {e}") + return None + text = strip_closed_think_blocks(raw or '') + text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I).strip() + data = None + a, b = text.find('{'), text.rfind('}') + if a >= 0 and b > a: + frag = text[a:b + 1] + for cand in (frag, _re.sub(r',(\s*[}\]])', r'\1', frag)): + try: + data = _json.loads(cand) + break + except Exception: + continue + if not isinstance(data, dict) or "ok" not in data: + return None + return { + "ok": bool(data.get("ok")), + "summary": str(data.get("summary", ""))[:300], + "issues": [str(x)[:220] for x in (data.get("issues") or []) if str(x).strip()][:6], + } + + +async def improve_markdown(skill_md: str, verdict: dict, transcript: str, url, model, headers, + workload: str = "foreground", owner: Optional[str] = None, + *, _llm_call=None, _effort_for_call=None): + """Have a model rewrite SKILL.md to fix the reviewer's issues. Returns the + corrected markdown, or None if it couldn't produce a usable change.""" + import re as _re + if _llm_call is None: + from src.llm_core import llm_call_async + _llm_call = llm_call_async + _effort_for_call = _effort_for_call or effort_for_call + issues = "\n".join("- " + str(i) for i in (verdict.get("issues") or [])) + sys_prompt = ( + "You are improving a reusable AI SKILL written in Markdown (frontmatter + body). " + "A QA reviewer found problems after a test run. Rewrite the SKILL.md to fix them: " + "make vague steps concrete, correct or remove references to tools that don't exist, " + "ensure the procedure is reproducible. " + "Keep the `name` field EXACTLY as-is (it is the skill's identity / filename). You MAY " + "correct the OTHER frontmatter — tags, category, when_to_use, description — when the " + "reviewer flagged them (issues prefixed 'metadata:') or they don't match the body; keep " + "retrieval metadata narrow: remove broad tags that would over-select the skill, and make " + "`when_to_use` say when NOT to use the skill if adjacent tasks are easy to confuse. Keep " + "valid frontmatter structure. Do NOT invent capabilities the agent lacks. Reason in " + " first if needed, then output ONLY the full corrected SKILL.md (no " + "fences, no commentary)." + ) + user_msg = ( + f"=== CURRENT SKILL.md ===\n{skill_md}\n\n" + f"=== REVIEWER VERDICT ===\n{verdict.get('summary', '')}\nIssues:\n{issues}\n\n" + f"=== TEST TRANSCRIPT ===\n{(transcript or '')[:6000]}" + ) + try: + raw = await _llm_call(url, model, + [{"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_msg}], + temperature=0.2, max_tokens=16384, headers=headers, timeout=180, + workload=workload, + reasoning_effort=_effort_for_call(url, model, owner)) + except Exception as e: + logger.warning(f"Audit: improve call failed: {e}") + return None + text = strip_closed_think_blocks(raw or '') + text = _re.sub(r'[\s\S]*$', '', text, flags=_re.I) + text = _re.sub(r'', '', text, flags=_re.I).strip() + if text.startswith("```"): + text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() + # Some reasoning models still prepend analysis or echo the old skill before + # the final corrected document. Keep only the last complete-looking + # frontmatter document so reviewer prose never gets persisted into SKILL.md. + starts = list(_re.finditer(r'(?m)^---\s*\n(?=[\s\S]*?^name\s*:)', text)) + if starts: + text = text[starts[-1].start():].strip() + return text or None diff --git a/services/skills/job_coordinator.py b/services/skills/job_coordinator.py new file mode 100644 index 000000000..1f737a519 --- /dev/null +++ b/services/skills/job_coordinator.py @@ -0,0 +1,216 @@ +"""Transient job transitions and public polling projections. + +HTTP ownership and approval validation happen before these operations. These +functions use the supplied registries and preserve the existing task ownership. +""" +import asyncio as _asyncio +import logging +import time as _time + +from src.prompt_security import untrusted_context_message +from .dependencies import AuditDependencies + +logger = logging.getLogger(__name__) + + +def start_test_job(name, md, task, url, model, headers, user, skills_manager, + request_authority, *, deps: AuditDependencies): + key = (user or "", name) + previous_job = deps.test_jobs.get(key) or {} + previous_approval = previous_job.get("approval") or {} + if previous_approval.get("approval_id"): + try: + from src.tool_approvals import tool_approval_store + tool_approval_store.consume( + previous_approval["approval_id"], + decision="deny", + owner=user, + session_id=None, + ) + except Exception: + logger.debug("Could not retire replaced skill approval", exc_info=True) + deps.test_jobs[key] = { + "status": "running", + "task": task, + "model": model, + "skill": name, + "started": _time.time(), + "log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}], + "verdict": None, + "_run": { + "md": md, + "url": url, + "model": model, + "headers": headers, + "owner": user, + "request_authority": request_authority, + }, + } + _asyncio.create_task(deps.run_test_job( + key, name, md, task, url, model, headers, user, skills_manager, + request_authority=request_authority)) + return {"ok": True, "status": "running", "skill": name, "model": model} + + +def continue_test_job(job, key, name, decision, expected, exact_approval, + skills_manager, *, deps: AuditDependencies): + job.pop("approval", None) + if decision == "deny": + job.pop("_transcript", None) + job.pop("_run", None) + job["log"].append({ + "type": "approval_denied", + "text": "Exact action denied; the skill test stopped without executing it.", + }) + job["verdict"] = { + "verdict": "inconclusive", + "confidence": 1.0, + "summary": "The test stopped because its exact action was denied.", + "issues": [], + } + job["status"] = "done" + return {"ok": True, "status": "done", "decision": "deny"} + + run = job.get("_run") or {} + transcript = job.pop("_transcript", []) + # stream_agent_loop owns its per-round message list internally. Rebuild + # continuation context from the original untrusted skill plus the + # accumulated transcript so repeated approvals do not lose earlier + # approved results, while keeping every transcript byte tainted. + messages = deps.test_messages( + run.get("md", ""), + job.get("task", ""), + ) + if transcript: + messages.append(untrusted_context_message( + "skill test transcript", + "".join(str(item) for item in transcript), + )) + messages.extend([ + { + "role": "assistant", + "content": str(expected.get("question") or "Allow this exact action once?"), + }, + { + "role": "user", + "content": ( + f"Approved the exact {exact_approval.pending.tool_name} " + "action shown above once." + ), + }, + ]) + job["status"] = "running" + job["log"].append({ + "type": "approval_granted", + "text": ( + f"Approved exact {exact_approval.pending.tool_name} action once; " + "resuming test." + ), + }) + _asyncio.create_task(deps.run_test_job( + key, + name, + run.get("md", ""), + job.get("task", ""), + run.get("url"), + run.get("model"), + run.get("headers"), + run.get("owner"), + skills_manager, + messages=messages, + transcript=transcript, + exact_approval=exact_approval, + )) + return {"ok": True, "status": "running", "decision": "approve"} + + +def test_status(user, name, *, deps: AuditDependencies): + job = deps.test_jobs.get((user or "", name)) + if not job: + return {"status": "none"} + return { + "status": job["status"], + "task": job.get("task"), + "model": job.get("model"), + "log": job.get("log", []), + "verdict": job.get("verdict"), + "approval": job.get("approval"), + } + + +def select_audit_names(skills_manager, user, scope, requested_names, skip_audited): + skills = skills_manager.load(owner=user) + # Built-ins are tracked, pre-approved application procedures. They do + # not consume audit turns and cannot be demoted by an audit result. + auditable_skills = [s for s in skills if s.get("source") != "builtin"] + by_name = {s.get("name"): s for s in auditable_skills if s.get("name")} + if isinstance(requested_names, list): + names = [] + seen = set() + for raw in requested_names: + nm = str(raw or "").strip() + if not nm or nm in seen or nm not in by_name: + continue + if scope not in ("all", "selected") and (by_name[nm].get("status") or "draft") == "published": + continue + if skip_audited and by_name[nm].get("audit_verdict"): + continue + names.append(nm) + seen.add(nm) + scope = "selected" if requested_names else scope + elif scope == "all": + names = [ + s.get("name") for s in auditable_skills + if s.get("name") and (not skip_audited or not s.get("audit_verdict")) + ] + else: + scope = "unchecked" if scope == "drafts" else scope + names = [ + s.get("name") for s in auditable_skills + if s.get("name") + and (s.get("status") or "draft") != "published" + and not s.get("audit_verdict") + ] + return names, scope + + +def start_audit_job(key, skills_manager, names, url, model, headers, teacher, user, + scope, *, deps: AuditDependencies): + if not names: + return {"ok": True, "status": "done", "total": 0, "results": [], "log": ["No skills to audit."]} + + deps.audit_jobs[key] = { + "status": "running", "scope": scope, "model": model, + "teacher": teacher[1] if teacher else None, + "total": len(names), "done": 0, "current": None, + "results": [], "log": [f"Auditing {len(names)} skill(s) with {model}" + (f"; teacher {teacher[1]}" if teacher else "")], + "started": _time.time(), "cancel": False, + } + task = _asyncio.create_task(deps.run_batch(key, skills_manager, names, url, model, headers, teacher, user)) + deps.audit_jobs[key]["task"] = task + return {"ok": True, "status": "running", "total": len(names), "model": model} + + +def audit_status(user, *, deps: AuditDependencies): + job = deps.audit_jobs.get((user or "",)) + if not job: + return {"status": "none"} + return { + "status": job["status"], "scope": job.get("scope"), + "total": job.get("total", 0), "done": job.get("done", 0), + "current": job.get("current"), "model": job.get("model"), "teacher": job.get("teacher"), + "results": job.get("results", []), "log": job.get("log", []), + "started": job.get("started"), "finished": job.get("finished"), + } + + +def cancel_audit(user, *, deps: AuditDependencies): + job = deps.audit_jobs.get((user or "",)) + if job: + job["cancel"] = True + job["status"] = "cancelled" + job["current"] = None + task = job.get("task") + if task and not task.done(): + task.cancel() + return {"ok": True, "status": "cancelled" if job else "none"} diff --git a/services/skills/model_selection.py b/services/skills/model_selection.py new file mode 100644 index 000000000..cd446632c --- /dev/null +++ b/services/skills/model_selection.py @@ -0,0 +1,75 @@ +"""Owner-scoped audit worker and teacher endpoint resolution.""" + +import logging + + +logger = logging.getLogger(__name__) + +def resolve_audit_models(owner=None, model_spec=None, endpoint_url=None): + """Resolve (url, model, headers, teacher) for an audit run from Settings. + + Worker = Utility model (falling back to Default, normalized to a served + model id); teacher = the optional Settings → Teacher Model config. Shared + by the manual /audit-all route and scheduled/event audits. Raises + ValueError if no worker model. + """ + from src.endpoint_resolver import resolve_endpoint, resolve_utility_fallback_candidates + if model_spec and endpoint_url: + # Scheduled tasks store the endpoint URL and model separately. Resolve + # the endpoint directly so an explicit task choice cannot be replaced + # by the global Utility setting. + from src.endpoint_resolver import build_headers, resolve_endpoint_runtime + from src.database import ModelEndpoint, SessionLocal + from src.endpoint_resolver import normalize_base, same_endpoint_base + from src.auth_helpers import owner_filter + url = endpoint_url + model = model_spec + headers = {} + db = SessionLocal() + try: + query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + for ep in owner_filter(query, ModelEndpoint, owner).all(): + base = normalize_base(getattr(ep, "base_url", "") or "") + if same_endpoint_base(url, base): + runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner) + headers = build_headers(api_key, runtime_base or base) + break + finally: + db.close() + elif model_spec: + from src.ai_interaction import _resolve_model + url, model, headers = _resolve_model(str(model_spec), owner=owner) + else: + url, model, headers = resolve_endpoint("utility", owner=owner) + if not url or not model: + # Utility fallbacks are an explicit part of the user's model + # configuration. Audits must use the same chain as other background + # work instead of treating an empty primary Utility slot as fatal. + for fallback_url, fallback_model, fallback_headers in resolve_utility_fallback_candidates(owner=owner): + url, model, headers = fallback_url, fallback_model, fallback_headers + break + if not url or not model: + raise ValueError("No model configured — set a Default or Utility model in Settings.") + try: + from src.llm_core import list_model_ids + import os as _os + _avail = list_model_ids(url, headers=headers) + if _avail and model not in _avail: + _base = _os.path.basename((model or "").rstrip("/")) + model = next((a for a in _avail if _os.path.basename(a.rstrip("/")) == _base), None) or _avail[0] + except Exception: + pass + + teacher = None + try: + from src.settings import get_setting + if get_setting("teacher_enabled", False): + spec = (get_setting("teacher_model", "") or "").strip() + if spec: + from src.ai_interaction import _resolve_model + t_url, t_model, t_headers = _resolve_model(spec, owner=owner) + if t_url and t_model: + teacher = (t_url, t_model, t_headers) + except Exception as e: + logger.warning(f"Audit teacher resolve failed: {e}") + return url, model, headers, teacher diff --git a/services/skills/prompts.py b/services/skills/prompts.py new file mode 100644 index 000000000..42e32e000 --- /dev/null +++ b/services/skills/prompts.py @@ -0,0 +1,52 @@ +"""Identical audit tasks and explicitly untrusted skill context.""" + +from src.prompt_security import untrusted_context_message + +def _skill_test_task(skill: dict) -> str: + """Build the one shared task used by both sides of an audit comparison.""" + if not isinstance(skill, dict): + skill = {} + ctx = (skill.get("when_to_use") or skill.get("description") or skill.get("name") or "").strip() + return ( + "Complete this task end-to-end. Use this exact task context and do not ask " + "the user for more input. If a harmless fixture is needed, create the " + "smallest realistic one that satisfies the context, state it explicitly, " + "then complete and verify the result. Do not perform destructive or " + "externally visible actions. Task context: " + (ctx or "(general)") + ) + + +def _skill_baseline_task(skill: dict) -> str: + """Backward-compatible alias; both audit arms must receive identical text.""" + return _skill_test_task(skill) + + +def _skill_test_messages(md: str, task: str) -> list[dict]: + """Keep user-editable skill text out of the trusted system role.""" + return [ + { + "role": "system", + "content": ( + "You are TESTING a skill. Follow the supplied reusable procedure " + "to complete the user's task for real, using available tools step " + "by step. If the skill is wrong, unclear, or references tools that " + "do not exist, do your best; the problems will be reviewed afterward." + ), + }, + untrusted_context_message("skill under test", md), + {"role": "user", "content": task}, + ] + + +def _skill_baseline_messages(task: str) -> list[dict]: + return [ + { + "role": "system", + "content": ( + "You are completing a baseline audit run. Do not use any saved " + "skill text. Solve the user's task using only your normal tools " + "and reasoning." + ), + }, + {"role": "user", "content": task}, + ] diff --git a/services/skills/publication.py b/services/skills/publication.py new file mode 100644 index 000000000..d5267d7e6 --- /dev/null +++ b/services/skills/publication.py @@ -0,0 +1,349 @@ +"""Audit publication and duplicate coordination through the canonical SkillsManager.""" + +import logging +import re +from typing import Optional + +from .dependencies import AuditDependencies + +logger = logging.getLogger(__name__) + +def auto_publish_policy(owner) -> tuple[bool, float]: + """Return (auto_publish_enabled, minimum_confidence) for audit finalization.""" + try: + from routes.prefs_routes import _load_for_user + prefs = _load_for_user(owner) or {} + except Exception: + prefs = {} + try: + from src.settings import get_setting + default_min = get_setting("skill_autosave_min_confidence", 0.85) + except Exception: + default_min = 0.85 + enabled = bool(prefs.get("auto_approve_skills", True)) + try: + min_conf = float(prefs.get("skill_min_confidence", default_min)) + except (TypeError, ValueError): + min_conf = 0.85 + return enabled, max(0.0, min(1.0, min_conf)) + + +def duplicate_blocker(skills_manager, name: str, owner) -> Optional[str]: + """Cheap duplicate guard matching the UI's duplicate grouping. + + The LLM necessity check catches semantic redundancy, but the UI also has a + cheap similarity pass. Use the same broad signal before auto-publishing so + a high-scoring lower-priority duplicate stays draft. + """ + import re as _re + + def _tokens(sk: dict) -> set[str]: + text = " ".join([ + str(sk.get("name") or ""), + str(sk.get("description") or ""), + str(sk.get("when_to_use") or ""), + " ".join(sk.get("procedure") or []), + " ".join(sk.get("tags") or []), + ]).lower() + text = _re.sub(r"-\d+\b", "", text) + return { + t for t in _re.split(r"[^a-z0-9]+", text) + if len(t) > 2 and t not in {"the", "and", "with", "for", "from", "using"} + } + + def _sim(a: dict, b: dict) -> float: + A, B = _tokens(a), _tokens(b) + if not A or not B: + return 0.0 + return len(A & B) / max(1, len(A | B)) + + def _base(n: str) -> str: + return _re.sub(r"-\d+$", "", str(n or "")) + + def _score(sk: dict) -> float: + return ( + (100000 if (sk.get("status") == "published") else 0) + + int(sk.get("uses") or 0) * 100 + + round(float(sk.get("confidence") or 0) * 100) + + (-5 if sk.get("audit_by_teacher") else 0) + - (len(str(sk.get("name") or "")) / 1000) + ) + + skills = [s for s in skills_manager.load(owner=owner) if s.get("status") != "binned"] + current = next((s for s in skills if (s.get("name") or s.get("id")) == name), None) + if not current: + return None + duplicates = [] + cur_name = current.get("name") or current.get("id") or name + for other in skills: + other_name = other.get("name") or other.get("id") + if not other_name or other_name == cur_name: + continue + if _base(cur_name) == _base(other_name) or _sim(current, other) >= 0.38: + duplicates.append(other) + if not duplicates: + return None + keeper = sorted([current, *duplicates], key=_score, reverse=True)[0] + keeper_name = keeper.get("name") or keeper.get("id") or "" + if keeper_name and keeper_name != cur_name: + try: + skills_manager.set_necessity( + cur_name, + False, + [keeper_name], + f"Lower-priority duplicate of {keeper_name}", + owner=owner, + ) + except Exception: + pass + return keeper_name + return None + + +def finalize_batch(skills_manager, results: list[dict], owner, log, *, deps: AuditDependencies) -> None: + """Draft audited failures, bin duplicate losers, and publish the best copy. + + Binned skills remain on disk for recovery and inspection, but the Skills + manager excludes them from retrieval. Only skills actually processed by + this audit job are changed here; an unrelated existing skill is never + moved just because it resembles an audited one. + """ + import re as _re + + auto_publish, min_conf = deps.auto_publish_policy(owner) + current = [ + s for s in skills_manager.load(owner=owner) + if s.get("source") != "builtin" and s.get("status") != "binned" + ] + by_name = {s.get("name"): s for s in current if s.get("name")} + processed = {str(r.get("skill")) for r in results if r.get("skill")} + protected = { + str(r.get("skill")) for r in results + if r.get("skill") and r.get("result") == "approval_required" + } + + def tokens(sk: dict) -> set[str]: + text = " ".join([ + str(sk.get("name") or ""), str(sk.get("description") or ""), + str(sk.get("when_to_use") or ""), " ".join(sk.get("procedure") or []), + " ".join(sk.get("tags") or []), + ]).lower() + text = _re.sub(r"-\d+\b", "", text) + return { + t for t in _re.split(r"[^a-z0-9]+", text) + if len(t) > 2 and t not in {"the", "and", "with", "for", "from", "using"} + } + + def similar(a: dict, b: dict) -> float: + left, right = tokens(a), tokens(b) + return len(left & right) / max(1, len(left | right)) if left and right else 0.0 + + def base(name: str) -> str: + return _re.sub(r"-\d+$", "", str(name or "")) + + def score(sk: dict) -> float: + try: + confidence = float(sk.get("confidence") or 0) + except (TypeError, ValueError): + confidence = 0.0 + return ( + (100000 if sk.get("status") == "published" else 0) + + int(sk.get("uses") or 0) * 100 + + round(confidence * 100) + + (-5 if sk.get("audit_by_teacher") else 0) + - len(str(sk.get("name") or "")) / 1000 + ) + + # Anything that does not clear the configured policy stays a draft. Drafts + # are excluded from retrieval/injection by SkillsManager.index_for(). + for name in processed - protected: + skill = by_name.get(name) + if not skill: + continue + verdict = str(skill.get("audit_verdict") or "").lower() + try: + confidence = float(skill.get("confidence") or 0) + except (TypeError, ValueError): + confidence = 0.0 + if verdict in {"needs_work", "fail"} or ( + verdict == "pass" and confidence < min_conf + ): + try: + skills_manager.update_skill(name, {"status": "draft"}, owner=owner) + log(f"{name}: kept as draft after audit") + except Exception: + logger.warning("Could not bin audited skill %s", name, exc_info=True) + + # Build the same connected duplicate groups shown by the UI. + parent = {s["name"]: s["name"] for s in current} + + def find(name: str) -> str: + while parent[name] != name: + parent[name] = parent[parent[name]] + name = parent[name] + return name + + def unite(left: str, right: str) -> None: + left, right = find(left), find(right) + if left != right: + parent[right] = left + + for index, left in enumerate(current): + for right in current[index + 1:]: + if base(left["name"]) == base(right["name"]) or similar(left, right) >= 0.38: + unite(left["name"], right["name"]) + groups: dict[str, list[dict]] = {} + for skill in current: + groups.setdefault(find(skill["name"]), []).append(skill) + + for group in groups.values(): + if len(group) < 2: + continue + passing = [] + for skill in group: + if skill["name"] not in processed or skill["name"] in protected: + continue + if str(skill.get("audit_verdict") or "").lower() != "pass": + continue + try: + confidence = float(skill.get("confidence") or 0) + except (TypeError, ValueError): + confidence = 0.0 + if confidence >= min_conf: + passing.append(skill) + if not passing: + continue + keeper = max(passing, key=score) + if auto_publish: + try: + skills_manager.update_skill(keeper["name"], {"status": "published"}, owner=owner) + log(f"{keeper['name']}: auto-approved as best passing duplicate") + except Exception: + logger.warning("Could not auto-approve skill %s", keeper["name"], exc_info=True) + for skill in group: + name = skill["name"] + if name == keeper["name"] or name not in processed or name in protected: + continue + try: + skills_manager.update_skill(name, {"status": "binned"}, owner=owner) + log(f"{name}: moved to bin as duplicate of {keeper['name']}") + except Exception: + logger.warning("Could not bin duplicate skill %s", name, exc_info=True) + + +def audit_flag_text(*parts) -> str: + text_parts = [] + for part in parts: + if isinstance(part, dict): + text_parts.extend(str(v or "") for v in part.values()) + elif isinstance(part, (list, tuple, set)): + text_parts.extend(str(v or "") for v in part) + else: + text_parts.append(str(part or "")) + return " ".join(text_parts).lower() + + +def utility_blocker(skill: Optional[dict], necessity: Optional[dict], + verdict_data: Optional[dict]) -> Optional[str]: + """Return a short reason when a passing skill still should stay draft. + + Broad/generic wording is not a blocker by itself. The blocker is whether + the skill failed to improve the agent versus a no-skill baseline, or whether + it duplicates another skill. + """ + generic_re = re.compile( + r"\b(duplicat\w*|redundan\w*|overlap\w*|same skill|same procedure)\b", + re.I, + ) + if isinstance(necessity, dict): + reason = str(necessity.get("reason") or "") + if necessity.get("necessary") is False and generic_re.search(reason): + return reason or "Duplicate or redundant skill" + + if isinstance(verdict_data, dict): + baseline_verdict = str(verdict_data.get("baseline_verdict") or "unknown").lower() + try: + usefulness = float(verdict_data.get("usefulness", 0.0) or 0.0) + except (TypeError, ValueError): + usefulness = 0.0 + try: + saved_turns = int(verdict_data.get("saved_turns", 0) or 0) + except (TypeError, ValueError): + saved_turns = 0 + try: + saved_tool_calls = int(verdict_data.get("saved_tool_calls", 0) or 0) + except (TypeError, ValueError): + saved_tool_calls = 0 + if baseline_verdict == "worse": + return "Skill performed worse than the no-skill baseline" + verdict_text = audit_flag_text( + verdict_data.get("summary"), + verdict_data.get("issues") or [], + ) + if generic_re.search(verdict_text): + return "Audit flagged the skill as duplicate or redundant" + return None + + +def finalize_status(skills_manager, name: str, owner, verdict: str, + confidence: Optional[float], necessity: Optional[dict] = None, + verdict_data: Optional[dict] = None, *, deps: AuditDependencies) -> str: + """Apply the user's audit publishing policy. + + Audit is the final pass: skills that pass at/above the threshold are + published; failing or unnecessary/redundant skills are returned to draft. + Inconclusive runs preserve the existing state because they provide no + evidence either way. The completed batch moves duplicate losers to the bin. + """ + auto_publish, min_conf = deps.auto_publish_policy(owner) + necessary = True + current = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None) + if verdict in {"inconclusive", "unknown"}: + return (current or {}).get("status") or "draft" + utility_reason = deps.utility_blocker(current, necessity, verdict_data) + if ( + isinstance(necessity, dict) + and necessity.get("necessary") is False + and necessity.get("redundant_with") + ): + necessary = False + if utility_reason: + necessary = False + try: + skills_manager.set_necessity(name, False, [], utility_reason, owner=owner) + except Exception: + pass + duplicate_of = deps.duplicate_blocker(skills_manager, name, owner) if verdict == "pass" else None + if duplicate_of: + necessary = False + c = float(confidence or 0.0) + status = "published" if (auto_publish and necessary and verdict == "pass" and c >= min_conf) else "draft" + try: + skills_manager.update_skill(name, {"status": status}, owner=owner) + except Exception: + pass + return status + + +def apply_markdown(skills_manager, name: str, md: str, owner) -> bool: + """Parse + persist an edited SKILL.md. Returns True on success.""" + try: + from services.memory.skill_format import Skill, slugify + sk = Skill.from_markdown(md) + # Pin the identity: the audit's fixer is now allowed to edit frontmatter + # (tags/category/when_to_use/description), but it must NEVER rename the + # skill — a changed `name` would move the dir and orphan the usage/audit + # sidecar entries that the caller keeps writing under the original name. + sk.name = name + return bool(skills_manager.update_skill(name, { + "name": sk.name, "description": sk.description, "version": sk.version, + "category": sk.category, "tags": sk.tags, "platforms": sk.platforms, + "requires_toolsets": sk.requires_toolsets, "fallback_for_toolsets": sk.fallback_for_toolsets, + "status": sk.status, "confidence": sk.confidence, "source": sk.source, + "teacher_model": sk.teacher_model, "owner": sk.owner or owner, + "when_to_use": sk.when_to_use, "procedure": sk.procedure, + "pitfalls": sk.pitfalls, "verification": sk.verification, "body_extra": sk.body_extra, + }, owner=owner)) + except Exception as e: + logger.warning(f"Audit: could not save edited skill {name}: {e}") + return False diff --git a/specs/_readme.md b/specs/_readme.md index 902c882f4..a8c3a80f0 100644 --- a/specs/_readme.md +++ b/specs/_readme.md @@ -1,6 +1,6 @@ # Specs DocumentMap -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 This folder is the compact implementation-truth map for humans and coding agents working on Odysseus. Read this file first, then open only the subsystem specs that match the work. @@ -56,14 +56,14 @@ project documentation. - [context-building.md](context-building.md): URL/search/RAG/memory/skills/YouTube/email/tool-output context, untrusted wrapping, unavailable context, intent boundaries. - [search.md](search.md): web search providers, ranking, cache/analytics, URL fetch/content extraction, `src.search`/`services.search` split. - [documents-rag-uploads.md](documents-rag-uploads.md): uploads, documents, PDF/form handling, personal docs, RAG/vector stores. -- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill extraction/formatting, owner isolation. +- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill storage/extraction and service-owned audit/evaluation, owner isolation. - [research.md](research.md): deep research jobs, synthesis, sources, research library, research UI panel. -- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, notes/todos. +- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, Notes feature state and lifecycle. - [email-contacts.md](email-contacts.md): IMAP/SMTP email, email library, scheduled mail, contacts/CardDAV. - [gallery-editor-media.md](gallery-editor-media.md): gallery, generated media, image editor drafts, signatures, emoji/font helpers. - [cookbook-hwfit.md](cookbook-hwfit.md): model downloads, local/remote model serving, hardware detection, fit ranking. - [speech.md](speech.md): STT and TTS services, routes, settings, optional dependencies. -- [frontend.md](frontend.md): static SPA, module loading, UI conventions, major JS areas, no-build frontend shape. +- [frontend.md](frontend.md): static native-ESM SPA, maintained Notes source/deployment graph, module loading, UI conventions and shared ownership. - [integrations.md](integrations.md): Codex/Claude scoped APIs, companion pairing, webhooks, external agent access. - [shell-mcp.md](shell-mcp.md): shell execution, background jobs, MCP manager, built-in MCP servers. - [settings-admin.md](settings-admin.md): settings, preferences, presets, backup/import/export, diagnostics, admin wipe. diff --git a/specs/agent-tools.md b/specs/agent-tools.md index c6b7a8184..25cb57b14 100644 --- a/specs/agent-tools.md +++ b/specs/agent-tools.md @@ -1,12 +1,14 @@ # Agent Tools -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope This spec covers agent/tool behavior in: -- `src/agent_loop.py`; +- `src/agent_loop.py`, `src/clean_agent_preview.py`, `src/turn_contract.py`, `src/agent_evidence.py`, and `src/agent_trace.py`; +- `src/agent_runtime/` authority, resource binding, action journal, durable effects, completion, and context resolution modules; +- `src/containment.py`, `src/process_ownership.py`, `src/process_lifecycle.py`, and `src/browser_identity.py`; - `src/llm_core.py`; - `src/tool_schemas.py`; - `src/tool_execution.py`; @@ -17,8 +19,7 @@ This spec covers agent/tool behavior in: - `src/tool_capabilities.py`; - `src/tool_approval_scopes.py`; - `src/tool_approvals.py`; -- `src/attachment_refs.py` and shared upload lifecycle helpers in - `src/upload_handler.py` / `src/tool_utils.py`; +- `src/attachment_refs.py` and shared upload lifecycle helpers in `src/upload_handler.py` / `src/tool_utils.py`; - `src/tool_implementations.py`; - `src/tools/*.py`; - `src/builtin_actions.py`; @@ -41,7 +42,7 @@ This spec covers agent/tool behavior in: `src.llm_core` owns provider payloads, native tool-schema emission, and provider stream parsing. `agent_loop` consumes normalized tool-call events and decides whether and how to execute them. -Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering. +The legacy loop and the opt-in compact preview share the execution boundary. Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering. Guide-only/no-tools turns are runtime policy, not prompt advice. `src.tool_policy` detects strong latest-turn directives such as guide-only mode, no-tools mode, and explicit requests not to use tools; it builds a `ToolPolicy` that hides schemas, disables known native tools, disables MCP for that turn, skips tool retrieval, suppresses local/workspace context injection, blocks document streaming/teacher escalation, and gives `tool_execution` a final execution backstop. @@ -64,7 +65,7 @@ Tool registration is split: When adding, removing, or renaming a tool, update the registry chain, execution dispatch, retrieval text, prompt wording, disabled-tool UI, and tests together. -`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Current prompt/schema assembly preserves only selected base tools unconditionally, then adds intent-, skill-, and retrieval-relevant tools so unrelated schemas do not flood small contexts. +`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Compact prompt/schema assembly selects policy-authorized intent-, skill-, and retrieval-relevant tools; the full-schema surface offers all policy-authorized built-ins and MCP schemas. Selection is not authority. ## Tool Retrieval And Execution @@ -76,7 +77,7 @@ Tool retrieval has domain-specific hooks beyond generic similarity: contact quer Interaction/session/model helper tools are native first-class tools, not prompt-only conventions. `ask_user` and `update_plan` live in `src.agent_tools.interaction_tools`, model delegation/listing helpers live in `model_interaction_tools`, session creation/list/send/manage helpers live in `session_tools`, and `manage_bg_jobs` lives in `bg_job_tools`. -Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `......` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn, common `tex` typos normalize to `text`, and delimiter scans are forward-only so unterminated tool markup cannot drive quadratic rescans. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser. +Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `......` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn and common `tex` typos normalize to `text`. Shared finite delimiter scanning lives in `src/text_scanning.py`; supported wrapper recovery and bounded scans are not a general linear-time guarantee for all parsing. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser. Current call sites include: @@ -108,9 +109,7 @@ Approval replay injects the sealed first tool result before the resumed model ro - `src.tool_capabilities` classifies each tool's effects and result integrity. Once external/workspace-untrusted content becomes model-visible, the request/session security context permits only explicitly low-impact tools without interruption and requires exact approval for high-impact, unknown, and arbitrary MCP calls. - `src.tool_approvals` seals an opaque, expiring exact first action plus server-only selected tools and continuation query to owner, session, origin run, tool content, workspace, capability snapshot, and—when relevant—document id/version/content digest. Chat choices grant the resumed task or the same chat session; both consume the exact first action, task scope bypasses the gate only during that resumed run, and chat scope is reconstructed only from a resolved card bound to the exact session id. The browser never receives selected tools/query and submits only task/chat/deny. Non-chat callers retain single-action behavior; new normal turns and superseding actions retire unresolved approvals without clearing taint. - Tool results that expose remote or stored untrusted content arm the gate even when their tool status is failed. Content-free failures and server-generated policy/approval placeholders do not. Native/provider tool messages and fenced results carry model-visible untrusted metadata/wrapping instead of relying on prompt wording alone. -- Attachment-bearing document, note, and calendar tools owner-reserve internal - upload references before durable writes and fail without mutation when the - referenced upload is unavailable. +- Attachment-bearing document, note, and calendar tools owner-reserve internal upload references before durable writes and fail without mutation when the referenced upload is unavailable. - Guide-only/no-tools mode blocks tools before prompt assembly, before execution, and in chat preprocessing paths that would otherwise fetch context or start tool-backed research. - Plan mode is policy, not prompt advice: mutating native tools are disabled through schema-derived detection plus a static backstop, and write/unknown MCP tools are hidden and runtime-blocked for that turn. @@ -134,12 +133,73 @@ When an email reader is active, browser chat passes active email metadata and th ## Degraded Behavior -- ToolIndex can degrade to keyword selection when embeddings, Chroma, index - warmup, or vector retrieval timeouts fail. -- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Local Ollama `/v1` and native `/api` endpoints default to text tools unless the endpoint explicitly advertises `supports_tools`; `gpt-oss` remains text-tool by default unless the endpoint opts in. +- ToolIndex can degrade to keyword selection when embeddings, Chroma, index warmup, or vector retrieval timeouts fail. +- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Legacy local Ollama `/v1` and native `/api` endpoints normally use text tools unless explicitly supported. Compact native surfaces force native schemas unless a text-recovery path is selected; configured compact/full modes and the external request contract participate in surface selection, so the legacy default is not universal. - MCP startup failure is non-critical; route/status surfaces expose per-server errors. - `ODYSSEUS_DISABLE_MCP`, missing `mcp`, uncached browser MCP packages, and per-server disabled tools can remove tools without blocking the app. -- Global `builtin_browser` disable behavior may not currently match qualified `mcp__builtin_browser__*` tool names. +- Native browser page execution is unavailable at the common boundary; browser registration and schemas do not override that denial. + +## Request Authority, Resource Identity, And Completion + +`src.turn_contract` owns immutable request-scoped tool selection and required-read operations. Selection and retrieval never grant permission. `src.agent_runtime.authority` owns server admission independently of offered schemas; `execute_tool_block()` requires an explicit security context and a bound `RequestAuthority`, intersects child authority with its parent, normalizes exact operations, and checks policy before resource resolution or dispatch. Delegated credentials have a separate early denial backstop. + +`src.agent_runtime.resources`, `resource_binding`, `owned_resources`, `remote_resources`, and `process_resources` bind operations to server-owned filesystem, owned-record, backend/configuration, process/job, and browser identities. Exact approvals seal these bindings and revalidate them before use; a changed backend, document, path identity, process incarnation, or browser state can refuse replay. Control-plane stores are excluded from native file operations. + +`src.agent_runtime.journal` records proposed, normalized, authorized, dispatched, and settled actions. `effects`, `effect_adapters`, and `effect_log` distinguish intent, executor outcome, observed state, and verified effects; a successful receipt alone does not establish resulting state. `completion.with_completion_gate` decorates `stream_agent_loop`, holds answer deltas until the generator unwinds, and checks completion against the server journal/evidence ledger while tool/progress/interaction events remain live. Cancellation cannot emit successful completion. `src.agent_trace` adapts native SSE, typed traces, and imported trajectories into evidence/completion views. + +`src.agent_runtime.runtime_selection` is the shared compact-preview eligibility rule. `src.clean_agent_preview` runs the opt-in clean v3 loop when its contract has `clean_compact_v3_preview` selection mode; regular/full-schema and compatibility surfaces retain the legacy loop. Preview schema validation, bounded retry/search/tool budgets, and evidence-based finish checks supplement the common executor rather than replacing its authority. + +Current native tools also include `apply_patch`, `get_workspace`, `host_shell`, `private_browser`, `get_weather`, `youtube_tool`, `inspect_media`, `extract_text`, `transcribe_media`, and `search_chats`. Filesystem, subprocess, web, weather, and media handlers own these implementations; `src.media_ingress` owns bounded local attachment ingestion, and OCR/transcription can depend on optional backends. + +## Compact Selection And Evidence Recovery + +`src.turn_contract` shares broad-web briefing classification with preview. Named private stores, active editor work, concrete local media and explicit operations take precedence over incidental public lookup words. Standalone social turns and full-match supplied-text proofreading/translation (including correction-only colon payloads) offer no tools; compound save/search requests remain separate. A subjectless lookup can request clarification only without conversation referents or supplied workspace/media/editor context. These lexical guards neither resolve arbitrary language nor grant authority. A core tool floor applies only without recognized families, exact selected tools or a sealed read, and is still intersected with inventory/policy; browser is not a core tool. + +Explicit discovery-and-page retrieval can select both web tools; workspace artifact requirements can retain bounded file/Python tools alongside web selection. Memory-use prohibitions suppress sealed memory prerequisites. Concrete media routes prefer OCR for exact text, transcription for captions, otherwise inspection; explicit downstream artifacts or personal actions remain distinct. Tool-family selection and multilingual heuristics do not guarantee execution order or semantic understanding. + +Preview rejects blank search queries rather than copying conversational text. It preserves requested temporal/news constraints and adds a PDF constraint only when asked. Discovery is bounded separately from source retrieval: no-evidence/repeated cosmetic searches suppress further search while discovered URLs can remain inspectable. Broad briefing recovery permits one breadth expansion after one successful search and fewer than two attempts, within the remaining round budget; an empty second search cannot restart that expansion. One shallow-answer repair uses an 80-word-or-missing-source-URL heuristic, not substantive grounding proof. Breadth work precedes presentation-only citation/content repair. + +Retrieval qualification uses bounded body/access/missing-page/repetition heuristics, including embedded article text and a 100-word/12-shingle duplicate detector. A successful retrieval prompts assessment of missing, stale or contradictory evidence rather than forcing completion. Browser fallback proposals cannot override the common page-execution denial. Failed transports can lead to alternative offered search/source retrieval, not an authorized automatic browser session. A rejected search may synthesize only from earlier usable retrieval. Terminal budget fallback preserves up to five deduplicated source strings with explicit preliminary/insufficient-evidence wording, or reports no usable evidence. Synthesized citations are not automatically filled from arbitrary discovered URLs; deterministic link-only answers require a positive whole-request grammar and enough extracted links. Unknown official-source identity is not inferred from PDF extensions or product words. + +Native tool choice normalization can make a uniquely offered named search required, narrow schemas before omitting an unsupported provider choice, or use named/auto compatibility for specific DeepSeek/Kimi/Qwen/Ajax families. It is serialization compatibility, not provider acceptance. Wire messages strip server-only provenance and empty assistant placeholders while preserving legitimate tool-call carriers. Sibling tool receipts remain contiguous before bounded untrusted visual blocks; still-image repeat identity includes path, casefolded query and size. Visual defaults retain one current record/three frames with a bounded 1–8 frame control and retire prior tool pixels separately from user uploads. + +Artifact completion is target-specific: off-target/scratch writes and generic successful shell receipts cannot satisfy a required artifact or force early synthesis. Empty runner declarations fall back to prompt-inferred artifacts. Directory requests require meaningful files; source-code writes can unwrap leading/trailing Markdown fences for allowlisted suffixes without certifying syntax. Successful workspace paths can display as `/workspace`, without implying universal path redaction. A reserved final synthesis and tightly bounded malformed-call recovery do not create extra execution authority. `turn_contract` and `agent_step` expose required artifacts/calls-used/pending/write-phase fields; these are progress telemetry, not completion proof. Execution timing metrics record attempted canonical tools even on failure. + +## Admission And Bounded Execution Details + +Chat-scope bearer admission resolves the credential owner and intersects delegated tool grants with server policy; client runtime metadata, retrieval results and prompted tool names cannot widen it. Interactive approval/session options are refused for delegated credentials. Persistent chat grants are server-signed; a normal new turn retires stale pending approvals, while exact first-action replay is one-use. For a non-inherited ungranted launch lacking a backend launch scope, `ToolApprovalStore.create()` may seal exactly one request resource root (or its sealed workspace fallback); inherited children cannot gain this scope. Local reads through delegated credentials, including local `web_fetch`, file-scheme/workspace reads and absolute-path PDF extraction, have an early denial backstop even when the automatic action gate is disabled or bypassed. + +`ODYSSEUS_TOOL_APPROVAL_GATE` defaults on; only normalized `0`, `false`, `no` and `off` disable it. The gate does not replace authority/resource checks. Request authority binds owner, request/thread, workspace and admitted operations; child scopes intersect the parent. Owned record revisions, backend/configuration epochs, filesystem identities and process/job generations are revalidated. Captured server producer observations supply evidence; arbitrary tool output or registry membership is not a trusted producer. Expected admission/resource failures become typed denials; unexpected programming errors are not silently converted into authorization success. + +Native file tools use the canonical agent workspace and explicit allowed roots, deny control-plane paths and all hardlinked regular files, and validate path/resource identities. `ls`, `glob` and `grep` share one control-plane snapshot per scan; root and bound-resource checks still observe fresh state. Grep normalizes relative-or-basename glob filters before bounded ripgrep batches, validates regex even with no targets, closes stdin and retains a bounded Python fallback with identity revalidation. Empty file writes require explicit clear intent for nonempty existing files; missing/null content is refused, already-empty files can be no-ops, and missing files use exclusive/staged creation without overwriting a competing creator. These checks do not make filesystem operations universally atomic or race-free. + +Bounded required-safe reads use an exact sealed operation from the admitted family; a presentation `max_items` limit is not an owner/database filter. Only one eligible active family is warmed and a failed read does not authorize probing alternate families. Bounded model context keeps actual visible records rather than hidden private overflow. Native call identifiers survive only when arguments match exactly; structured turn replacement preserves tool/result pairing. Supplied documents, email, Notes/Todos, skills and workspace instructions remain untrusted reference data. + +Compact preview keeps connections alive without replaying executed tools. It permits at most two qualifying context-size retries per turn with reconstructed paired messages and one pre-response transport retry; yielded stream content is not retried. Pre-execution schema hints are freshly generated from offered schemas/arguments (bounded required/type/enum/additional-property/path guidance). Caught execution exceptions instead use fixed public messages and error categories; raw exception details can remain in server logs. Terminal provider errors use fixed public failure events and do not emit a successful done event. + +Repeated successful exact calls are suppressed while changed arguments or pagination can proceed; repeated failed exact calls and ignored corrective nudges have separate bounded stop rules. Artifact workflows reserve bounded calls and require actual usable artifacts, not merely directory existence or a tool receipt. Compact interactive rounds clamp to 1–200 with an eight-round fallback, and nonpositive interactive tool budgets use a large sentinel. Validated native workspaces instead clamp to 1–64 rounds with an eight-round fallback and a fixed 32-call budget. Legacy adaptive handling and UI limits are separate. Budgets are checked between rounds and do not interrupt an already-running call. Optional verifier retries and teacher escalation are separate recovery controls; teacher work starts after the parent stream contexts close. + +Durable effects distinguish intended mutations, execution outcome and observed state. Claims are persisted before execution; failure to establish a durable claim prevents execution. The newest eligible authorized observation for a resource wins; partial/undecidable evidence cannot borrow an older success, unknown impacts overlap conservatively, and later mutations/new incarnations stale earlier reads. Whole-file postconditions are all-or-none when derivation is unsupported; partial reads are not whole-file digests. Failed reads establish absence only after confined repeated ENOENT checks. Verifier cancellation remains unsettled; changed undeclared targets contradict completion. Only owned running processes survive as running effects; background settlement does not by itself verify output. External execution may finish with disclosed unverified effects, rather than claiming local containment or verified remote state. Scheduled built-ins and arbitrary remote systems are not all covered by this journal. + +## Artifact Recovery And Completion Claims + +Artifact delivery contracts narrow offered writer schemas on copied schema objects and bind declared targets without treating directory descendants or filename patterns as filesystem confinement. Binary suffixes refuse text-writer delivery, including relevant bridge paths, and prefer an offered capable writer. Empty, malformed, off-contract, or non-materialized output uses bounded shared recovery attempts; pending sibling calls and late results do not justify claiming an artifact exists. Python literal-output analysis is a planning aid, not execution evidence. + +Compact tool-call parsing accepts bounded concatenated complete JSON objects with ordered call identities and normal admission checks. Limited trailing tool markup after a complete supported call is inert; arbitrary prose is not an executable call. Artifact research reserves bounded call/time budget for delivery rather than guaranteeing interruption of an already-running tool. + +Completion assessment in `src.agent_runtime` distinguishes a usable artifact, a satisfied obligation, and verified execution. File evidence uses exact normalized identity and bounded regular-file reads, rejecting symlink/nonregular aliases rather than trusting basename matches. Partial reads do not prove whole-file verification, late observations do not retroactively verify earlier claims, and executable verifier results take precedence over optimistic prose. + +Final presentation can remove unsupported execution, test-count, coverage, or all-fixed assertions while preserving quoted/explanatory text. It does not invent new obligations from fabricated assistant claims. Provider errors, approval waits, and budget exhaustion remain distinct terminal states; a polished answer is not an execution receipt. Child journals remain separate, teacher review follows closed child execution, and incomplete children do not become successful distillation evidence. Explicit no-web filtering cannot be undone by an unrelated memory/toolset early return. + +## Parser And Diagnostic Ownership + +`src.text_scanning` supplies forward-only shared scans for the existing staged tool grammars, preserving delimiter priority, mandatory terminal-dot captures and legacy newline behavior. The parser does not become a universal linear-time or arbitrary-language recognizer. Binding catches expected input/resource errors as curated denials; programming defects such as AttributeError are allowed to propagate rather than being relabeled as policy refusal. + +Filesystem postconditions derive exact requested content from the producer transforms, including edit newline behavior and universal-newline patch prestate, with bounded identity-checked UTF-8 reads. Unsupported derivation clears the complete obligation set instead of verifying a convenient subset. Only a process-bound launch or detachment can attest RUNNING; unrelated returned lifecycle keys, arbitrary containment dictionaries and external acknowledgements cannot manufacture native cleanup or job observations. + +## Trusted MCP Attribution + +`src/tool_execution.py` strips caller-provided `_odysseus_*` fields from qualified/legacy MCP JSON and parsed tool arguments. Dispatch then injects trusted owner/session context for email and trusted owner for image MCP tools; missing trusted context does not preserve a forged claim. `_call_mcp_tool()` forwards owner through native/unavailable/disconnected direct fallback paths. Image results from qualified dispatch receive promoted image fields. The image MCP server persists the trimmed owner and registers fresh files; this transport attribution does not replace route authorization. ## Current Gaps @@ -147,11 +207,7 @@ When an email reader is active, browser chat passes active email metadata and th - Agent prompts remain heavy for small local context windows. - Some AI-control helpers are still globally wired from app startup rather than a narrower service layer. - Tool registry consistency is manual across handler maps, tags, aliases, schemas, retrieval descriptions, execution dispatch, settings/model routes, and frontend toggles. -- MCP disabled-tool changes can stale-cache tool retrieval because disabled maps are not always an index generation input. +- Registry and retrieval consistency still requires coordinated updates across native and MCP surfaces. - External MCP output still needs a single central size cap before model re-entry; untrusted-result metadata and the post-external-context action gate now cover the prompt-injection/authorization boundary. - Auth-disabled/no-login owner propagation is inconsistent between route dependencies and chat/agent execution, so tool-security and native tool storage behavior need dedicated regression coverage. -- Agent tests mostly cover helpers and targeted regressions, including round-cap - and disconnect cancellation paths, but not an end-to-end fake-LLM - `stream_agent_loop` path with retrieval, native schemas, prompted blocks, - disabled/admin hiding, MCP tools, plan/workspace state, user-time context, and - tool-result SSE. +- Authority, resources, effects, containment, preview, and turn contracts have focused regression suites; live provider and platform behavior still requires separate validation. diff --git a/specs/auth-security.md b/specs/auth-security.md index bc1d6ec0d..219bfe2fc 100644 --- a/specs/auth-security.md +++ b/specs/auth-security.md @@ -1,6 +1,6 @@ # Auth And Security -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -20,8 +20,7 @@ This spec covers current security and trust-boundary behavior in: - `src/prompt_security.py`; - `src/url_safety.py` and `src/url_security.py`; - `src/host_docker_access.py`; -- `src/attachment_refs.py` and upload lifecycle enforcement in - `src/upload_handler.py` / `routes/upload_routes.py`; +- `src/attachment_refs.py` and upload lifecycle enforcement in `src/upload_handler.py` / `routes/upload_routes.py`; - `src/secret_storage.py`; - `src/api_key_manager.py`; - `src/integrations.py`; @@ -56,7 +55,7 @@ Reserved usernames include request-only sentinels `internal-tool`, `api`, `demo` Public/auth-exempt surfaces are limited to setup, signup/login/logout/status, feature/settings/integration preset reads, health/version/login, `/static/*`, and task webhook trigger paths. `routes/task/task_routes.py` owns validation of `POST /api/tasks/{task_id}/webhook/{token}` path credentials. -Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py:89`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact. +Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact. Deleting a user revokes that user's browser sessions and API-token rows, then the admin delete route invalidates the in-memory bearer-token cache so already-cached tokens stop authenticating. @@ -68,40 +67,26 @@ Admin promotion/demotion is a live auth flag change through `AuthManager.set_adm Cookie requests use the real username. Bearer-token requests are stamped as `request.state.current_user = "api"` plus `api_token_owner`, `api_token_scopes`, and token id. Routes that support API-token access must explicitly use `effective_user()` or route-local scope helpers instead of treating `"api"` as an owner. -Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`; `require_admin()` trusts the stamped sentinel or raw internal header and should be used behind equivalent middleware control. +Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`. Native shell/PID and local Cookbook controls add resource-bound admission and reject an internal header or sentinel as human administration. This boundary is surface-specific: common `core.middleware.require_admin()` still admits a matching internal token or sentinel, including MCP configuration routes; producer-side tool policy is a separate check. Missing-owner values remain state-dependent at legacy call sites, but new storage-facing code has one normalization contract: - Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies. -- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode. - Native shell and local Cookbook administration additionally require a direct - loopback connection without proxy forwarding, cross-site indicators, or an - internal-tool header. Remote/proxied anonymous traffic remains denied at - these controls. Auth-enabled administration still requires a human admin. - This mode trusts local programs as well as the local operator: a headerless - loopback request cannot identify which local program sent it. Agent program - launches retain inherited networking; this is not protection against hostile - local code. Use authenticated mode when local programs are outside that trust. - Local Cookbook tools use a separate one-use capability for an admitted exact - request/operation/native backend and resolved launch body; that capability - cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes. +- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode. Native shell and local Cookbook administration additionally require a direct loopback connection without proxy forwarding, cross-site indicators, or an internal-tool header. Remote/proxied anonymous traffic remains denied at these controls. Auth-enabled administration still requires a human admin. This mode trusts local programs as well as the local operator: a headerless loopback request cannot identify which local program sent it. Agent program launches retain inherited networking; this is not protection against hostile local code. Use authenticated mode when local programs are outside that trust. Local Cookbook tools use a separate one-use capability for an admitted exact request/operation/native backend and resolved launch body; that capability cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes. - Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state. - SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller. - `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior. - `__odysseus_local__` is a valid storage owner but never a login or request sentinel. Adoption is incremental: callers that do not use the storage-owner helper can still expose older `None`/empty/null compatibility behavior. -Authenticated `manage_tasks` mutations require an exact stored task-owner -match and reject both cross-owner and legacy null-owner rows. The `owner=None` -agent path keeps deliberate auth-disabled single-user compatibility, including -unscoped list/create/mutation behavior. +Authenticated `manage_tasks` mutations require an exact stored task-owner match and reject both cross-owner and legacy null-owner rows. The `owner=None` agent path keeps deliberate auth-disabled single-user compatibility, including unscoped list/create/mutation behavior. Owner-scoped route code should use `require_user()` or equivalent policy before querying per-owner data. Current note CRUD/reorder/reminder routes do this so an auth-enabled request that reaches the route without identity returns `401` instead of falling into single-user/null-owner compatibility behavior. -Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py:1231`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py:865`), the `manage_tasks` agent tool (`src/tools/system.py:469`), webhook triggers (`routes/task/task_routes.py:1045`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py:1063-1074`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path. +Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py`), the `manage_tasks` agent tool (`src/tools/system.py`), webhook triggers (`routes/task/task_routes.py`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path. ## API Tokens And Scoped Integrations -`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` caches active token prefix rows and verifies bearer tokens with bcrypt. API-token requests set `request.state.current_user = "api"` plus token owner/scopes. +`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` builds a complete replacement active-prefix map and publishes it by reference, including `app.state`, before verifying bearer tokens with bcrypt. Readers holding an older map are not emptied; this does not guarantee freshest revocation visibility or serialize overlapping refreshes. API-token requests set `request.state.current_user = "api"` plus token owner/scopes. Current call sites include Codex/Claude scoped APIs, `/api/v1/chat`, webhooks, selected session routes, companion pairing, and external integrations. `/api/codex/*` and `/api/v1/chat` enforce route-local scopes; companion and selected session routes use owner attribution. `companion/pairing.py` can mint chat-scoped tokens outside normal token CRUD. @@ -137,21 +122,12 @@ Current untrusted surfaces include fetched URLs, web results, emails, memories, - `src/url_security.py` owns public HTTP(S) validation for integration/API-token supplied URLs. It should fail closed for private IP, loopback, invalid scheme, and unsafe redirect targets. - `src/url_safety.py` owns local-first outbound URL safety for model endpoints and similar local services. Loopback/LAN can be allowed by default, and private-IP blocking is an explicit caller policy. Strict `block_private=True` also rejects RFC 6598 shared/CGNAT space (`100.64.0.0/10`) explicitly because Python does not classify that range as private. - `core.log_safety.redact_url()` strips URL userinfo, query strings, and fragments before endpoint URLs enter logs. Model, chat/research endpoint, contact/CardDAV, and similar diagnostics should use this helper instead of logging raw admin-configured URLs. -- `src.webhook_manager` validates webhook URLs at create and delivery time, - rejects private/internal targets, disables redirects, and pins delivery to - the public IP set that passed validation immediately before the request. -- `src.integrations` owns admin-configured integration base URLs and secret - masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity. +- `src.webhook_manager` validates webhook URLs at create and delivery time, rejects private/internal targets, disables redirects, and pins delivery to the public IP set that passed validation immediately before the request. +- `src.integrations` owns admin-configured integration base URLs and secret masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity. - `src.outbound_fetch` owns reusable public-URL classification, validates every redirect hop, rejects private/local resolved addresses, and pins the HTTP connection to the validated public IP while preserving original URL/SNI/Host semantics. `services.search.content` adapts that transport for extraction and caching. - Path-based tools, upload/document/gallery/signature/generated-image routes, embedding cache paths, and research JSON helpers must stay confined to allowed roots and owner-scoped files. Native file/code-navigation tools also apply a case-insensitive sensitive-path denylist so `grep`, `glob`, `ls`, direct reads, and writes cannot reveal `.env`, SSH/GPG material, private-key filenames, or similar secret paths. -- Durable upload references are owner-reserved before chat/session, document, - note, or calendar writes. Cleanup scans every current durable reference - surface and fails closed on incomplete discovery or inconsistent upload-index - state rather than deleting a possibly live upload. -- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM - sidecars to `0600` on POSIX after resolving the real path from the parsed - engine URL. Windows, in-memory, and non-SQLite databases are excluded, and - failed POSIX restriction is logged as a secret-file warning. +- Durable upload references are owner-reserved before chat/session, document, note, or calendar writes. Cleanup scans every current durable reference surface and fails closed on incomplete discovery or inconsistent upload-index state rather than deleting a possibly live upload. +- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM sidecars to `0600` on POSIX after resolving the real path from the parsed engine URL. Windows, in-memory, and non-SQLite databases are excluded, and failed POSIX restriction is logged as a secret-file warning. - Secret-like DB columns use `EncryptedText` or `src.secret_storage`. Email passwords and Google OAuth mail tokens are encrypted manually in `EmailAccount` string columns; Google OAuth state is HMAC-signed and callback writes are owner-checked before token storage. `src.api_key_manager` keeps provider API keys encrypted in `data/api_keys.json`, writes by loading the raw encrypted dict so saving one provider does not rewrite other providers' keys as plaintext, and restricts local key-file permissions where the platform supports chmod. Vault state in `data/vault.json` is a chmod-restricted JSON secret store, not Fernet-encrypted DB storage. Do not log or return decrypted secrets except for intentional admin vault retrieval flows with audit/reason checks. - `.env` files are secrets-only inputs and should not be read or printed during agent work. @@ -170,11 +146,39 @@ Host Docker socket access is a high-trust admin/deployment choice, not a normal - Legacy migrations claim null-owner SQL/JSON data for the primary admin when possible, and startup repeats a null-owner sweep hourly. Remaining null-owner rows are surface-specific compatibility data that must be deliberately included, no-oped for single-user mode, or rejected for strict ownership gates. - `.env` is loaded with `utf-8-sig`, so Windows BOM auth flags still parse. +## Server Admission And Model Execution + +`src.agent_runtime.authority.RequestAuthority` is server-owned admission separate from schema availability or intent selection. `execute_tool_block()` requires explicit security context, verifies owner/session/workspace binding, intersects delegated child authority, applies policy denial before approval/bridge dispatch, and resolves typed backend/filesystem/owned/process/browser resource identities. Exact approvals seal and revalidate these bindings; task/chat taint grants cannot manufacture absent backend or resource authority. + +Native filesystem operations use `src.path_confinement` and `src.agent_runtime.resources` to reject sensitive/control-plane paths, including runtime effect stores and aliases. Native model shell/Python paths use enforcing containment with required filesystem, process-tree, and wall-clock dimensions; missing required enforcement fails before spawn. This is distinct from direct operator/admin HTTP shell access and declared external execution bridges. Network and resource-limit dimensions are not automatically promised by the default requirements. + +`core.auth.AuthManager.reset_user_password()` permits an admin to reset a non-admin account and revokes that account’s sessions. Admin targets and non-admin callers are refused. + +## Scoped Admission And Public Error Details + +Browser chat/session/history bearer support requires chat scope and resolves the token owner. Delegated callers cannot approve actions, inherit browser chat grants, supply raw session credentials or bypass route validation. Resolved persistent chat grants are HMAC signed and checked; client metadata removes authority-bearing fields. Parent/child task and teacher authority intersects, and local file/workspace/PDF reads have delegated early denials independent of the optional action gate. The automatic high-impact gate defaults on, disabled only by explicit normalized false values; stale threat-model prose describing unrestricted model filesystem access is not the current native contract. + +Local-first URL checks classify IPv4-mapped addresses by embedded IPv4 and decode exactly `64:ff9b::/96` with strict embedded-target policy, even when the caller allows private services. Other translation prefixes receive no inferred exemption. IPv6 loopback is checked before reserved-address denial and follows the configured private policy. Mail connection helpers validate the resolved address set and pin actual TCP connection; scholarly metadata validation does not inherit that stronger transport claim. + +Host/client run/read/write/patch/poll/cancel bridge clients disable environment proxy trust and carry exact admitted authority, while external execution remains unattested local containment. MCP backend/config and Vault unlock/catalog identities are epoch/revision bound. Native browser page actions and raw global Playwright calls are currently denied unconditionally; metadata-only `session_info` does not restore them. Native filesystem root checks reject NUL/CRLF and all hardlinked regular files, but do not imply every OS path operation is atomic. + +Preview validation may expose bounded schema-derived argument guidance before dispatch; caught execution exceptions use fixed public errors/categories. Raw exception text can remain in server logs. Transport/provider terminal errors are distinct from successful completion, and public assertion filtering is not a general secret scrubber. Upload reference reservations, scoped owners and runtime credential custody continue to apply independently. + +## Configuration And Generated-Media Boundaries + +Auth-route app configuration uses `_require_admin_user()` to honor explicit disabled-auth operator mode for settings/features, document-style extraction and integrations; user-store administration still requires an admin identity. Settings GET exposes full settings to the disabled-auth operator, while auth-enabled anonymous/non-admin reads remain scrubbed. This is a trusted deployment mode, not an anonymous multi-user grant. + +Generated-image serving checks filename syntax before identity, then `effective_user()` (cookie or API-token owner) before disk access. A live process-local registration, gallery owner, linked session owner, or owned-chat metadata reference can authorize access; absence/mismatch returns 404 and database verification errors return 503. Disabled auth skips ownership checks. Responses use private revalidation and nosniff headers. Tool argument parsers strip caller `_odysseus_*` fields before server context injects trusted image/email owner/session attribution; see `gallery-editor-media.md` and `agent-tools.md`. + +## Upload Route Authorization + +Upload routes distinguish disabled-auth operation from unavailable/unconfigured auth infrastructure: explicit no-login bypasses owner gates; auth-enabled missing manager is 503, unconfigured manager 401 and missing/sentinel identity 403, without loopback fallback. Interactive admin override remains available for non-owned/legacy ownerless files. Delegated API credentials require a real token owner, matching non-null file ownership and an appropriate scope; even an admin-owned token has no cross-owner or ownerless override. Read scopes are any of `chat`, `documents:read`, `documents:write`; upload creation/OCR mutation requires `chat` or `documents:write`. See `documents-rag-uploads.md` for transition behavior. + ## Current Gaps -- There is no shell/filesystem sandbox for admin tools. +- Direct operator/admin HTTP shell execution remains powerful; model-code containment and typed admission must not be generalized into a sandbox claim for every administrative surface. - Token scopes remain coarse for some surfaces. -- `app.py` AuthMiddleware lacks direct regression coverage for bearer-token state/cache behavior, trusted-loopback proxy-header rejection, and internal-tool owner stamping. +- Auth policy, scoped tokens, and request authority have focused regression tests; full middleware composition and live proxy deployments still need separate validation. - Codex/Claude scoped route enforcement still needs stronger regression coverage. - `THREAT_MODEL.md` still has stale token-scope and `/api/v1/chat` SSRF gap text that should be reconciled with current route validation. - The Default/Local owner contract is canonical but only incrementally adopted; route helper `""`, chat/agent `None`, SQL/JSON null-owner compatibility, and calendar fallback owner behavior still need domain-by-domain migration decisions. diff --git a/specs/calendar-tasks-notes.md b/specs/calendar-tasks-notes.md index b3259c932..33386abd1 100644 --- a/specs/calendar-tasks-notes.md +++ b/specs/calendar-tasks-notes.md @@ -1,6 +1,6 @@ # Calendar, Tasks, And Notes -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -18,7 +18,7 @@ This spec covers calendar, reminders, tasks, assistant runs, and notes in: - scoped Codex wrappers in `routes/codex_routes.py`; - database models `CalendarCal`, `CalendarEvent`, `ScheduledTask`, `TaskRun`, `Note`, and `CrewMember`; - direct DB CLIs `scripts/odysseus-calendar`, `scripts/odysseus-notes`, and `scripts/odysseus-tasks`; -- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, `static/js/notes.js`, and `static/js/assistant.js`; +- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, the `static/js/notes.js` compatibility facade and maintained `frontend/src/features/notes/` graph deployed under `static/js/features/notes/`, and `static/js/assistant.js`; - tests covering calendar routes/utilities, CalDAV, recurrence, timezone handling, scheduler behavior, task webhooks, notes CLI/tool behavior, and task CLI behavior. ## Calendar @@ -53,6 +53,8 @@ Cookbook serve scheduling crosses this domain. The Cookbook UI creates `cookbook `routes.task.task_routes` owns task CRUD, status, manual run/stop/cancel, pause/resume, owner-scoped run/activity history, metadata, onboarding defaults, cache clearing, parse endpoints, and webhook-token regeneration. `app.py` imports the canonical package path; `routes/task_routes.py` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Chained-task `then_task_id` values are validated as same-owner relationships on create/update, and scheduler execution also rejects cross-owner or cyclic chains. +`src.tools.system.do_manage_tasks()` translates structured schedules: `day_of_month` is an integer 1–31 for monthly tasks, while recurring `weekdays` names plus `scheduled_time` in UTC `HH:MM` become a cron expression. Conflicting day/cron inputs are rejected. One-off `scheduled_date` values are parsed to naive UTC and must be future dates; recurring schedules reject supplied one-off dates, and invalid cron expressions are refused. A time-only edit of an existing five-field cron rewrites its minute/hour fields while preserving calendar fields and recomputes `next_run`; it does not merely update the legacy display clock. + Task webhook paths are auth-exempt at the app middleware layer only for `/api/tasks/{task_id}/webhook/{token}`. The route still validates active task state plus task-specific webhook token before dispatch. Task runtime behavior: @@ -65,25 +67,16 @@ Task runtime behavior: - event-bus triggers persist counters and `next_run` before scheduler handoff; - the in-process scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`, and multiple enabled app processes can double-run work. - action tasks with `run_local`, `run_script`, `ssh_command`, or - `cookbook_serve` are admin-only. `routes.task_routes` enforces this on - create/update/manual run and hides those actions from `/meta/actions` for - non-admin owners; webhook and scheduler execution pause the task and clear - `next_run` if an admin-only action belongs to a non-admin owner. + `cookbook_serve` are admin-only. `routes.task_routes` enforces this on create/update/manual run and hides those actions from `/meta/actions` for non-admin owners; webhook and scheduler execution pause the task and clear `next_run` if an admin-only action belongs to a non-admin owner. - background LLM task execution uses the background workload path, and the - scheduler can abort/cancel active in-process task runs when foreground browser - activity appears. + scheduler can abort/cancel active in-process task runs when foreground browser activity appears. - `tidy_research` scans all persisted research files because broken JSON has no trustworthy owner stamp, so it runs only for admins or the explicit auth-disabled single-user operator and refuses regular/pre-setup callers before enumeration. `routes.assistant_routes.py` owns crew/assistant settings and run-status surfaces that use the scheduler. `TaskScheduler.ensure_assistant_defaults()` currently seeds the personal assistant crew member and pinned assistant session, but no longer auto-creates Morning/Midday/Evening check-in tasks. Existing crew-linked check-in tasks are still rendered and managed when present. ## Notes And Reminders -`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that -canonical path. `routes.note_routes` replaces its module entry with the -canonical module for legacy import and monkeypatch compatibility. Notes are -SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat -state, AI classification, source/session provenance, and agent session -linkage. +`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that canonical path. `routes.note_routes` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Notes are SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat state, AI classification, source/session provenance, and agent session linkage. Notes CRUD/reorder/reminder routes resolve the acting owner through `require_user()`: auth-enabled anonymous requests fail closed before hitting owner-scoped queries, while documented no-login/single-user modes still resolve to the compatibility owner path. @@ -105,17 +98,15 @@ Reminder dispatch is Note-owned: Email/ntfy failures degrade into channel result fields rather than blocking every reminder path. ntfy and generic webhook reminder URLs run through outbound URL safety checks, with `REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS` controlling whether private/LAN targets are allowed. ntfy notification titles are converted to ASCII with replacement and capped at 200 characters before entering HTTP headers. Reminder dedupe uses owner-scoped cache files under `data/`. +`src.builtin_actions.action_ping_notes()` counts delivery only when `dispatch_reminder()` reports success for the configured browser/email/ntfy/webhook channel. Typed receipts in `data/note_pings_.json` record time and channel. A browser fallback or legacy timestamp does not prove external delivery: the next eligible scan retries the configured external channel without queueing the recent browser fallback again. The scanner reloads dispatcher-owned receipts before pruning so it cannot replace a successful channel receipt with a timestamp; total delivery failure is not checkpointed. Retries occur only within 90 seconds of the due time, while recent matching-channel receipts suppress delivery for 25 minutes. Existing retry tests cover webhook/ntfy failures, legacy receipts, browser dedupe, total failure, and receipt-preserving pruning; live channel delivery remains separate validation. + ## Agent, Codex, And CLI Surfaces `do_manage_tasks`, `do_manage_notes`, and `do_manage_calendar` own agent-side writes. `do_manage_calendar` supports batch event creation plus list range aliases (`start`, `start_time`, `start_date`, `range_start`, `from`, `dtstart`, `since`, and matching end aliases), calendar name/short-id lookup, importance/tag aliases, and reminder offsets expressed as numbers, minute/hour words, or common abbreviations such as `min`/`mins`/`hr`/`hrs`. If a model supplies a loose `query`, `date_range`, or `range` without explicit start/end datetimes, `list_events` returns an error asking the caller to resolve the range and call again instead of guessing. Event classification reads `Memory.text` for personal context before LLM classification. `src.tool_index` encodes the reminder policy that notes/todos own reminders while calendar events own time blocks. Agent native tool owner handling is not uniform today. `do_manage_tasks()` filters lists only when `owner` is truthy and creates tasks with the passed owner, so `owner=None` can create legacy/null-owner tasks. For authenticated/non-empty owners, edit/delete/pause/resume/run require an exact stored owner match and reject both cross-owner and null-owner rows; `owner=None` retains single-user compatibility. `do_manage_notes()` list/query behavior distinguishes `None` from `""`, with `None` acting as broader single-user compatibility while `""` filters to empty-owner rows in some paths. `do_manage_calendar()` query helpers filter only when owner is not `None`, while calendar creation routes through the calendar fallback owner for default calendars. These are compatibility behaviors, not a cross-user sharing model. -Note and calendar route/tool writers owner-reserve any canonical internal upload -references in content, checklist/color/image fields, descriptions, and -locations before their database writes. Missing or wrong-owner uploads fail the -write instead of creating a dangling durable reference; reservations serialize -with upload cleanup. +Note and calendar route/tool writers owner-reserve any canonical internal upload references in content, checklist/color/image fields, descriptions, and locations before their database writes. Missing or wrong-owner uploads fail the write instead of creating a dangling durable reference; reservations serialize with upload cleanup. Chat forwards browser timezone offset and IANA timezone name so natural-language note/calendar tools can anchor dates to the user clock. A valid IANA zone wins over the fixed offset for current-time/DST reasoning; invalid or absent names fall back to the offset and then server-local/UTC compatibility behavior. Chat can auto-promote note/calendar/reminder intents to agent mode. @@ -171,9 +162,32 @@ Existing coverage is strongest around CalDAV URL hardening/writeback, client cle Route-level coverage is thinner for full calendar route behavior, task CRUD/security/run controls, live webhook token dispatch, notes owner CRUD/reminder delivery, assistant defaults/run status, event-bus triggers, Codex todo/calendar scopes, and frontend panel wiring. +## Calendar Reminder And Invitation State + +Calendar event responses now include reminder note id, due time, minutes-before, and source-email provenance. Create/update accept `reminder_minutes`; an explicitly supplied null clears the reminder, while omission preserves it. Reminders are owner-scoped `Note` todos with calendar label/source. Passed events skip creation, and a reminder whose intended time passed is clamped to now. Legacy matching uses generated title plus due time rather than an event UID; deleting reminders uses the title/source/owner match, so similarly named events remain a compatibility-sensitive boundary. `GET /api/calendar/events/{uid}` resolves recurring occurrence IDs back to the owner-visible base event. + +`src/email_calendar_import.py` owns invitation revision state through `EmailCalendarInvitation`: identity hashes owner, normalized sender, ICS UID, and optional recurrence id instead of letting untrusted ICS UIDs address arbitrary local events. Sequence/DTSTAMP ordering suppresses stale replays, cancellation wins equal-version ties and leaves tombstones, and detached occurrences exclude their original series instance. Fixed striped file locks serialize imports across processes. Unsupported reply methods are ignored, and `THISANDFUTURE` revisions require a replacement series. + +Task create/update persist sealed `request_authority_json`; execution binds that authority to LLM/research work rather than deriving permission from task output. Task endpoint resolution uses owner-visible registered endpoint bases or their exact derived chat URL before runtime credentials are attached; scheme, host, effective port and full trimmed path must match, with no userinfo, parameters, query or fragment. Legacy shared endpoint visibility is retained. The scheduler helper catches credential resolution exceptions and can continue with empty headers, so this is not a universal no-network-on-resolution-failure guarantee. Authority/effect contracts are owned by `src/agent_runtime`, not the scheduling layer. + +## Scheduler Cache And Native Task Details + +Scheduler `_cached` requests share one pending future per key with a TTL. Waiters shield the shared work from their own cancellation; completion/failure removes only the matching pending future, and failures can retry rather than being cached as success. Endpoint discovery separately caches successful empty Tailscale results, while discovery failures do not become successful empty cache entries. + +Task/assistant endpoint updates accept registered owner-visible routes, canonicalize empty values to no override and reject invalid routes. Local shell/script/Cookbook scheduled actions require their sealed native backend capability; missing capabilities fail closed and raw external/SSH execution cannot borrow it. Headless task/background continuations close their streams in the originating task, preserve authority, honor replacement final responses and distinguish provider failure from successful completion. + +`NotificationLog` GET is owner-scoped and non-consuming, defaults to 200 rows and clamps to 1000. Notification insert title/body limits are 200/2000 characters; notification text does not grant task authority. Calendar reminder matching is legacy title/source/owner-based rather than UID-linked: nearest eligible due time within zero to seven days of the base event start can match, while deletion removes all same-title matches rather than discriminating by exact alarm time. Negative offsets clamp to zero, passed events do not create reminders, passed alarm time clamps to now, explicit null clears and omission preserves. + +## Notes Frontend State And Lifetimes + +The canonical Notes composition entry owns one `createNotesState()` instance, injected HTTP/storage/notification ports, application workflows and UI factories. Domain modules own note rules and local-time recurrence. Application modules own query/order, optimistic submission/card/checklist/deletion, selection, drafts, agent actions and reminders; UI modules delegate mutations and transport through those operations. The legacy facade preserves default/named open/close/toggle/new/openNote/badge methods and aliases, including named-only reminder-dot dismissal, without exposing mutable state. + +Background startup is idempotent: after three seconds it loads active notes, then starts a 30-second reminder interval and immediate check. Close/minimize/force-close dispose panel resources but leave that page-owned interval running. Scans use the current shared note collection, skip archived/date-only/fired notes, fire within the previous minute, and advance or mark older reminders; archived-only/error-cleared lists can still defer active reminders. Server synthesis has a 1.5-second local-notification fallback, with toast/highlight persistence. This browser path remains separate from backend delivery receipts. + +Drafts persist synchronously on input/change and serialize existing-note network autosaves after 500 ms; successful saves clear only the matching draft, while failure/new/drawing drafts remain recoverable. Canvas bitmaps are not draft JSON. Explicit drawing save awaits PNG upload before optimistic submission. Selection dispatches `odysseus:notes-context-selected` synchronously and exits only when the consumer sets `accepted`; chat retains context/session authority. Closing releases owned portals/listeners/gestures/observers, and late fetch/masonry work checks panel connectivity/open state. It does not add universal async generation guards: fetch remains last-completion-wins, optimistic creation replaces the first remaining temporary note, and bulk actions can swallow individual failures before refetch/reporting requested counts. + ## Current Gaps -- CardDAV still needs URL hardening parity with CalDAV; CalDAV now resolves hostnames during validation and revalidates writeback URLs. - `do_manage_notes()` should match HTTP note-route owner behavior for legacy null-owner notes. - Auth-disabled agent tools can produce or read broader owner scopes than route handlers because they receive `owner=None`; tasks, notes, and calendar need aligned policy/tests. - Task webhook tests should keep exercising live route token behavior and diff --git a/specs/chat.md b/specs/chat.md index ca350f692..f843e72ae 100644 --- a/specs/chat.md +++ b/specs/chat.md @@ -1,20 +1,19 @@ # Chat -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope This spec covers current chat behavior in: - `routes/chat_routes.py` and `routes/chat_helpers.py`; -- `routes/session_routes.py` and canonical `routes/history/history_routes.py`, - with `routes/history_routes.py` as a compatibility shim; +- `routes/session_routes.py` and canonical `routes/history/history_routes.py`, with `routes/history_routes.py` as a compatibility shim; - `src/chat_helpers.py`; -- `src/agent_runs.py`; +- `src/agent_runs.py` and `src/background_tool_jobs.py`; +- `src/turn_contract.py`, `src/request_models.py`, and `src/agent_runtime/` request authority/runtime selection/context resolution; - `src/chat_handler.py` and `src/chat_processor.py`; - `core/session_manager.py` and `core/models.py`; -- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment - references and write reservations; +- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment references and write reservations; - `src/context_budget.py`, `src/context_compactor.py`, and `src/topic_analyzer.py`; - `src/foreground_model_routing.py`, `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`; - `routes/workspace_routes.py` for workspace selection support; @@ -41,16 +40,10 @@ Runtime behavior: - browser chat sends `X-Tz-Offset` and an IANA timezone name; request-local helpers prefer a valid IANA zone for DST-aware current-time reasoning, then fall back to the fixed offset; - browser chat can send a selected workspace path; route code only resolves it for admin/single-user flows, validates it as an existing directory, and forwards it so agent file/shell tools are confined by `src.tool_execution`; - stream callbacks can outlive a deleted session, so persistence must fail closed instead of recreating orphan messages; -- message metadata carries timestamps, metrics, tool events, sources, hidden - thinking/reasoning text when providers expose it separately, context-trim - metrics, structured attachment references, and related UI state; +- message metadata carries timestamps, metrics, tool events, sources, hidden thinking/reasoning text when providers expose it separately, context-trim metrics, structured attachment references, and related UI state; - metadata preserves requested and actual reply models and endpoints, per-round route transitions, and answering-route cost attribution; stable session ids remain available so prompt/sequence-memory and KV-cache paths can address the same conversation consistently; -- multimodal content can be a list of content blocks for the live provider call, - while persistence collapses raw media into readable text and stable - attachment-reference lines; -- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop, - and intent-without-action guard events so the frontend can distinguish a - controlled stop from a stalled response. +- multimodal content can be a list of content blocks for the live provider call, while persistence collapses raw media into readable text and stable attachment-reference lines; +- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop, and intent-without-action guard events so the frontend can distinguish a controlled stop from a stalled response. `src.agent_runs` owns detached in-memory stream runs, replay buffers, replacement cancellation, resume subscribers, explicit stop, and terminal-buffer eviction. Closing the SSE connection does not necessarily stop generation. `static/js/chat.js` can live-resume a still-running detached stream through `/api/chat/resume/{session_id}`; rich responses reload from DB for canonical rendering. Detached runs are process-local and do not survive server restart. @@ -81,30 +74,15 @@ Current call sites include: - deep research orchestration in `src/research_handler.py`; - compare entry points in canonical `routes/compare/compare_routes.py` and frontend compare modules. -Agent-mode tool access is gated in layers. Chat route toggles and privileges -build a disabled-tool set; incognito and compare mode remove persistence-heavy -or UI-breaking tools; `src.action_intents.message_needs_tools()` provides -conservative regex auto-escalation hints; `src.agent_loop`, -`src.tool_security`, `src.tool_execution`, and internal loopback validation -remain server-side enforcement owners. +Agent-mode tool access is gated in layers. Chat route toggles and privileges build a disabled-tool set; incognito and compare mode remove persistence-heavy or UI-breaking tools; `src.action_intents.message_needs_tools()` provides conservative regex auto-escalation hints; `src.agent_loop`, `src.tool_security`, `src.tool_execution`, and internal loopback validation remain server-side enforcement owners. `allow_bash` and `allow_web_search` can be read from the JSON request body for browser chat posts that do not submit traditional form fields. -Web search tools are per-turn explicit opt-in. Either `allow_web_search=true` -or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit -`allow_web_search=false` wins over `use_web=true` and keeps those tools -disabled. Explicit latest-turn web-search intent can still auto-escalate into -agent mode and narrows the available tool set toward `web_search`/`web_fetch`, -but it no longer re-enables web tools after an explicit denial or global -disable. +Web search tools are per-turn explicit opt-in. Either `allow_web_search=true` or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit `allow_web_search=false` wins over `use_web=true` and keeps those tools disabled. Explicit latest-turn web-search intent can still auto-escalate into agent mode and narrows the available tool set toward `web_search`/`web_fetch`, but it no longer re-enables web tools after an explicit denial or global disable. Guide-only/no-tools requests build an effective tool policy before preprocessing and agent dispatch. That policy suppresses tool-backed preprocessing/background extraction/research, disables schemas and MCP for the turn, and is still enforced by `src.tool_execution` if a model emits a tool call anyway. -When route context is trimmed without full compaction, chat emits a -`context_trimmed` SSE event and carries before/after message/token counts into -metrics. Provider reasoning/thinking deltas are streamed for live UI handling -but kept out of the visible saved assistant content and stored in metadata when -available. +When route context is trimmed without full compaction, chat emits a `context_trimmed` SSE event and carries before/after message/token counts into metrics. Provider reasoning/thinking deltas are streamed for live UI handling but kept out of the visible saved assistant content and stored in metadata when available. ## Attachments @@ -112,12 +90,11 @@ available. Attachment-only sends are valid. Missing or unauthorized ids are skipped during preprocessing, while a missing/wrong-owner durable reference aborts a message/history replacement before existing transcript rows are removed. Upload failures keep pending files for retry, unsupported media can degrade to text markers, optional Office/PDF/VL dependencies can emit extraction banners, Office attachments can create markdown documents when extracted server-side, and fillable-PDF auto-document failures fall back to normal PDF extraction. `chat_messages.content` and FTS do not retain provider data URLs; structured references stay in metadata for reloads. Chat does not own upload bytes or durable document storage; it requests document/upload behavior from those subsystems. -Frontend chat distinguishes normal resend from regenerate-from-here: normal resend appends a fresh user copy and carries upload IDs where available, while regeneration truncates from the selected point. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used. +Frontend resend normally replaces from the selected user turn: it truncates server history before removing that turn and later DOM/thread state, resolves the persisted message id with index fallback, and aborts if truncation fails. Explicit append-only resend appends a fresh copy; attachments are retained through the respective path. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used. -Native document tool outputs can open or refresh the document editor from -tool-result metadata, so the UI can recover if a later `doc_update` stream event -is missed. The chat renderer also hides raw/incomplete leaked tool JSON and -document fences from normal transcript text. +The Notes panel can attach selected Note/Todo IDs as reference context for a chat send. `static/js/chat.js` keeps selection in memory per chat session, transfers a pending selection when a new session is materialized, restores the indicator on session switches, and lets the user clear it. A send captures the selected IDs for its current session and includes `selected_note_ids` in the streaming form. This is separate from uploaded attachments and is not durable session storage. The server reloads exact-owner notes and wraps their text as untrusted context; validation and formatting are owned by [context-building.md](context-building.md). + +Native document tool outputs can open or refresh the document editor from tool-result metadata, so the UI can recover if a later `doc_update` stream event is missed. The chat renderer also hides raw/incomplete leaked tool JSON and document fences from normal transcript text. When untrusted external/workspace content has entered the agent context, high-impact tool calls pause as exact approval cards instead of executing. The browser can allow the rest of the interrupted task, allow this chat session, or deny; it submits only the opaque id/decision with an empty control-plane message and does not mutate the composer. The server restores the sealed first action plus private selected tools/query, revalidates policy and document freshness, consumes the first action, and resumes without persisting a synthetic user message. Task scope ends with that resumed run. Chat scope persists the resolved card and marks later context only for that exact session; forks do not inherit it. A normal message retires an unresolved card while preserving taint. @@ -127,7 +104,7 @@ When untrusted external/workspace content has entered the agent context, high-im The scoped API-token chat surface is `/api/v1/chat`. Browser chat routes can receive bearer-auth state from middleware, but route code must not assume `"api"` is a durable owner; API-token support requires explicit scope checks and token-owner attribution. -Incognito disables memory, skill, and chat-history tools and skips assistant DB persistence, but current user-message persistence and later cleanup are not a strict no-write guarantee. Treat incognito changes as security-sensitive until that contract is clarified. +Incognito skips durable user and assistant message writes, supplies the current user turn ephemerally without duplicate insertion, avoids loading saved transcript history, and disables memory/skill/history context paths. This is a message-history contract, not a guarantee that every route, session header or auxiliary operation performs no write. ## Search Boundary @@ -144,11 +121,31 @@ Incognito disables memory, skill, and chat-history tools and skips assistant DB - Docker/native endpoint differences are owned by runtime/model setup, but chat sessions depend on the saved endpoint URLs and headers. - Copying a response from the UI copies the displayed answer text and omits hidden reasoning/thinking segments. +## Turn Preparation And Durable Background Results + +Chat preparation derives request authority separately from tool inventory. `src.turn_contract` selects capability families and required reads for compact/native routes; exact approval continuations, the TUI surface, and full-schema routes opt out of that selection path. Endpoint/model tool modes and the clean-route aliases (`cleanv3`, `preheret`) influence routing; the shared runtime-selection helper determines whether both context preparation and execution use compact v3. Full-schema models retain the complete enabled inventory subject to server permission gates. + +Sessions persist independent memory-extraction, memory-injection, and skill-injection toggles, thinking mode, temperature/max-token overrides, workspace `cwd`, endpoint id, accumulated token totals, and cost. Session hydration restores these values. Generated images are preserved when a session is deleted unless image deletion is explicitly requested. + +`src.background_tool_jobs.BackgroundToolJobs` stores tool-work origin and once-only chat delivery in `BackgroundToolJob` SQL rows. This is separate from process-local detached SSE buffers and disk-backed shell jobs. Result context is reconstructed from background metadata; a durable completion must remain attributed to its originating owner/session. + +## Session Preparation Details + +Credential recovery selects the session's bound enabled URL-matching endpoint before resolving its provider-auth id; a deleted/disabled bound endpoint does not trigger selection of another account. Legacy unbound recovery chooses the oldest enabled owner-visible URL-matching `ModelEndpoint` by creation time/id and persists `endpoint_id` before resolving runtime headers. Client/TUI context is bounded untrusted metadata, not admission, workspace grants or permission to execute supplied verifier commands. Context-info exposes bounded tool/skill/AGENTS path inventory rather than instruction bodies; host existence details are admin-only. + +Header persistence retries selected SQLite locked failures with bounded backoff and can continue in memory after exhaustion; it does not guarantee durability. Session listing uses owner-filtered unarchived database rows, hides synthetic trace sessions and treats empty sessions according to persisted messages/documents/images or live state. Synthetic `sft_` trace capture is enabled by default unless opted out; prefix selection and trash/tombstones are not data erasure guarantees. Auto-title uses a bounded utility call and a short word-based fallback. + +Active document state is owner-checked and chat-bound. An explicitly empty active-document state disables fallback, minimized editors do not resurrect context, and session switches preserve the exact active document rather than selecting the first cached document. A stream run id identifies streamed UI replacement and differs from nested execution-journal identity. Structured turn replacement and terminal error events preserve partial content without claiming a successful completion. Background tool delivery attribution is separate from provider success and disk-backed shell result delivery. + +## History Truncation And Cleanup + +`routes/history/history_routes.py` verifies session ownership before parsing truncation requests. The body must be a JSON object with an explicit `keep_count` or nonblank string `before_msg_id` (`message_id` alias); null/empty primary IDs fall through to the alias, while other nonstrings fail. Counts accept nonnegative integers or integer strings, rejecting booleans, floats, null and negative values. A missing message boundary returns 404 before history mutation; explicit zero remains a supported clear operation. Invalid JSON/targets return 400 without deleting saved messages or SFT trace pairs. Cleanup uses durable message rows and rechecks the count at deletion; see `persistence.md`. + ## Current Gaps - Chat, agent, research, and compare orchestration still meet in a large route file. - Context preface behavior is spread across `routes/chat_helpers.py`, `src/chat_processor.py`, route injections, and agent/tool paths. - Detached stream lifecycle spans `routes/chat_routes.py`, `src/agent_runs.py`, `static/js/chat.js`, `static/js/sessions.js`, and non-chat callers. - Some frontend stream state is still global/module-level in `static/js/chat.js` and needs careful session isolation when adding background or resumable flows. -- Chat lacks route-level SSE regression tests for `/api/chat_stream`, live resume/stop/status, mode handoff, persistence metadata, partial-save behavior, attachment/doc-update events, browser timezone offset/workspace handling, and literal URL context intent. -- Bearer-token behavior on browser chat routes and incognito persistence need explicit contract decisions and regression coverage. +- Route scope, stream errors, tool-policy preprocessing, request contracts, and runtime preparation have focused tests; full browser/provider/resume behavior still spans several modules. +- Scoped bearer admission and incognito message-history handling have focused regressions; they do not establish a universal no-write or live provider guarantee. diff --git a/specs/compare.md b/specs/compare.md index a68f39716..3a4d77e6d 100644 --- a/specs/compare.md +++ b/specs/compare.md @@ -1,6 +1,6 @@ # Compare -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -40,11 +40,9 @@ Frontend compare behavior is split by responsibility: - `panes.js` and `stream.js` own paired response rendering; - `vote.js` and `scoreboard.js` own voting and history display. -Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` routes those controls into the main chat renderer/control plane, pauses pane completion/autograding while a choice is pending, and can resume the pane after the user decision; compare orchestration keeps its busy state until those continuations settle. +Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` renders shared choice controls inside the originating pane history, pauses pane completion/autograding while a choice is pending, and resumes only while the pane session still matches. Continuation waits for the originating controller to release the pane, restores the card after a ten-second timeout, and refuses to send a stale choice into a replacement controller; compare orchestration keeps its busy state until those continuations settle. -Mobile compare layout collapses multi-pane grids to a single column so panes -remain readable on narrow screens while the desktop grid still uses the -selected column count. +Mobile compare uses selectable pane tabs and one visible active pane; desktop retains the selected multi-column grid. Pane add/remove/shuffle paths keep active-tab and session/settings indices aligned. ## Ownership Boundaries @@ -69,6 +67,14 @@ Compare frontend code is part of the app DOM security surface. Current stream/se - Shared legacy `ModelEndpoint.owner == NULL` rows remain visible through owner filters. Legacy `Comparison.owner == NULL` rows are not treated as shared for authenticated vote/delete/history flows. - `/api/compare/start` and `/{comp_id}/vote` remain implemented but are not the active frontend path. +## Per-Pane Generation Controls + +`static/js/compare/panes.js` owns per-pane inference controls and saves `thinking_mode`, `temperature_override`, and `max_tokens_override` to `/api/session/{id}/generation-settings`. The backend session/provider path owns the actual generation semantics. Pane creation initializes overrides, removal and shuffle preserve index alignment, and mandatory-thinking model controls are disabled where identified. `stream.js` renders response statistics including token/latency/context/cost metrics and the thinking-off indicator; compare header statistics open a compact details popup. + +## Shared Celebration Renderer + +`static/js/compare/vote.js` imports and reexports `spawnConfetti` from `static/js/shared/ui/confetti.js`, published from `frontend/src/shared/ui/confetti.js`. Notes imports the shared renderer directly; voting no longer owns its implementation. Vote behavior and callers retain the compatibility export. + ## Current Gaps - Blind mode is not a confidentiality boundary; client/local state can still expose model identity before vote. diff --git a/specs/context-building.md b/specs/context-building.md index 3101e26ee..879128bfe 100644 --- a/specs/context-building.md +++ b/specs/context-building.md @@ -1,6 +1,6 @@ # Context Building -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-15 ## Scope @@ -9,10 +9,10 @@ This spec covers model-context construction in: - `src/chat_processor.py`; - `src/chat_handler.py` and `src/youtube_handler.py`; - `routes/chat_helpers.py` and context injection in `routes/chat_routes.py`; -- `src/agent_loop.py`; +- `src/agent_loop.py`, `src/clean_agent_preview.py`, `src/turn_contract.py`, and `src/agent_runtime/context_resolution.py`; +- `src/media_ingress.py`, `src/agent_tools/media_tools.py`, and `src/email_attachment_text.py`; - `src/tool_execution.py`; -- `src/attachment_refs.py` and uploaded-file manifest construction in - `routes/chat_helpers.py`; +- `src/attachment_refs.py` and uploaded-file manifest construction in `routes/chat_helpers.py`; - `src/tool_policy.py`; - `src/prompt_security.py`; - `src/tool_capabilities.py`, `src/tool_approval_scopes.py`, and `src/tool_approvals.py`; @@ -53,12 +53,9 @@ Current untrusted context sources include: - emails and attachments; - tool output from external/user-controlled data. -Live multimodal provider blocks can contain data URLs, but persisted and -tool-facing context uses stable attachment references. Tool manifests carry an -`odysseus://attachment/` URI and owner-checked read policy; local paths are -compatibility data added only after owner and root-confinement checks. Persisted -chat context keeps readable text/reference lines rather than reinserting raw -media bytes into later turns or search state. +Live multimodal provider blocks can contain data URLs, but persisted and tool-facing context uses stable attachment references. Tool manifests carry an `odysseus://attachment/` URI and owner-checked read policy; local paths are compatibility data added only after owner and root-confinement checks. Persisted chat context keeps readable text/reference lines rather than reinserting raw media bytes into later turns or search state. + +`/api/chat_stream` accepts explicitly selected Notes/Todos through the JSON form field `selected_note_ids`. `routes.chat_routes._parse_selected_note_ids()` limits the encoded field to 8192 characters and the array to 50 nonempty string IDs of at most 128 characters, deduplicating valid IDs and returning `400` for malformed input. The route queries only rows whose `Note.owner` exactly matches `effective_user(request)`; missing or other-owner IDs contribute no context. Titles, content, note type, and checklist item state are formatted by `_format_selected_notes_context()` and passed to `routes.chat_helpers.build_chat_context()` as an untrusted "Selected notes and todos" preface. Selection supplies reference data, not tool authority; the preface discourages redundant retrieval or permission requests just to read those same supplied notes. ## URL, Search, And Tool-Derived Context @@ -104,6 +101,24 @@ Guide-only/no-tools policy can suppress context acquisition before the model cal - `src.tool_execution` for `web_search`, `web_fetch`, file, shell, MCP, and other tool outputs; - `src.deep_research` and research handlers for search/fetch/extract flows used by research jobs, with fetched webpage text wrapped before extraction and analyzed URLs tracked separately from source snippets. +## Effective Context And Media Evidence + +Compact preview preparation resolves the effective context window once through `src.agent_runtime.context_resolution`; terminal metrics use that stored resolution and do not trigger new discovery. Runtime-confirmed, provider-advertised, operator-declared, static-table, and unknown evidence remain distinct, with conflicts recorded. Operator declarations can cap stronger evidence; unknown is represented as zero rather than an invented known limit. Metadata probing has a three-second preparation deadline, caches remote observations, and re-probes local serving processes. Legacy chat/agent budgeting remains a separate compatibility path. + +`src.context_compactor` trims context and prunes multimodal images. Session memory-injection and skill-injection toggles independently control preface construction; memory extraction is a separate session setting. `src.skill_index` semantically ranks native skills using an already-ready ToolIndex embedding lane, with lexical fallback, while `search_chats` retrieves owner-scoped historical messages instead of making all history ambient context. + +`src.media_ingress` limits file counts, source/encoded bytes, image dimensions/pixels, document characters, video frames, and probe/extraction time. Native media tools return observed media/text evidence through the same untrusted-result path; filenames and model guesses do not establish inspected image/video content. `src.email_attachment_text` supplies shared attachment text extraction for mail context. + +## Resolution And Embedding Limits + +`src.agent_runtime.context_resolution.ContextResolution` binds effective context evidence to the resolved endpoint/model before compact preparation shapes history. The stored resolution feeds terminal metrics without another discovery call. A legacy shaping default does not turn unknown evidence into a known limit; a bare integer cannot supply typed provenance. Metadata requests use the turn's provider headers only at configured or server-resolved provider origins, without credential-following redirects, and endpoint URLs stay out of serialized resolution metrics. Remote observations cache for 600 seconds, failures for 60 seconds, while local servers are re-probed. + +`src.embeddings` accepts an explicit `FASTEMBED_THREADS` integer from 1 through 256 and raises for malformed or out-of-range values rather than silently changing the requested execution setting. This controls embedding setup, not proof of a ready vector service. + +## Compact Source Projection + +Compact editor/email preparation can use current memory and bounded prior-source references without importing arbitrary hidden document bodies. The email classifier supplies dependency evidence for the selected path, not a general model-routing authority. Document edits are bounded batches with per-edit outcomes: a saved `doc_update` is distinct from an unapplied suggestion, exact unique-text matching can reject an ambiguous suggestion, and partial success does not mean every proposed edit was saved. + ## Current Gaps - URL/search context result shape is not unified across chat prefetch, agent tools, and research. diff --git a/specs/cookbook-hwfit.md b/specs/cookbook-hwfit.md index 7c9fcb5c0..56b1be6eb 100644 --- a/specs/cookbook-hwfit.md +++ b/specs/cookbook-hwfit.md @@ -1,6 +1,6 @@ # Cookbook And Hardware Fit -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-15 ## Scope @@ -40,7 +40,7 @@ Access policy is split by surface: - download/setup/SSH key/cache scan/serve/GPU/kill/state/task-status are admin/internal-tool surfaces; - `/api/cookbook/hf-latest` is authenticated-user gated; -- HW Fit routes are authenticated read/probe routes through normal middleware, not admin-only operations; +- HW Fit routes carry a router-wide admin dependency because host/model-path probes can use server SSH/filesystem authority; - bearer API tokens do not satisfy Cookbook admin gates. Runtime behavior: @@ -130,7 +130,7 @@ Runtime behavior: - Linux, Windows/PowerShell, macOS, Docker, NVIDIA, AMD, Apple Silicon, and CPU-only systems have different command paths. - Remote hosts are accessed through SSH helpers; Cookbook host/port/path inputs must be validated before command construction. -- HW Fit remote host/port query values currently do not share all Cookbook route-level validation before SSH probing. +- HW Fit detection targets use shared host/port validation before remote probing; the router-wide admin gate still owns admission. - Missing local tools or failed installs should surface command/output/error detail where possible. - GPU overlays remain optional and do not break CPU-only deployments. - Docker GPU overlays pass host devices/env; they do not install CUDA/ROCm engines by themselves. @@ -188,6 +188,18 @@ Existing coverage is strongest for helper validation/quoting, SSH host validatio Route-level auth/security and degraded-return coverage is thinner for Cookbook admin routes, shell dependency routes, `/api/cookbook/hf-latest`, state/status edge cases, HW Fit routes, frontend JS behavior, and helper scripts such as `hf_download.py`, `add_hwfit_models.py`, and `diffusion_server.py`. +## Native Control And Adapter Contracts + +`routes/cookbook_routes.py` gates native download/serve producers even when scoped wrappers call them directly. Local tools use a one-use admitted operation/backend/body capability from `src.agent_runtime.local_model_control`; fallback administration uses the shell admin gate. Kill-PID, SSH-key and state mutation do not inherit that model capability. Remote operations retain separate validation and admin requirements. + +Cached-model scans pass request-owned script bytes over subprocess stdin instead of sharing `scan_cache.py`; cancellation/timeout terminates that request's scanner. Image serving accepts a shell-safe explicit `runtime_adapter`, passes it as `ODYSSEUS_MLX_IMAGE_ADAPTER`, and retains `auto` detection with dependency preflights. HW Fit ranking carries image dependency-package metadata; native AWQ rows no longer require multiple GPUs on otherwise supported accelerator platforms. + +## Native Process And Catalog Limits + +Tool-reachable local Cookbook stop refuses legacy unadmitted tmux/process targets with resource-identity denial before scanning or killing; native model capabilities admit only the exact owner/request/backend/body and consume once. Scheduled native serving requires the sealed capability, while remote/operator HTTP flows retain their distinct validation. Procfs discovery returns empty when procfs is unavailable and cannot prove absence or authorize survivor reclamation. Shared process cleanup freezes known descendants, checks start tokens and observes death; uncertain identity is retained, with unavoidable signal/observation races rather than guaranteed portable teardown. + +Image collection refresh has a short in-memory and longer disk cache; network failure can preserve stale model data rather than force an empty catalog. Latest lookup and organization prefilters use external metadata and heuristic first-party/format/parameter sizing; fit estimates (including a 90-percent VRAM budget) are recommendations, not measured serving acceptance. Bundled HF/MLX inventories remain empty in final source, and generated inventory inspection is separate from consumer/schema review. + ## Current Gaps - Cookbook-created model endpoint ownership/shared/null-owner policy needs a deliberate decision. diff --git a/specs/documents-rag-uploads.md b/specs/documents-rag-uploads.md index 18914be54..b92cace0d 100644 --- a/specs/documents-rag-uploads.md +++ b/specs/documents-rag-uploads.md @@ -1,6 +1,6 @@ # Documents, RAG, And Uploads -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -10,8 +10,7 @@ This spec covers file/document context, document storage, and vector retrieval i - `routes/upload_routes.py`, `routes/personal_routes.py`, `routes/embedding_routes.py`, canonical `routes/document/document_routes.py` and `routes/document/document_helpers.py`, plus their top-level compatibility shims; - chat attachment paths in `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, and `src/chat_processor.py`; - `core/session_manager.py`, `src/attachment_refs.py`, `src/upload_handler.py`, - `src/upload_limits.py`, and the public reference contract in - `docs/attachments.md`; + `src/upload_limits.py`, and the public reference contract in `website/attachments.md`; - `src/document_processor.py`, `src/document_actions.py`, `src/personal_docs.py`, and `src/markitdown_runtime.py`; - `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, `src/chroma_client.py`, `src/embeddings.py`, and `src/embedding_lanes.py`; - PDF/form helpers in `src/pdf_runtime.py`, `src/pdf_forms.py`, and `src/pdf_form_doc.py`; @@ -54,45 +53,17 @@ Chat does not own attachment extraction. Runtime flow: - `src.document_processor.build_user_content()` produces model-ready text, PDF text, Office/EPUB text when MarkItDown or the DOCX fallback is available, image/multimodal blocks, truncation, and PDF/Office auto-document updates; - chat streams attachment, PDF-created `doc_update`, and `rag_sources` events where applicable. -Extensionless image and audio attachments derive their data-URI subtype from -the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become -invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename -has no extension. +Extensionless image and audio attachments derive their data-URI subtype from the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename has no extension. ## Durable References And Cleanup -`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw -upload storage: attachment id, name, MIME type, size, and optional checksum, -creation time, dimensions, vision text/model, and gallery id. Live provider -calls may still receive multimodal data URLs for the current turn, but durable -chat content is normalized to readable text plus compact reference lines. -Structured references remain in message attachment metadata, and chat FTS -triggers omit inline media while startup migration scrubs legacy indexed data -URLs. +`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw upload storage: attachment id, name, MIME type, size, and optional checksum, creation time, dimensions, vision text/model, and gallery id. Live provider calls may still receive multimodal data URLs for the current turn, but durable chat content is normalized to readable text plus compact reference lines. Structured references remain in message attachment metadata, and chat FTS triggers omit inline media while startup migration scrubs legacy indexed data URLs. -Agent/tool manifests expose `odysseus://attachment/` with -`read_policy: "owner_checked_upload"`. A compatibility filesystem path is -included only after owner-aware upload resolution, upload-root confinement, and -tool-readable-root checks; the stable contract for external tools is the URI -and attachment id, not host layout. +Agent/tool manifests expose `odysseus://attachment/` with `read_policy: "owner_checked_upload"`. A compatibility filesystem path is included only after owner-aware upload resolution, upload-root confinement, and tool-readable-root checks; the stable contract for external tools is the URI and attachment id, not host layout. -Writers reserve referenced uploads before committing durable state. This -includes session message append/replace and history rewrites, document -create/update and native document edits, note route/tool create/update, -calendar/event route/tool create/update, and attachment-bearing session -updates. A missing or wrong-owner reference aborts before destructive -replacement and surfaces a route conflict or tool error. Reservations serialize -with cleanup through the upload-index lock and refresh access time. +Writers reserve referenced uploads before committing durable state. This includes session message append/replace and history rewrites, document create/update and native document edits, note route/tool create/update, calendar/event route/tool create/update, and attachment-bearing session updates. A missing or wrong-owner reference aborts before destructive replacement and surfaces a route conflict or tool error. Reservations serialize with cleanup through the upload-index lock and refresh access time. -Admin cleanup first scans chat content and attachment metadata, current and -versioned documents including PDF markers, gallery filenames/hashes, note -image/color/content/checklist fields, and calendar color/description/location -fields. Reference discovery or index-integrity failure aborts cleanup; the -lower-level API removes nothing without both completed id and hash snapshots. -Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp -metadata are candidates. Matching index rows are persisted away before byte -deletion and restored if deletion fails. This lock is process-local, so the -documented race protection assumes the current single-worker deployment. +Admin cleanup first scans chat content and attachment metadata, current and versioned documents including PDF markers, gallery filenames/hashes, note image/color/content/checklist fields, and calendar color/description/location fields. Reference discovery or index-integrity failure aborts cleanup; the lower-level API removes nothing without both completed id and hash snapshots. Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp metadata are candidates. Matching index rows are persisted away before byte deletion and restored if deletion fails. This lock is process-local, so the documented race protection assumes the current single-worker deployment. ## Living Documents And PDF @@ -100,19 +71,13 @@ documented race protection assumes the current single-worker deployment. `static/js/documentLibrary.js` owns local library state after archive/delete actions, including total counts and language chips. Server route truth still owns durable document state. -`static/js/document.js` owns the browser document editor and markdown preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document. +`static/js/document.js` owns the browser document editor, a sanitized rich-text editing view and Markdown source/preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document. Document mutations also happen through agent tools, Codex document routes, email attachment import, and scripts. HTTP and native-agent document writers owner-reserve any internal upload/PDF references before persisting new current content or versions. Native document tool outputs include metadata that the browser can use to open/update the editor if a later stream update is missed. Those callers must preserve document owner, attachment, and version semantics. After external/workspace-untrusted context, a proposed document mutation is sealed into an exact approval with document id, current version, content digest, tool content, owner/session, and workspace. Approval continuation re-reads and verifies those fields before consuming the one-use authorization, so an intervening edit cannot apply a stale approved patch to new content. -Email draft documents are a first-class document language. Create/update paths -detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve -protected reply/forward headers such as `In-Reply-To`, `References`, -`X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original -history when model or UI edits replace the draft body. Creating a draft for the -same source UID/folder in the same session updates the active draft instead of -creating a duplicate. +Email draft documents are a first-class document language. Create/update paths detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve protected reply/forward headers such as `In-Reply-To`, `References`, `X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original history when model or UI edits replace the draft body. Creating a draft for the same source UID/folder in the same session updates the active draft instead of creating a duplicate. `Document` rows own current content and owner. `DocumentVersion` rows own immutable snapshots. Document access should be owner-filtered, not session-id-only; the session document listing path still needs regression coverage for per-document owner filtering after the session owner check. @@ -163,7 +128,7 @@ Some non-route tool/script paths can index ownerless or arbitrary directories an ## Optional And Degraded Behavior - ChromaDB/FastEmbed are default installed dependencies, but Chroma can be offline or unreachable. -- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service and persistent Chroma storage. +- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service with `chromadb-data:/data` in all three Compose variants. Pre-fix containers can hold live vectors in their writable layer; preserve/rescue that data before recreation using `docs/chromadb-volume-migration.md`. - HTTP embeddings can fall back to FastEmbed; when both lanes exist, lane separation avoids Chroma dimension conflicts. - MarkItDown is optional for Office/EPUB extraction; chat attachments and personal directory indexing have clear degraded behavior, while direct RAG upload does not share the same extraction path. - PyMuPDF is optional, unlocks PDF form/render/fill paths, and carries AGPL implications when installed. @@ -186,7 +151,7 @@ Concrete enforcement points include: Extracted attachment text is currently appended into the user message rather than wrapped as a separate untrusted-context message. That is current behavior and a prompt-injection hardening gap. -Bearer-token callers are not a scoped document/upload API surface today. Routes that treat token-authenticated users as owners need explicit scope/effective-user policy before they are considered safe token APIs. +Browser document routes still need explicit bearer-token scope/effective-user policy. Upload endpoints have their own delegated owner/scope gates described below; that contract does not extend to every document/PDF/RAG route. ## Testing Coverage @@ -194,6 +159,26 @@ Existing useful coverage includes upload owner scope, upload IDs, upload atomici Route-level coverage is thinner for document CRUD, PDF import/render/export/fill, direct RAG upload, embedding admin/security behavior, and RAG unavailable states. +## Preserved Originals And Document Views + +`POST /api/documents/import-docx` requires document privilege, verifies any supplied session owner, stores the original through `UploadHandler`, extracts Markdown, and creates a `docx` document with a `docx_source` upload marker. `GET /api/document/{doc_id}/render-docx` checks document and source-upload visibility, uses Mammoth to return an HTML fragment for client sanitization, and returns 503 when Mammoth is absent. The frontend supplies a paper preview while extracted text remains available for editing/search/model context. + +`GET /api/document/{doc_id}/convert-original/{target}` converts preserved DOCX to PDF or PDF to DOCX directly with host LibreOffice/soffice, after document privilege, ownership, and source-upload checks. It uses a temporary output directory and isolated LibreOffice profile in a worker thread with a 120-second subprocess timeout; it does not convert from extracted Markdown. Missing LibreOffice returns 503, timeout 504, and unsuccessful output 502. + +Markdown documents can open `GET /api/document/{doc_id}/visual-report`, reusing `src.visual_report.generate_visual_report()` after document ownership validation. ZIP export recognizes email drafts as `.eml` and converts their header/body separator into an RFC-style blank line. The frontend document graph now also includes `documentOutline.js` and `documentStats.js`. + +## Editing And Export Boundaries + +Rich-text editing mirrors a textarea and supports formatting, checklists, images and tables through sanitized contenteditable content. Internal markers are removed from export; converting rich text/Markdown into simple DOCX paragraphs is lossy and does not preserve arbitrary Word layout. Browser PDF export uses a script-disabled print iframe with same-origin/modal allowances, waits for fonts/styles/images/math and cleans up afterward; server PDF rendering and preserved-original LibreOffice conversion are separate paths. + +Per-document save queues serialize requests and track edit revisions so intervening edits remain marked dirty; this does not promise that every cached response body preserves a newer in-memory edit. Restoring the editor targets the exact active document. Suggestions apply only when the exact find text is unique, report partial applied/unapplied suggestion IDs and retain unapplied review state; server-saved edits suppress duplicate frontend application. Protected email envelope/history remains distinct from editable draft content. PDF/signed-reply preparation still has late async ownership/account edge cases requiring targeted checks. + +## Upload Authentication And Delegated Scopes + +`routes/upload_routes.py` explicitly bypasses upload ownership/auth-service gates only when auth is disabled, including after accounts were configured. With auth enabled, missing auth manager returns 503, an unconfigured manager returns 401, and missing/request-sentinel identity returns 403. Reads, thumbnails and vision reads/edits use `_verify_upload_access()` before confined path access. Interactive owners may access their uploads; verified interactive admins may access other owners and legacy ownerless uploads. Delegated tokens never inherit that administrator override and require an actual token owner matching a non-null file owner. + +Delegated creation and OCR edits require any of `chat` or `documents:write`; downloads/thumbnails/OCR reads accept any of `chat`, `documents:read`, `documents:write`. Missing/unrelated scopes and invalid token owners fail with 403; owner mismatch/ownerless token access uses 404. POST saves effective owner attribution and clears request-sentinel owners. No-login anonymous uploads can remain null-owned and become interactive-admin-only after auth is enabled. Size/type/filename/path validation remains active in no-login mode; these gates do not replace the global authentication middleware. + ## Current Gaps - Direct RAG upload still needs clearer file-type validation and MarkItDown/PDF extraction parity decisions. @@ -201,5 +186,5 @@ Route-level coverage is thinner for document CRUD, PDF import/render/export/fill - Chat RAG can remain degraded after startup even if personal routes later initialize the RAG singleton. - PyMuPDF-dependent routes do not all share the same optional-runtime helper/error behavior. - Signed-reply preparation needs owner-scoped source email account/signature regression coverage. -- Document/upload routes need explicit bearer-token scope/effective-user policy. +- Browser document/PDF/RAG routes need explicit bearer-token scope/effective-user policy beyond the upload endpoint contract above. - User-facing document/PDF/RAG route matrices need more regression coverage for owner denial, admin gates, unavailable services, and degraded optional dependencies. diff --git a/specs/email-contacts.md b/specs/email-contacts.md index 204a97b57..b2f71841b 100644 --- a/specs/email-contacts.md +++ b/specs/email-contacts.md @@ -1,6 +1,6 @@ # Email And Contacts -Last updated: dev@2e2bb52 | 2026-08-16 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -39,7 +39,7 @@ This spec covers mail and contacts in: Exactly one default account per owner is enforced as a serialized database transition. Startup normalizes legacy duplicate defaults and installs a unique per-owner default constraint/index; first create, delete/promotion, set-default, demo teardown, and owner rename lock the relevant owner rows and commit atomically. Multi-owner rename acquires locks in canonical order so stale concurrent writers fail closed. -`routes.email_helpers` owns: +`routes.email.email_helpers` owns (flat imports remain compatibility aliases): - account owner assertions and config fallback order; - IMAP/SMTP connection helpers and related transport utilities; @@ -57,11 +57,9 @@ Email owner semantics are route-local and compatibility-sensitive: - `routes.email_helpers._require_auth()` returns `""` in `AUTH_ENABLED=false` mode, rejects configured auth with no user, and only tolerates first-run anonymous loopback fallback. - Empty owner is treated as single-user compatibility: account-ownership assertions no-op, default/first-enabled account fallback can be global, and email cache clauses include `owner = '' OR owner IS NULL`. - Non-empty owners scope account/config/cache queries. Legacy ownerless account - rows are visible to an authenticated owner only when the row's IMAP username - or from-address matches that owner, so old unowned rows do not become global - cross-user accounts in configured multi-user deployments. + rows are visible to an authenticated owner only when the row's IMAP username or from-address matches that owner, so old unowned rows do not become global cross-user accounts in configured multi-user deployments. -`routes.email_routes` owns the HTTP mail surface: +`routes.email.email_routes` owns the HTTP mail surface: - account CRUD, test, default, and masked config reads; - Google OAuth authorize/callback for Workspace and .edu Gmail-style accounts; @@ -91,7 +89,7 @@ IMAP helpers quote mailbox names, raise the Python IMAP line cap for large messa Scheduled email rows live in `data/scheduled_emails.db` and are owner-scoped. Scheduled send times are normalized before storage. -`routes.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`. +`routes.email.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`. Manual and scheduled summaries use the shared LLM adapter and owner-scoped cache instead of constructing provider calls locally. Scheduled summaries use background fallback policy and yield to foreground work; provider exception text is shaped before it can reach the browser. @@ -107,9 +105,9 @@ Transport degraded behavior: ## Caching And Staleness -Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has its own session SWR cache, cache-buster refreshes, scheduled/search cache exclusions, and stale-row behavior when refresh fails. +Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has bounded in-memory and local caches, cache-buster refreshes, scheduled/search cache exclusions and stale rows on refresh failure. A cached early return is not a universal stale-while-revalidate guarantee. -Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens, carry immutable mailbox context, and ignore late responses after account, folder, or message changes. +Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens and carry mailbox context with selected request-generation guards. Those guards are not universal: some detached/inline completions still use mutable account/folder state, so late-response safety must be assessed per path. Library prewarm runs only while genuinely idle, as one bounded single-flight request for the default or last-used enabled account and initial page. Visible foreground work, panel lifecycle, account changes, or explicit reads cancel or join it so delayed duplicate IMAP work cannot escape the idle gate. @@ -117,7 +115,7 @@ List/read route caches are owner/account-aware. Helper-side summary, AI-reply, t ## Attachments And Signed Replies -Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX depends on `python-docx`; PDF form/open-in-doc flows can depend on optional PyMuPDF. +Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX attachment-to-document extraction uses MarkItDown first; optional LibreOffice conversion has a 60-second bound and can supply a PDF preview, otherwise extracted Markdown remains available; PDF form/open-in-doc flows can depend on optional PyMuPDF. Email attachment-as-document flows stamp `Document.source_email_*` provenance. `GET /api/email/attachments-download/{uid}` builds an owner-scoped ZIP of visible non-signature attachments using safe names. `compose-from-odysseus` and `compose-from-odysseus-zip` can stage owner-visible documents and gallery images as compose uploads, preserving legacy session fallback only where the source object remains visible to the owner. `prepare-signed-reply` verifies document ownership, reconstructs reply headers, flattens/stages signed PDFs as compose uploads, and leaves final send/draft review to the compose flow. @@ -135,12 +133,7 @@ When the email reader is active, browser chat sends selected-message metadata. ` `mcp_servers/email_server.py` exposes email tools for MCP/agent use. It has its own account discovery, IMAP/SMTP, attachment, cache, and send paths, but account visibility now mirrors the HTTP owner policy. The active owner comes from a hidden `_odysseus_owner` argument when the caller provides one, or from `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`. If any enabled account is owner-scoped and no current/configured owner exists, email MCP returns an owner-scope error instead of listing global accounts. -MCP email account filtering includes owner-owned rows and legacy ownerless rows -whose mailbox/from-address matches the owner. Confirmation-first `send_email` -resolves the selected account before stashing an `agent_draft`, so drafts cannot -be staged against another owner's account. MCP-created draft documents use the -resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER` -and single-admin fallback only as document-visibility compatibility. +MCP email account filtering includes owner-owned rows and legacy ownerless rows whose mailbox/from-address matches the owner. Confirmation-first `send_email` resolves the selected account before stashing an `agent_draft`, so drafts cannot be staged against another owner's account. MCP-created draft documents use the resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER` and single-admin fallback only as document-visibility compatibility. MCP email send behavior is confirmation-first by default: `send_email` and reply send paths stash a `scheduled_emails` row with `status='agent_draft'` when `agent_email_confirm` is true, and browser routes expose pending drafts for approval or cancellation. Separate MCP draft tools create Odysseus compose documents for user review without sending. @@ -161,7 +154,7 @@ Contact runtime behavior: - the native contacts CLI is CardDAV-oriented and does not fully match web JSON fallback behavior; - agent contact tools reuse helper functions in-process because the HTTP routes require browser/admin auth. -Contacts are global admin-only data today. There is no per-user contact sharing model unless a future spec defines one. +HTTP contact administration remains admin-gated, but in-process helpers have owner-aware local rows and per-owner caches. Named owners see their own rows plus legacy ownerless contacts; synthetic `sft_` owners do not see ownerless rows and bypass CardDAV. This compatibility does not establish a general cross-user sharing API. ## Security Policy @@ -177,7 +170,7 @@ Known security policy details: - Google OAuth state and callback owner checks are part of the account-boundary contract; - scheduled emails must remain owner-scoped; - email pre-retrieval contacts context is allowed only for admin/single-user situations; -- MCP attachment downloads need route-level path-containment parity; current MCP paths are separate from the HTTP compose/attachment helper path. +- HTTP and MCP attachment extraction share `src.mail_attachment_paths.attachment_scope_dir()` for hashed owner/account/folder/message directories; remaining attachment consumers still need end-to-end scope/path coverage. CardDAV credentials and URLs are security-sensitive. CardDAV URL setup and derived href writes/deletes pass through outbound URL validation; absolute hrefs from a CardDAV server are constrained back to the configured origin before credentials are reused. CardDAV passwords in settings are encrypted and masked on read; environment-sourced legacy password values are used as supplied. @@ -196,6 +189,30 @@ Existing coverage includes header/envelope/IMAP/SMTP behavior, serialized defaul Route-level and duplicate-path coverage is still thin for email list/read/search/mutations, account CRUD/security outside the OAuth path, send/draft security, attachments, scheduled-poller failures, contacts admin/CardDAV routes, MCP account/scope behavior, CardDAV degraded mode, and executable frontend behavior. +## Mail Safety And New Library Flows + +Mail connection helpers resolve host addresses once and connect only to policy-approved addresses through `src.url_safety.connect_outbound_tcp()`, retaining hostname TLS verification. Link-local, non-loopback reserved, multicast and unspecified destinations are denied. IPv6 loopback and IPv4-mapped loopback follow the private-address policy rather than unconditional reserved-address denial. Exactly `64:ff9b::/96` is decoded to its IPv4 target and evaluated strictly, including all DNS answers; network-specific NAT64 prefixes are not inferred. Private/loopback/shared destinations are denied for ordinary multi-user owners, allowed for admins/auth-disabled operators, and configurable with `EMAIL_ALLOW_PRIVATE_IPS=true`; `EMAIL_BLOCK_PRIVATE_IPS=true` takes precedence and denies them for everyone. + +The library adds owner-scoped unread-state, attachment metadata caches, bulk attachment ZIP, flagged-message actions, translation, and reviewed unsubscribe flows. `/unsubscribe/scan` reads newsletter/spam candidates; `/unsubscribe/execute` refetches the chosen message and supports mailto unsubscribe by sending through the selected account. HTTP unsubscribe links remain a manual-open path. `/unsubscribe/cleanup` moves reviewed candidates to Junk/Trash and can expand a sender's unsubscribe-header messages. Scanning alone does not send or move mail. + +`src.mail_attachment_paths` hashes the full owner/account/folder/UID scope into an extraction directory so legacy ownerless paths cannot reuse another mailbox's staged attachment. `src.email_attachment_text` centralizes readable attachment extraction. Email calendar pollers apply ICS revisions through `src.email_calendar_import`; invitation state and reminder semantics are described in `calendar-tasks-notes.md`. `src.email_task_intent` supplies email-task intent helpers, and `src.email_reply_stream` handles reply-stream text behavior. + +## Mailbox State And Delivery Boundaries + +MCP email binds owner and active session in request-local contexts and resets them on exit. Cross-account list caching is short-lived (20 seconds) and listing can use four concurrent workers; it does not promise single-flight freshness. Nonempty local header-index results can suppress a live lookup. MCP sent-time ISO ranges normalize missing zones to UTC and use an inclusive start/exclusive end; browser date-only received-time filters include the end date. Cached/fixture paths do not uniformly apply every date filter. Search pills combine terms; index fallback and at-most-six attachment search terms do not guarantee complete provider search. + +MCP bulk UID move fallback checks command statuses and verifies absence from the source; this is not proof of destination delivery. Browser and MCP single-message move paths instead rely on MOVE or COPY/STORE/expunge acknowledgements without a post-move source read. + +Browser bulk delete requires a successful native MOVE or recoverable Trash COPY before removing originals. COPY failure retains source/index; fallback STORE batches only copied UIDs for selective RFC 4315 UID EXPUNGE, never mailbox-wide EXPUNGE. Index rows are removed only after confirmed deletion. Failed selective expunge retains the index and attempts to remove newly added `\Deleted` flags while preserving observed pre-existing flags; disconnects can prevent rollback, and a lost acknowledgement is not proof the source survived. Servers without selective expunge can therefore report failure despite a Trash copy. Responses distinguish confirmed deleted and failed UIDs, total failure, and partial success; source and Trash list caches invalidate in `finally` when any deletion was confirmed. Single-message paths retain their separate behavior. + +Draft replacement appends the new mailbox draft before removing the old one. Sent draft cleanup follows successful Sent append; SMTP, IMAP and local database state are not one transaction. Scheduled send claims prevent concurrent claim winners, not exactly-once SMTP across a crash after send and before checkpoint. + +Composer sends first save a recovery mailbox draft, then use `wait_for_delivery` and preserve exact draft UID/folder for delivery cleanup. Failures/cancellation retain recoverable draft state. AI reply application checks generation/document/body snapshots and intervening manual input before overwriting. Confirmation-first MCP pending drafts are a distinct workflow. Reply-by-Message-ID resolves owner/account/UID context rather than trusting a global id; recursive fallback work is bounded and gathered on cancellation. Reply generation disables thinking, uses bounded initial output and one larger retry; this does not guarantee provider delivery. + +Unsubscribe scanning inspects at most the newest 500 candidates and deduplicates senders; bulk operations are bounded and can partially fail. A sent mailto attempt can move its source to Trash, while pending confirmation leaves it untouched; successful send-attempt bookkeeping is not confirmation that a remote subscription ended. Blocked-sender JSON is owner-keyed but not transactionally coupled to mailbox changes. Bulk attachment ZIP is capped at 250 messages in batches of twenty, skips failed messages, uses safe names and assembles in memory. + +Reader HTML snapshots use inert parsing/sanitization and sandbox/CSP constraints; blocked network in browser tests is a test condition rather than universal runtime network isolation. Attachment-only/media-only bodies remain meaningful; a thumbnail `img.src` is not itself a URL sanitizer. Local reminder metadata can identify folder/UID without an account and is a narrower compatibility path than canonical mailbox identity. Contact cache success/failure windows and shared refresh locking do not turn a CardDAV outage into local-write success. + ## Current Gaps - Owner-keyed cache policy still needs an explicit decision for thread boundaries, plus continued migration/query audits for every email side table. diff --git a/specs/frontend.md b/specs/frontend.md index 825f81f73..8e2ed56de 100644 --- a/specs/frontend.md +++ b/specs/frontend.md @@ -1,6 +1,6 @@ # Frontend -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -11,14 +11,14 @@ This spec covers the current browser app in: - `static/index.html`; - `static/login.html`; - `static/app.js`; -- `static/style.css`; +- ordered stylesheets under `static/css/`; - `static/js/*.js` and `static/js/*/*.js`; - vendor libraries under `static/lib/*`; - custom fonts and static assets under `static/fonts/*`; - `static/sw.js` and `static/manifest.json`; - frontend-oriented tests in `tests/*_js.py`, `tests/*.mjs`, `tests/bombadil-spec.ts`, static DOM/CSS/source-shape tests, and app/static tests such as `tests/test_app_static_mime.py`. -`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed. +`/backgrounds` remains registered in `app.py` but targets the absent `static/backgrounds.html`. Authentication middleware also gates it when auth is enabled, despite its handler docstring describing a public prototype page. This is a known route/static gap; the page is not a working frontend surface at the checked revision. `static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG. @@ -35,7 +35,7 @@ This spec covers the current browser app in: ## Runtime Shape -The frontend is a raw static SPA served by FastAPI. There is no Vite, React, TypeScript, bundler, or generated build output. +The browser runtime is a static native-ES-module SPA served by FastAPI, without a runtime Node/build dependency. Incremental maintained sources now live under `frontend/src/`, with explicit source-to-deployment mappings in `frontend/modules.json`; the map publishes `shared/actionMenuOrder.js`, the Notes feature graph and shared confetti to committed `static/js/` outputs. `scripts/build_frontend.py` publishes/checks those bytes during development, never application startup. Legacy modules remain authored under `static/`; there is no Vite/React/TypeScript bundle. `app.py` owns: @@ -49,7 +49,11 @@ The frontend is a raw static SPA served by FastAPI. There is no Vite, React, Typ The two first-paint Fira Code faces are preloaded so the shell does not wait for later CSS discovery. `static/js/startupShell.js` lets the visible shell initialize before session loading completes; session/transcript hydration is deferred and coordinated by `static/js/sessions.js` plus history/session routes rather than blocking first paint. -Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter currently contains the image-editor module graph so an editor never opened online can still open offline. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification. +Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter contains the image-editor module graph so an editor never opened online can still open offline. The Notes feature graph and shared confetti are explicitly in `PRECACHE`, together with their exact legacy helper URLs. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification. + +`static/style.css` has been removed. `static/index.html` loads numbered `static/css/00-tokens.css` through `17-research.css` in order, followed by the three domain panel stylesheets. `static/sw.js` precaches those exact versioned URLs; cascade order remains part of behavior. + +New extracted owners include `static/js/turnRendering.js` for live/canonical turn DOM reconciliation, `backgroundToolJobs.js` for deduplicated background deliveries and research status cards, `emailReplyStream.js` for email reply streams, and `chatgptSubscriptionUsage.js` for account usage UI. Settings panel modules now also include `agent.js`, `api.js`, `imageModels.js`, `speech.js`, and `writingStyle.js`. ## Security Policy @@ -76,7 +80,7 @@ Storage/secrets policy: `static/sw.js` owns PWA cache behavior: - API and non-GET requests are bypassed; -- root navigation uses stale-while-revalidate; +- only `/` navigation uses network-first with cached-shell fallback; other navigation URLs are not replaced with the app shell; - JS/CSS use network-first behavior; - other static assets use cache-first with background refresh; - `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes. @@ -97,7 +101,7 @@ Current major frontend areas include: - image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`; - gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers. -`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade. +`static/js/emailLibrary.js` is a compatibility wrapper over one unversioned package entry and explicit leaves for reader, menus, attachments, reply, settings and unsubscribe behavior; the final wrapper has ten named exports. `tests/test_email_library_module_graph_js.py` compares the unversioned public surface, imports immediate package modules in synthetic browser shells and checks quoted service-worker URLs. It does not prove every callback, nested module, load order or offline behavior. `tests/helpers/js_modules.py` uses immediate entry-first file enumeration and column-zero source extraction; its package-name binding regex is not lexical-scope analysis. Coordinator ownership: @@ -116,9 +120,15 @@ Recent browser behavior contracts include mobile chat Enter inserting newlines w The Settings finder and navigation are registry-backed, hide admin-only destinations from non-admin users, lazy-load admin panels, and keep the registry synchronized with DOM panels. Email OAuth connect preserves SMTP security and reopens the settings surface; unread message opens use one authoritative backend read/mark-seen request with stale-response guards; email-library prewarm is idle-only, single-flight, bounded to the initial page, and cancelled around visible foreground work. +## Notes Feature Ownership + +`frontend/src/features/notes/index.js` composes one canonical Notes instance from domain rules/recurrence, application operations/state, infrastructure ports and UI factories. `frontend/modules.json` publishes its source modules byte-for-byte under `static/js/features/notes/` and shared confetti under `static/js/shared/ui/`. `static/js/notes.js` is the compatibility facade: it reexports the canonical methods/default, assigns `window.notesModule` and starts idempotent background reminders. Versioned/unversioned facade imports converge on the unversioned canonical entry. `static/app.js` retains the versioned facade caller; Compare voting imports and reexports shared confetti rather than owning it. + +State mutations belong to `application/state.js`; UI gets live read-only views, and operation writes unwrap nested views to keep stored note data cloneable. The composition injects HTTP, persistence, clock/scheduler, notification and shared platform callbacks. Panel disposal releases owned portals, document/window listeners, gesture state and masonry observers; detached/closed-panel checks prevent late completions from recreating observers. Tablet reorder rewiring replaces stale binding sentinels. Reminder scheduling is page-owned and survives close/minimize; custom reminder date initialization uses DOM value assignment rather than HTML interpolation. Domain-specific drafts/reminder/selection contracts and remaining races are in `calendar-tasks-notes.md`. + ## UI Policy -- New code must run as browser ES modules without a build step. +- New runtime code remains native browser ES modules; manifest-selected maintained sources use the development publication/check step without adding a startup build. - Reuse existing CSS variables, modal/window patterns, icon style, storage helpers, and route conventions. - Custom font handling includes bundled OpenDyslexic assets plus user-supplied fonts exposed through `/api/fonts/custom`; font and text-size settings must stay coordinated between settings UI, theme helpers, and CSS variables. - Avoid relying on stale module summaries. @@ -138,25 +148,37 @@ The Settings finder and navigation are registry-backed, hide admin-only destinat Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution. -`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a `static/style.css` restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover. +`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a stylesheet cascade restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover. Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling. -Missing coverage includes: +Areas that still need explicit behavior verification include: - SPA route/static auth and no-cache headers; - CSP header contents and nonce injection for `/` and `/login`; - service-worker API/non-GET bypass and cache strategy; -- service-worker precache versus `index.html` script/module tags, including query strings (stylesheet links and their `?v=` strings are covered by `tests/test_static_stylesheet_manifest.py`; script and module tags are not); -- ongoing manifest/icon reference drift; -- module graph/load-order validation; +- complete service-worker precache membership versus HTML scripts/modules: stylesheet parity is covered by `tests/test_static_stylesheet_manifest.py`, and `tests/test_frontend_module_version_parity.py` checks selected app/index/SW versions plus regex-discovered JS/index URL identities, but neither proves complete graph evaluation; +- ongoing manifest/icon reference drift; `sw.js` now explicitly precaches the default manifest and three PWA icons; +- complete legacy module graph/load-order behavior beyond maintained-source architecture gates and the email-library browser graph; - degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path. +## Rendering, Provider UI And Static Checks + +`turnRendering.js` reconciles canonical/live ownership with turn replacement metadata while keeping tool activity. Compact corrected/final events can replace earlier prose; the server completion gate can buffer answer deltas, so this is not a promise of immediate streaming. Ordinary ask-user choices have 1–3 keyboard shortcuts; tool approval cards are excluded. Active document state restores the exact chat-bound document; editor rich-only font controls and stats portal z-order are independent of chat tool authority. The research map SVG has an image role and accessible name, without proving complete accessibility. + +Usage ledgers use nested Maps to preserve arbitrary legacy keys as data, while fresh writes require a nonempty session id and reject reserved session/run keys before accounting. JSON conversion preserves wire shape; metrics with a run id replace that run's previous value, while legacy writes without one add to aggregate totals and use per-object recorded/pending guards. Web Locks are used when available and bounded entries fold into totals, so evicted run replay is not forever deduplicated. Admin provider cards hide ChatGPT/recognized Featherless base URLs; optional fingerprints and displayed version/source commit are informational rather than byte attestation. Featherless catalog search is debounced/paginated with bounded results; optimistic pin/tool-mode PATCH saves and load-more handling do not guarantee acknowledged persistence or universal stale-response cancellation. Collapsed account usage cards retain loaded DOM, including errors, rather than proving fresh telemetry. + +Markdown SVG uses an empty-sandbox, no-referrer iframe and restrictive CSP; title extraction is flat escaped text rather than parent-DOM parsing. Fenced and complete raw SVG are separate from generic HTML fences, with bounded aspect ratio. This does not confer the different permissions used by document print/run iframes. Remote mail rendering and image thumbnail assignment retain their own sanitizer/network boundaries. + +Stylesheet helpers read the shell-ordered complete app cascade, exclude vendored libraries and share cache-token checks. Recursive lexical hygiene rejects deleted-monolith references with bounded exceptions; comment-stripped keyframe counting catches duplicate global names. Document-source helpers enumerate implementation files and scan braces lexically; quoted import/SW membership and minimum callable export tests are narrower than semantic module execution. The fixed computed-style snapshot covers 676 elements across its configured page/viewport/theme/density cases, freezes animation timing and checks a global rule-swap sensitivity count; it is not arbitrary browser/layout coverage. Manual benchmark HTML and historical screenshots are not newly reproduced acceptance. + +The website has seven hover/focus/tap preview clips, ambient video, failure placeholders and thresholded swipe navigation. Reduced-motion CSS disables selected animation rather than guaranteeing all video stops. Restored branding/PWA assets do not prove license clearance or decoded-image review; the known `/backgrounds` absent-file/auth gap remains. + ## Current Gaps -- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`. -- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only. +- The ordered stylesheet cascade and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`. +- There is no general build-time type checking or complete legacy script-order validation. `scripts/check_frontend.mjs` checks maintained-source imports, layer boundaries, explicit legacy exceptions, deployment URL/precache identity, and dependency cycles; its inventory mode reports legacy static imports/cycles. These gates cover the manifest-selected maintained graph, not every legacy module. Service-worker checks also cover stylesheet URLs and the email-library package. - Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care. - `window.*` compatibility bridges remain widespread. - PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing. -- The static asset/route manifest regression covers stylesheets referenced by app-owned HTML and `sw.js`; scripts, modules and `manifest.json` icon references are still unverified. +- Static regressions cover stylesheet references, selected script/module URL identities and maintained-output precache membership; complete legacy graph membership/evaluation and ongoing manifest icon drift still require checks. diff --git a/specs/gallery-editor-media.md b/specs/gallery-editor-media.md index edc4efa20..6a3b693b1 100644 --- a/specs/gallery-editor-media.md +++ b/specs/gallery-editor-media.md @@ -1,6 +1,6 @@ # Gallery, Editor, And Media -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -28,7 +28,7 @@ This spec covers media surfaces in: - gallery upload, library, album, tag, favorite, ZIP, delete, and saved-project views; - chat-generated image rendering/edit/delete bubbles; -- agent `generate_image` and stale `edit_image` tool paths; +- agent `generate_image` and owner-scoped `edit_image` tool paths; - MCP image-generation rows/files; - image editor AI tools and model endpoint pickers; - document PDF signing with stored signatures; @@ -51,11 +51,13 @@ Generated media provenance: - upload `file_hash` is a separate metadata field; - generated files live under `data/generated_images`; - chat image generation writes files and inserts `GalleryImage` rows through `src.ai_interaction`; -- MCP image generation can create ownerless rows/files; +- MCP image generation stamps the trusted dispatched owner when present, trims empty owner values to null, and can still create ownerless rows/files without that context; - generated-but-not-yet-imported images can have no gallery row; - once a gallery row exists, owner checks decide visibility where the route enforces them. -`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates hex-like image/video filenames, rejects path escape and missing files, serves rowless generated files, checks row owner when a row exists, allows null-owner compatibility rows, and uses immutable/nosniff cache headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters. +`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates filename syntax before ownership and disk probing. With auth enabled, `effective_user()` resolves cookie/API-token owners. A live registry entry is checked first: a matching owner authorizes, while a mismatch returns 404 without database fallback. Only absent registration reaches the gallery lookup: a nonempty row owner must match; a row without an owner instead requires a matching linked-session owner. If that lookup finds no gallery row, it requires a chat metadata reference in an owned session. Unattributed/mismatched files return 404 and database verification failures return 503. Disabled auth skips ownership verification. Confined path/existence checks follow authorization, and responses use `private, no-cache, must-revalidate` plus nosniff headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters. + +`src/generated_images.py` owns a lock-protected, process-local one-hour recent-owner registry. Image generation/edit writers in `src/ai_interaction.py` register trusted owners after writing bytes; the image MCP server stamps gallery ownership and registers after commit when an owner exists. Registry entries are ephemeral, not durable gallery ownership, and matching registry access can survive a database outage. Tool dispatch strips caller-reserved attribution keys before injecting verified owner context, including qualified image MCP and direct fallback paths. ## Image Tools And Providers @@ -92,7 +94,7 @@ Optional dependency behavior: ## Editor Drafts -`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store title, payload JSON, thumbnail, source image, timestamps, and owner. +`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store name, dimensions, payload JSON, thumbnail, source image, timestamps, and owner. Frontend editor behavior is split across `static/js/editor/*` and `static/js/galleryEditor.js`: canvas state, layer panel, masks, history, snapping, stroke pipeline, inpaint/rembg/harmonize tools, AI tool runner, model pickers, an AI edit command box that routes natural-language edit requests into existing inpaint/remove/upscale/background/style actions where possible, import wiring, topbar controls, auto-save, resume by draft ID or source image, draft-only open, and cleanup after close. `static/js/panels.js` loads this module graph on first editor use, shares concurrent imports, retries failed loads, and `static/sw.js` keeps the lazy graph in a separate offline panel precache. @@ -126,11 +128,11 @@ Known boundaries: - image-generation routes require `can_generate_images`; - image proxy/editor endpoints currently resolve client-selected, DB-selected, or fallback image model endpoints without full owner-scoped endpoint-key policy or uniform outbound revalidation; -- generated-file serving allows rowless files and null-owner compatibility rows; +- rowless generated files require an owned chat reference or recent registration; null-owner gallery rows require a matching owned session; - uploads are byte-limited and extension-gated, with content sniffing available through `UploadHandler.detect_content_type()` when `python-magic`/`libmagic` is installed; - several base64 JSON editor routes accept large decoded image payloads and need route-level size discipline; - gallery DB filenames should be joined through shared generated-media path helpers before filesystem operations; -- editor draft source image IDs, payloads, and thumbnails are owner-scoped by draft owner but do not fully validate source-gallery ownership or payload size; +- editor drafts owner-scope rows for identified users and cap serialized payload bytes, but source-gallery ownership and thumbnail size still need separate validation; - emoji proxy constrains codepoint filenames and degrades invalid, unknown, or unreachable SVGs to transparent no-store placeholders, but remote SVG content still deserves security review; - visual report Markdown HTML/link/image output needs continued sanitization coverage. - `scripts/mlx_image_server.py` pins generation/edit routing to the process-start model and ignores request-selected model names, preventing unauthenticated callers from selecting a local model directory/repository whose model-specific script or bridge would execute. @@ -142,24 +144,41 @@ Known boundaries: - Missing generated files are skipped in ZIP downloads; if all are missing, the route returns no files found. - Soft delete commits the gallery row state before removing the disk file, so a failed DB write does not orphan a missing image row. - AI tagging can fail when disk files are missing. -- Static JS/CSS/HTML assets revalidate because there is no frontend build/versioning. +- Static assets revalidate; most editor modules remain static ES modules, while `frontend/modules.json` and the committed generated-output freshness gate govern the manifest-selected native ES modules published by byte-for-byte copying to committed deployment outputs. - Gallery/editor frontend state includes stale-while-revalidate and listener cleanup to avoid stale handlers. -- `edit_image` tool schema/implementation currently appears stale against implemented `/api/image/*` and `/api/gallery/*` routes. +- `src/tools/image.py` implements `edit_image` in-process: prompt edits call the model adapter, upscale creates a 2x/4x Lanczos copy, and rembg creates a copy when the optional package is installed. It checks exact gallery owner and confined source path; uploaded attachment URIs support prompt edits after owner-aware resolution. ## Testing Coverage Existing tests cover EXIF dimensions, owner-filter helper behavior, direct upload limits, image-generation privilege source shape, sharpen auth, gallery null-user denial, endpoint SSRF/source checks, editor draft payload validation, lazy editor loading/offline precache, MLX request-model pinning, font family derivation, visual-report helper behavior, gallery CLI previews, and selected security regressions. -Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and frontend editor behavior. +Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and full browser editor integration. Focused codec, layer/mask/group, transform, history, worker, and draft-budget regressions are present; this maintenance pass did not execute them. + +## Versioned Editor Documents And Budgets + +`static/js/editor/document-codec.js` is the shared project-file/autosave serialization boundary, currently document version 15. It migrates older versions, rejects future versions, validates layer/group/mask/selection and geometry data, and bounds canvas pixels, layer counts, total surfaces, and embedded image data. Masks, adjustments, effects, groups, vector text/shapes, placed layers and view/guide state pass through this codec rather than a second hand-written draft shape, subject to serialization/decode and history limitations below; this is not lossless persistence of every mask/effect property. + +Editor responsibilities now include `document-geometry.js` and layer geometry for canvas transforms, `layer-groups.js`/`layer-selection.js` for grouping/multi-selection, `selection-mask.js` for selections, `text-layer.js`/`shape-layer.js` for vector content, and `adjustment-layer.js`/`effects.js` with worker paths for compositing operations. `history-budget.js` bounds raw ImageData history adaptively; rendering cancellation prevents stale asynchronous results from applying after newer edits. + +`routes/editor_draft_routes.py` uses `EDITOR_DRAFT_MAX_BYTES` both for early rejection of oversized declared Content-Length on writes and for the authoritative serialized-payload byte check. Missing/malformed Content-Length still reaches the latter check after parsing. This cap does not supply source-gallery ownership or a separate thumbnail budget. + +`src/media_ingress.py` separately builds bounded local multimodal attachments for supported image/video/audio/document paths with source hashes, encoding/dimension/frame limits, and artifact metadata. Agent evidence/trace consumers record ingress metadata; it is not the browser upload storage or editor serialization owner. + +## Editor Persistence And Operation Limits + +Codec limits count canvas/surface allocations and embedded strings rather than universally bounding decoded bytes. Failed canvas encoding or image restoration can omit/drop data; effect-mask normalization and history snapshots do not preserve every mask density/feather property. Async capture can span intervening edits, so it is not an immutable atomic pixel snapshot. History deduplicates shared pixel buffers and trims older snapshots to a budget while retaining the newest even when oversized; it does not serialize the entire history into a draft. + +Draft autosave reserves one in-flight capture and uses session/generation checks for assigning returned ids, with PUT404 recreation. Those checks do not cancel an old server request. The current persist chain can clear an earlier error and display Saved after catch/early return; the badge is not a durable-write receipt. Project state uses server draft ids and a compact session descriptor rather than a full browser-sessionStorage payload. Restore can drop failed raster/mask/placed sources and is not a lossless project recovery promise. + +Placed transforms, crop/resize/flip, linked versus document-space masks, grouped effects and rasterization retain distinct geometry paths. Duplicate/merge/copy operations do not guarantee independent preservation of every effect-mask/retained metadata field; raw layer copying differs from flattened canonical output. Pixel/position locks are checked on selected mutation paths rather than proving every control respects every lock. Escape cancellation is operation-specific (including crop/transform/filter prompts), not a universal rollback guarantee. Filter/worker generations prevent selected stale results, but shared intermediate canvases and Quick Edit busy-end polling do not establish atomic pixels or successful downstream AI work. ## Current Gaps - Owner-scoped endpoint-key resolution is needed for image proxy/editor routes. - Media routes need a clear API-token policy: reject token callers, or implement owner/scope handling. -- Generated-image serving needs live route tests for invalid filenames, rowless files, owned rows, null-owner rows, MIME/cache headers, and cross-owner behavior. -- Mutable generated filenames plus immutable cache headers need cache-busting tests for replace/save-over-original flows. +- Direct handler ownership/cache tests cover generated-image denial and fallback branches; middleware-mounted serving, MIME/account-switch and live browser cache behavior still need broader checks. +- Private revalidation replaces immutable generated-media caching; replace/save-over-original and account-switch behavior still warrant browser cache tests. - Base64 JSON editor payload size limits need hardening; upload content sniffing should keep native/Docker parity coverage as dependencies change. -- MCP image generation needs an owner attribution decision or explicit admin-only documentation. -- `edit_image` tool route mapping appears stale. +- Image MCP now receives trusted server-dispatch ownership; direct out-of-band MCP callers and cross-process recent-registry behavior remain separate trust/availability limits. - Emoji SVG proxy/cache and visual-report raw HTML/link sanitization need stronger tests. - Optional image dependency fallbacks are mostly untested. diff --git a/specs/integrations.md b/specs/integrations.md index 06a574e11..a23f6129f 100644 --- a/specs/integrations.md +++ b/specs/integrations.md @@ -1,6 +1,6 @@ # Integrations -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -80,17 +80,11 @@ The Cookbook scoped-agent surface currently exposes `cookbook:read` and `cookboo `src.integrations` owns generic API integration presets, `data/integrations.json`, API-key encryption/decryption, secret masking, plaintext-key migration, enabled integration prompt text, and `execute_api_call()`. -`routes.auth_routes` owns admin-only HTTP CRUD/test routes for these integrations. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health. +`routes.auth_routes` owns HTTP CRUD/test routes for these integrations, requiring an admin unless auth is explicitly disabled. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health. `api_call` is the agent/tool execution path for configured integrations. It is blocked for non-admin/public users by tool security, accepts only relative paths, uses the admin-configured base URL/auth settings, and returns truncated external responses to the model, including a sentinel when long JSON lists are shortened. Admin-authored integration descriptions are prompt context; external responses remain untrusted data. -`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and -path-only values, rejects request paths that are not relative absolute paths -(`/...`) or that carry schemes/fragments, treats `/` as the base URL without -appending an extra slash, and checks the final URL through `src.url_safety`. -Link-local/metadata targets are always rejected; setting -`INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private -addresses for operators who do not need LAN integrations. +`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and path-only values, rejects request paths that are not relative absolute paths (`/...`) or that carry schemes/fragments, treats `/` as the base URL without appending an extra slash, and checks the final URL through `src.url_safety`. Link-local/metadata targets are always rejected; setting `INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private addresses for operators who do not need LAN integrations. After validation, `execute_api_call()` pins the outbound connection to the validated IP snapshot while preserving the configured URL, Host header, TLS server name, and redirect policy. DNS cannot select a different destination between SSRF validation and transport. @@ -159,8 +153,7 @@ This spec owns the cross-integration framing and agent/token/webhook surfaces. D - Missing or corrupt `data/integrations.json` loads as an empty list; non-object rows are ignored. - Plaintext generic integration API keys migrate to encrypted storage on load. - Webhook delivery has no retry/backoff queue; the persisted state is last status or sanitized last error. -- Webhook URLs are validated at create and delivery time, redirects are disabled, - and delivery connects to the IP set validated immediately before the request. +- Webhook URLs are validated at create and delivery time, redirects are disabled, and delivery connects to the IP set validated immediately before the request. - Companion LAN detection is best-effort and falls back to local host/port defaults unless a valid `COMPANION_BASE_URL` is configured. - `ODYSSEUS_URL` must be reachable from the external coding agent; no Docker/native URL rewrite is performed. @@ -182,12 +175,30 @@ Current targeted coverage includes API-token CRUD basics, chat-scoped `/api/mode The integration audit also ran the targeted venv subset covering those areas with 52 passing tests and one warning. +## Bound External Backends + +Tool-backed integration calls consume `src.agent_runtime.remote_resources` backend identity. `src.integrations.execute_api_call()` refuses a bound call when the saved integration identity differs from the admitted resource; a changed configuration cannot silently inherit an earlier approval. MCP and client/host bridges have their own producer/configuration identities and do not gain permission from advertised schemas. + +Scoped Cookbook adapters reject internal-tool transport/header calls instead of treating internal owner attribution as an external scoped credential. In auth-disabled mode they still require the native local-operator boundary before borrowing endpoint functions. Public API-token scopes remain distinct from browser admin and internal-loopback authority. + +## Adapter Admission Limits + +Generic `app_api` refuses decoded `/api/codex/cookbook` selectors before dispatch, so it cannot wrap the dedicated scoped adapter to borrow its permissions. Integration requests preserve their explicit query/body inputs and owner/backend binding; an observed `api_call` response is an external acknowledgement, not sealed proof of remote poststate. Public scoped credentials, browser admin access and native local operator admission remain separate boundaries. + +## App Configuration Compatibility + +Integration list/create/update/delete/connectivity-test handlers in `routes/auth_routes.py` use `_require_admin_user()`: explicit disabled-auth operator mode is admitted without a session, while auth-enabled access still requires an admin. Integration listing continues masking keys. This app-configuration exception does not relax user-store administration or scoped external API authorization. + +## Delegated Upload Access + +`routes/upload_routes.py` applies a separate delegated-token owner/scope gate to browser-compatible upload endpoints. `chat` or `documents:write` permits creation/OCR edits; either of those or `documents:read` permits download/thumbnail/OCR reads. Scope is an any-of intersection, not a requirement for every listed scope. Tokens remain confined to their actual owner's non-null-owned files even when the owner is an interactive admin; invalid/missing/sentinel token identity fails closed. This supplements global token middleware rather than changing other scoped API contracts. + ## Current Gaps - Codex/Claude scoped routes, owner restoration, degraded 503 behavior, plugin zip contents, and helper-script path refusal need focused regression tests. - Token profile/update behavior and Settings agent-token scope toggles need direct coverage. - Codex Cookbook scopes need continued Settings, route-check, and `ALLOWED_SCOPES` regression coverage. -- Generic integration HTTP CRUD/test routes, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin flows need direct coverage. +- Generic integration route fixtures cover CRUD/test admission with auth enabled and disabled, using substituted stores and network calls. Live notification delivery, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin integration still need broader coverage. - `do_manage_tokens()` does not match `/api/tokens` semantics for `ody_` prefix, owner, scopes, and cache invalidation. - `do_manage_webhooks()` bypasses route behavior and does not cover signing-secret parity. - Companion read endpoints should either require `chat` scope or be documented as an explicit scope-policy exception. diff --git a/specs/llm-models.md b/specs/llm-models.md index 2613b2ee8..8bbde0077 100644 --- a/specs/llm-models.md +++ b/specs/llm-models.md @@ -1,6 +1,6 @@ # LLM Models And Endpoints -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-15 ## Scope @@ -10,7 +10,7 @@ This spec covers model/provider behavior in: - `src/endpoint_resolver.py`; - `src/foreground_model_routing.py`; - `src/model_discovery.py`; -- `src/model_context.py`; +- `src/model_context.py`, `src/generation_budget.py`, `src/model_pricing.py`, `src/model_profiles.py`, and `src/utility_effort.py`; - `src/model_capabilities.py`; - `src/model_capability_readers/`; - `src/task_endpoint.py`; @@ -32,41 +32,19 @@ This spec covers model/provider behavior in: `llm_core` owns payload shape. Route files and chat/agent code should request a call; they should not duplicate provider-specific payload quirks. -Kimi Code User-Agent discovery has both sync and async implementations. Async -post and stream paths probe `/models` through their existing async client and -await each candidate, so header negotiation does not block the event loop; both -paths share the accepted-value cache and 403 fallback policy. +Kimi Code User-Agent discovery has both sync and async implementations. Async post and stream paths probe `/models` through their existing async client and await each candidate, so header negotiation does not block the event loop; both paths share the accepted-value cache and 403 fallback policy. Provider-specific behavior is part of this layer: `LLM_CONNECT_TIMEOUT` controls the connect budget for sync and streaming calls, Kimi Code endpoints retry a small whitelisted User-Agent set on 403 and cache the accepted value, official Moonshot/Kimi Code and Anthropic Opus 4.7+ payloads omit sampling controls where required, and major-only Opus IDs such as `claude-opus-5` also omit temperature instead of falling through numeric minor-version parsing. Reasoning models omit or clamp unsupported temperature values, while self-hosted compatible endpoints keep normal parameters unless detected otherwise. Mistral structured content is normalized in async utility calls as well as stream/chat paths, and Mistral/Moonshot/Kimi reasoning content, `gpt-oss` harmony output, DeepSeek V4 thinking identifiers, and native/OpenAI-compatible Ollama thinking formats keep hidden reasoning separate from visible text. Tool names that collide with GPT-OSS built-ins are aliased on the provider boundary and mapped back before execution. Copilot request metadata remains defensive against malformed `request_flags`. ## Canonical Provider And Model Shape -`src.model_capabilities` owns canonical model family, task, modality, -capability, limit, evidence, assertion, deterministic-control, probe-result, -reasoning-control token, and display-query values. -`src.model_capability_readers` owns endpoint-scoped stable identity, lightweight -provider detection, record serialization, and normalization of already-fetched -provider payloads. Readers do no network I/O. Model-specific observations are -kept in `model-quirks.md`, not a runtime registry without a consumer. +`src.model_capabilities` owns canonical model family, task, modality, capability, limit, evidence, assertion, deterministic-control, probe-result, reasoning-control token, and display-query values. `src.model_capability_readers` owns endpoint-scoped stable identity, lightweight provider detection, record serialization, and normalization of already-fetched provider payloads. Readers do no network I/O. Model-specific observations are kept in `model-quirks.md`, not a runtime registry without a consumer. -Provider support and model support are different facts. A provider may expose -tools, reasoning, vision, or multiple APIs while individual models differ. -Provider-native readers describe where model evidence can appear. Current -concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter, -Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain -unknown. +Provider support and model support are different facts. A provider may expose tools, reasoning, vision, or multiple APIs while individual models differ. Provider-native readers describe where model evidence can appear. Current concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter, Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain unknown. -Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]` -or `models[]` items with `id`, `name`, or `model`; it does not accept a bare -list and never promotes capability-looking fields. Unknown fields remain in -the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md), -[model-quirks.md](model-quirks.md), and the -[provider map](model-providers/_readme.md). +Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]` or `models[]` items with `id`, `name`, or `model`; it does not accept a bare list and never promotes capability-looking fields. Unknown fields remain in the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md), [model-quirks.md](model-quirks.md), and the [provider map](model-providers/_readme.md). -This canonical layer is currently exercised by focused unit tests but is not -wired into runtime discovery, endpoint resolution, model context, request -shaping, or frontend pickers. `routes/model_routes.py` model probes continue to -return model IDs through their existing runtime path. +Canonical records are exercised by focused unit tests but are not wired into runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers. The reader package’s `detect_vendor()` helper does have narrow runtime consumers in `src/ai_interaction.py` and gallery routes for OpenRouter image transport; that does not integrate canonical capability records. `routes/model_routes.py` model probes continue to return model IDs through their existing runtime path. Route-level probe helpers in `routes/model_routes.py` are the current exception: they build minimal provider-specific probe payloads using `llm_core` detection helpers. Keep probe behavior aligned with `llm_core` provider adapters. LLM provider HTTP clients and endpoint probes share `src.tls_overrides.llm_verify()`, which can add an operator-provided `LLM_CA_BUNDLE` on top of normal certificate verification without turning verification off or widening that trust to arbitrary URL fetches. @@ -86,7 +64,7 @@ OpenAI-compatible model-list URL construction preserves `/v1` bases and inserts `routes/session_routes.py` owns binding sessions to endpoint IDs, owner-scoped header construction, raw-endpoint rejection for non-admin users, model validation, and persisted session headers. Compare panes and normal chat session creation use this path. -`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while admins can see all. Secret fields must remain encrypted and scrubbed in responses. +`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, per-model tool-mode overrides, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while ordinary admin inventory can see all. ChatGPT Subscription rows are an exception: account visibility requires exact effective-owner equality even for admins. Secret fields must remain encrypted and scrubbed in responses. Decrypted endpoint headers can be copied into session metadata for chat use. Endpoint deletion must clear dependent settings and copied session headers. @@ -94,7 +72,7 @@ Decrypted endpoint headers can be copied into session metadata for chat use. End `src.model_discovery` owns host/env/Tailscale/local-port scanning for model servers. Admin `/api/providers` and `/api/discover` use that scanner; endpoint CRUD, test, refresh, and hidden-model controls are frontend-owned by `static/js/admin.js`. -`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` owns default, utility, vision, image, TTS, STT, and fallback selectors. +`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` coordinates default, utility, vision and fallback selectors; `static/js/settings/imageModels.js` and `static/js/settings/speech.js` own their extracted image and TTS/STT panels. `src.task_endpoint` owns background-task endpoint/model resolution for task routes and scheduler callers. It resolves `task_endpoint_id`/`task_model` through the normal endpoint resolver with owner context. @@ -102,7 +80,7 @@ Cookbook and HWFit own local model download, serve, ranking, and auto-registrati ## Context Length -`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows. +`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Local llama.cpp context probes try `/slots` and then `/props.default_generation_settings.n_ctx`. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows. ## Runtime Fallback And Routing @@ -127,6 +105,18 @@ Provider tool calls are untrusted requests, not authorization. `supports_tools` - SSE/stream parsers tolerate null choice/usage/tool-call entries and null streaming tool-call arguments; provider events should degrade to empty text or shaped stream errors instead of crashing the chat loop. - Provider adapters carry small model-specific quirks: Opus 4.7+ and official Kimi/Moonshot code payloads omit `temperature`, Kimi/Moonshot/Mistral reasoning content is preserved separately, ChatGPT Subscription refreshes bearer credentials, native Ollama can handle multimodal content, and Ollama `/v1` responses for Qwen3/Gemma4-style thinking can suppress thinking text when requested. +## Additional Runtime Owners + +`src.generation_budget` estimates native tool-schema and image reserve costs, fits output limits to the remaining context budget, and parses provider context errors for bounded recovery. `llm_core` uses these helpers in async/streaming paths; recovery is separate from switching to a fallback model. `src.model_context.estimate_text_tokens()` counts non-ASCII characters individually and Latin characters at roughly 0.3 tokens each, including tool-call names/arguments. These remain estimates, not tokenizer guarantees. + +`src.model_profiles` selects the Odysseus compact schema and progressive-thinking profile from bounded `odysseus`/`ajax` tokens in the model ID leaf. `supports_user_thinking_toggle()` uses explicit model-family patterns and suppresses the toggle for that profile and GPT/o-series leaves; these are runtime heuristics outside the canonical reader. Endpoint `model_tool_modes` accepts `none`, `compact`, or `full` per model, with legacy aliases normalized by model routes. This is schema/routing configuration, not tool execution authority. + +`src.utility_effort` resolves owner-aware utility effort, including fallback-specific overrides and eligible session-route effort. Validation currently uses ChatGPT model metadata, including maintained defaults and model-name fallback metadata; it is not evidence from the canonical reader. `src.model_pricing` owns server-side known-price estimates; unknown models return no estimate and local/subscription endpoints are excluded from per-token cost tracking. + +Featherless endpoints use an explicit pin inventory instead of bulk catalog refresh. Admin `GET /api/model-endpoints/{ep_id}/catalog-search` searches active conversational models available on the current plan with bounded pagination and a 45-second in-memory query cache. New Featherless rows default to an empty pin list. `/api/models?refresh=true&wait_refresh=true` can wait up to 15 seconds on the shared background refresh. + +`src.endpoint_resolver.resolve_owner_registered_endpoint()` accepts only an enabled owner-visible registered base or its derived chat URL, returning the server-owned row. URL identity includes scheme, host, effective port, and path; caller credentials, query strings and fragments are rejected. `same_endpoint_base()` governs exact origin/base-path credential reuse. Native runtimes can rewrite persisted `host.docker.internal` bases to loopback. + ## Security Policy - Endpoint API keys are encrypted in `ModelEndpoint.api_key` and never returned by endpoint APIs; admin surfaces return key presence only. @@ -143,9 +133,15 @@ Provider tool calls are untrusted requests, not authorization. `supports_tools` - utility model fallbacks for summarization/extraction; - frontend Settings and model picker endpoint management. +## Discovery And Route Identity Details + +`src.model_discovery` timestamps successful Tailscale host queries, including an empty result, for the 60-second cache. A failed query does not become a successful empty observation. This discovery cache is separate from endpoint URL resolution and model-context probe caches. + +Exact registered endpoint authority rejects URL credentials, parameters, and any query or fragment delimiter, including an empty one; it accepts the registered base or an explicitly derived inference route rather than broad same-host aliases. Owner-visible shared endpoint rows remain distinct from exact-owner-only data. Utility effort forwarding remains explicit at participating callers; a configured off/none/default selection can suppress session effort rather than being treated as a missing override. + ## Current Gaps -- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; the canonical reader package has no production consumer yet. +- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; canonical records have no runtime model-catalog consumer yet, while provider detection is reused by media paths. - Provider-specific behavior is concentrated in `llm_core.py`, which is large and easy to regress. - Several runtime request builders still use model-name heuristics. They should migrate only after endpoint/provider code supplies structured identity and a real consumer contract; the canonical catalog does not add a parallel quirk matcher. - Endpoint identity and fallback behavior need careful review when new OAuth/subscription providers are added. diff --git a/specs/memory-skills.md b/specs/memory-skills.md index 2933c6c68..291061b5b 100644 --- a/specs/memory-skills.md +++ b/specs/memory-skills.md @@ -1,6 +1,6 @@ # Memory And Skills -Last updated: dev@2e2bb52 | 2026-08-16 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -14,7 +14,7 @@ This spec covers persistent memory and user skills in: - provider abstractions in `src/memory_provider.py`; - LLM extraction/audit in `services/memory/memory_extractor.py`; - skill storage, format, import, and extraction in `services/memory/skills.py`, `services/memory/skill_format.py`, `services/memory/skill_importer.py`, and `services/memory/skill_extractor.py`; -- skill routes in `routes/skills_routes.py`; +- skill HTTP/compatibility bindings in `routes/skills_routes.py` and evaluation/audit/job/model/prompt/publication services under `services/skills/`; - prompt/tool call sites in `src/chat_processor.py`, `src/agent_loop.py`, `src/ai_interaction.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_security.py`; - MCP and Codex surfaces in `mcp_servers/memory_server.py` and `routes/codex_routes.py`; - backup/admin/CLI surfaces in `routes/backup_routes.py`, canonical `routes/admin_wipe/admin_wipe_routes.py` plus its shim, `scripts/odysseus-memory`, `scripts/odysseus-skills`, and `scripts/odysseus-backup`; @@ -43,25 +43,23 @@ Extraction handles reasoning-model response shapes and records explicit dislike/ `services/memory/skills.py` owns disk-backed skill storage under `data/skills///SKILL.md`, plus `_usage.json` usage/audit sidecars. Legacy `data/skills.json` is a read-only fallback/import source, not the current write shape. -`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns CRUD/search/index/import, owner filtering, skill test/audit jobs, and admin-gated built-in tool instruction overrides. +`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns HTTP CRUD/search/index/import, authentication/owner verification, exact approval validation, request models and admin-gated built-in instruction overrides. Evaluation, execution, job coordination, audit sequencing and publication policy now live under `services/skills/`; durable identity/storage/lifecycle remains in `services/memory/skills.py`. Skill extraction is owned by `services/memory/skill_extractor.py`. It can suggest or save skills from conversations, tries valid brace-delimited JSON candidates with `JSONDecoder.raw_decode()`, rejects ambiguous multiple top-level JSON objects instead of guessing, and saved skills remain user-editable data. Agent skill behavior: - matched skills are owner-scoped, confidence-gated, usage-counted, and wrapped as untrusted context; -- `index_for()` exposes published skills plus teacher-escalation drafts gated by platform and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate; +- `index_for()` exposes built-ins and eligible user skills gated by current approval policy, platform, local-model status, and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate; - user prefs such as skills enabled, auto-approve, and max injected skills shape runtime insertion; -- the level-0 base skill index currently calls `index_for(owner=None)`, so it is not fully owner-scoped. +- the level-0 skill index passes the acting owner into `index_for()`, applies active-toolset/platform and local-model gates, and returns its user-editable index block separately for untrusted-context wrapping. - skill tests use the configured utility model rather than the chat default and wrap user-editable skill text as untrusted context; approval continuation for a test or teacher-generated skill uses the same exact-action gate as the normal agent loop. ## Tools, MCP, And Backup Native `manage_memory` and `manage_skills` tool paths pass owner context and use in-process policy gates. `manage_skills` requires an explicit action instead of silently defaulting a malformed call. Manual memory add can choose a category, and route-side manual add validates the source session owner before attaching session-derived memories. `mcp_servers/memory_server.py` lazy-initializes `src` managers and exposes list/add/edit/delete/search. It can scope to `ODYSSEUS_MCP_MEMORY_OWNER` or `ODYSSEUS_MEMORY_OWNER`; if the JSON store contains owner-bearing entries and no owner env is configured, it returns an owner-scope error instead of listing or mutating across owners. Ownerless stores remain ownerless compatibility mode. -The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows -when checking whether its newly added entry is already present; it ignores -those rows instead of calling mapping methods on them and crashing the add. +The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows when checking whether its newly added entry is already present; it ignores those rows instead of calling mapping methods on them and crashing the add. `/api/export` owner-filters memories and skills. `/api/import` imports skills through current disk-backed `SkillsManager` APIs, stamping missing owners to the importer and preserving supported skill metadata. Full data snapshots through `scripts/odysseus-backup` preserve on-disk skill trees, memory JSON, and caches differently from JSON import/export. @@ -92,7 +90,7 @@ Owner isolation is surface-specific: - native memory/skill tools are expected to pass owner context; - Codex exposes scoped token memory behavior separately; - normal memory/skills routes are cookie/current-user surfaces, not scoped token APIs; -- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index currently has ownerless/global behavior; +- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index now passes the acting owner; - vector dedup during memory extraction suppresses only same-owner or legacy-ownerless vector matches. Skill test/audit flows intentionally run user-editable `SKILL.md` content as instructions inside controlled jobs. Those jobs rely on route owner checks, admin gates where applicable, and tool execution policy. @@ -107,10 +105,41 @@ Existing tests cover memory extraction/degraded vectors, owner isolation, unread Route-level memory CRUD/security, skills route security, MCP memory behavior, vector degraded writes, compatibility facade owner behavior, backup skill import, admin vector cleanup, and frontend endpoint wiring need broader coverage. +## Skill Approval, Retrieval, And Persona Continuity + +`services/memory/builtin_skills.py` synchronizes tracked `BUILTIN_SKILLS_DIR` procedures at startup into an immutable ownerless shared catalog, published with confidence 1.0. Owner-aware list/view paths expose these built-ins alongside the owner's skills; built-ins skip user audits. User-editable memories and imported/extracted skills retain their untrusted-context treatment. + +`SkillsManager.index_for()` excludes failing user audits and unnecessary skills. With automatic approval enabled, a user skill needs published status, a passing audit, and confidence at the owner's current threshold; a stale published flag alone is insufficient. Manual publication supplies approval when automatic approval is disabled. `remote-only` skills are hidden from local/private-endpoint model indices. Search also gates toolsets and platforms, combining lexical scores with optional semantic scores from `src/skill_index.py`; semantic retrieval reuses an already-ready ToolIndex embedding lane and falls back to lexical search without a foreground cold model load. + +Skill audit protocol v2 separates functional pass/fail from no-skill baseline utility, storing summary, baseline verdict, usefulness, saved turns/tool calls, and audit version in `_usage.json`. Legacy inconclusive v1 results are eligible for re-audit. `services/memory/skill_lifecycle.py` supplies a bounded automatic queue: transient/inconclusive checks retry daily, failed/needs-work repairs weekly, and built-in/binned/pass/skipped entries are excluded. Route jobs preserve request authority and exact-action approval continuation. + +Memory extraction automatically pins new identity/contact facts only while the count of already-pinned identity/contact entries for the acting owner and compatible ownerless entries is below five; it does not unpin older entries. Extra facts remain retrievable by relevance. Persona continuity extraction stores concise notes in preset/persona data through `PresetManager.update_persona_memory()`, separately from global memory JSON; health personas use a cautious medical brief schema and persona deletion removes its notes. + +## Mutation And Audit Lifecycle Limits + +Memory edit/delete paths reject empty identities instead of allowing an empty substring match, and mutating operations preserve unreadable-store refusal. Persona extraction updates an existing persona rather than creating a missing persona implicitly; persona notes remain separate from global memory. + +`services/skills/publication.py` implements batch finalization behind the route compatibility wrapper `_finalize_audit_batch()` and changes only skills processed by that job, protects approval-required results, and retains binned duplicates on disk while excluding them from retrieval. Name normalization and a 0.38 token-Jaccard threshold group potential duplicates; this heuristic is not semantic equivalence. Functional success and baseline usefulness remain separate audit dimensions, so a successful baseline does not itself make the skill functionally inconclusive. + +`SkillAuditUnavailable` stops the batch without treating infrastructure failure as a skill verdict. Other audit errors can record inconclusive results. Manual test and audit agent streams use `contextlib.aclosing`, including early exits for exact-action approval, so the originating agent task closes rather than remaining suspended behind a pending choice. These lifecycle guarantees do not establish successful model execution when the provider or containment backend is unavailable. + +## Skill Test Endpoint Recovery + +Skill tests prefer the configured utility route; their registered fallback resolves server-owned runtime credentials and does not trust caller-supplied provider headers. The audit-arm path raises `SkillAuditUnavailable` on stream failure and stops the batch without a skill verdict. Manual primary-stream exceptions instead retain a partial transcript for subsequent baseline/evaluation; if grading returns `fail`, confidence can still decrease. An unavailable result is not guaranteed for every manual-test failure. + +## Skills Service Boundaries + +`services/skills/evaluation.py` owns verdict parsing/grading, necessity/retrieval assessment, efficiency fields and markdown improvement. `agent_runner.py` owns manual/audit agent streams, transcripts, request/exact-approval authority and same-task `aclosing`. `audit_workflow.py` owns per-skill checks, self-edit/worker retry, optional distinct-teacher rewrite, sequential batches and scheduled selection. `job_coordinator.py` owns transient job transitions, candidate selection, safe polling projections and cancellation requests. `model_selection.py` resolves owner-scoped worker/teacher credentials and utility fallback; `prompts.py` constructs tasks and explicitly untrusted skill messages. `publication.py` applies confidence/duplicate/publishing policy through SkillsManager; its preferences adapter still imports `routes.prefs_routes`. + +Routes retain patchable aliases/wrappers, including the service-owned manual/audit registry dictionaries and `SkillAuditUnavailable`. A frozen `AuditDependencies` is built from current route bindings at service entry, preserving caller/test seams without rebinding service globals or importing the Skills route module from services. `app.py` still registers the same router and built-in actions still use route compatibility helpers. Registries are process-local jobs, not durable stores. + +Manual completion adjusts confidence (pass .95, needs_work .6, fail .4) without publishing. Audits compare skill/no-skill arms on one task; clean/self-edit/teacher passes use .95/.85/.8, and continued non-pass after retries becomes draft at .35. Initial unknown/inconclusive verdicts preserve publication state during per-skill finalization; this does not cover rewrite retries or later batch duplicate finalization. Per-skill auto-publish requires enabled owner policy/threshold and no necessity/utility/duplicate blocker. Batch duplicate finalization instead selects a processed passing keeper at or above the threshold, can publish it under the owner policy without reapplying those per-skill blockers, and bins other eligible processed duplicates; approval-required and unrelated skills remain protected from that batch mutation. + +Approval validation stays in HTTP: pending job/id/owner/empty-session must match and the sealed grant is consumed with `allow_continuation=False` before continuation. Resumed skill/transcript context remains untrusted; unattended arms deny encountered approvals. Infrastructure unavailability stops a batch without a skill verdict or finalization. Existing limits remain: rewrite retry results other than pass, including unknown/inconclusive or an approval pause, can still lead to demotion, and manual cancellation may retain private continuation state. Extraction does not add model/containment availability or broad behavioral equivalence guarantees. + ## Current Gaps - `services/memory/service.py` needs an explicit owner-scope/support decision before it is treated as a public memory API. -- The agent level-0 skill index should thread owner or be documented as an intentional local/global index. - MCP memory still needs a deliberate multi-user UX/config decision, but current behavior avoids cross-owner access when owner-bearing rows exist without an explicit MCP owner env. - Memory JSON import does not rebuild vector indexes. - Admin wipe vector clearing is currently ineffective. diff --git a/specs/model-capability-canonical.md b/specs/model-capability-canonical.md index 75e646f37..5648c61c9 100644 --- a/specs/model-capability-canonical.md +++ b/specs/model-capability-canonical.md @@ -1,6 +1,6 @@ # Canonical Provider And Model Capability Layer -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-14 ## Scope @@ -15,14 +15,9 @@ This spec covers the implementation introduced on current `dev` in: - regression coverage in `tests/test_model_capabilities.py` and `tests/test_model_capability_readers.py`. -The layer normalizes already-fetched JSON-compatible values. It performs no -network I/O, does not shape provider requests, does not persist its output, and -does not authorize model or tool use. No production caller currently consumes -the canonical records outside this package; runtime integration remains later -work. +The layer normalizes already-fetched JSON-compatible values. It performs no network I/O, does not shape provider requests, does not persist its output, and does not authorize model or tool use. No production caller currently consumes the canonical records outside this package; runtime integration remains later work. -There is no `src/provider_capability_schemas.py`, capability-specific -diagnostics module, or runtime model-quirk registry on current `dev`. +There is no `src/provider_capability_schemas.py`, capability-specific diagnostics module, or runtime model-quirk registry on current `dev`. ## Layer Boundaries @@ -47,9 +42,7 @@ diagnostics module, or runtime model-quirk registry on current `dev`. capability object, assertions, deterministic controls, and optional raw provider evidence. -Provider transport support and per-model support are separate facts. Request -and response adapters remain in `src.llm_core` and related provider modules. -Model-specific observations remain in [model-quirks.md](model-quirks.md). +Provider transport support and per-model support are separate facts. Request and response adapters remain in `src.llm_core` and related provider modules. Model-specific observations remain in [model-quirks.md](model-quirks.md). ## Current Serialized Shapes @@ -70,20 +63,13 @@ Model-specific observations remain in [model-quirks.md](model-quirks.md). } ``` -`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`, -`stable_model_id`, `display_name`, `capability_assertions`, and -`deterministic_controls`. It does not currently emit a schema version or the -flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are -included only when the caller passes `include_raw=True`. +`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`, `stable_model_id`, `display_name`, `capability_assertions`, and `deterministic_controls`. It does not currently emit a schema version or the flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are included only when the caller passes `include_raw=True`. -Endpoint configuration can explicitly map `model_type=llm` to chat and -`model_type=image` to image generation. Missing or unrecognized endpoint types -stay unknown rather than silently becoming chat-capable in this schema layer. +Endpoint configuration can explicitly map `model_type=llm` to chat and `model_type=image` to image generation. Missing or unrecognized endpoint types stay unknown rather than silently becoming chat-capable in this schema layer. ## Identity And Reader Dispatch -`records_from_payload()` selects a reader from an explicit `vendor`, or from -`detect_vendor(base_url, endpoint_kind)` when no vendor is supplied. +`records_from_payload()` selects a reader from an explicit `vendor`, or from `detect_vendor(base_url, endpoint_kind)` when no vendor is supplied. Current detection order and behavior are: @@ -94,11 +80,7 @@ Current detection order and behavior are: These are normalization hints, not authorization. Hostname checks accept an exact domain or its dot-delimited subdomains after lowercasing and removing a trailing dot, so names such as `notopenai.com` do not match `openai.com`; local-port mappings remain intentionally covered by tests. Callers must not treat any result as proof of endpoint trust. -Implemented reader modules are `generic_openai`, `openai`, `openrouter`, -`google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face, -SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the -generic identity-only reader. Other explicitly supplied vendor strings are -also preserved while using that generic reader. +Implemented reader modules are `generic_openai`, `openai`, `openrouter`, `google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face, SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the generic identity-only reader. Other explicitly supplied vendor strings are also preserved while using that generic reader. Stable model identity is scoped in this order: @@ -108,15 +90,9 @@ Stable model identity is scoped in this order: ## Generic Identity-Only Contract -The generic reader accepts mapping payloads containing `data[]` or `models[]`. -Each item must itself be a mapping and provide `id`, `name`, or `model`. -Bare-list payloads and `key`/`slug`-only items are not accepted by the current -implementation. +The generic reader accepts mapping payloads containing `data[]` or `models[]`. Each item must itself be a mapping and provide `id`, `name`, or `model`. Bare-list payloads and `key`/`slug`-only items are not accepted by the current implementation. -The reader deliberately returns unknown family, modalities, capabilities, and -controls. It preserves the raw item on the in-memory record but does not parse -type/task fields, descriptions, ownership, supported-parameter lists, -capability-looking booleans, or token limits. +The reader deliberately returns unknown family, modalities, capabilities, and controls. It preserves the raw item on the in-memory record but does not parse type/task fields, descriptions, ownership, supported-parameter lists, capability-looking booleans, or token limits. ## Provider-Native Readers @@ -136,22 +112,13 @@ capability-looking booleans, or token limits. served model. It records tool/streaming claims, explicit unsupported vision/audio assertions, controls, and runtime/training/size limits. -Readers tolerate non-object entries and unknown fields where their helpers -permit it. They do not infer authoritative capability from model IDs or display -names. +Readers tolerate non-object entries and unknown fields where their helpers permit it. They do not infer authoritative capability from model IDs or display names. ## Evidence Semantics -The canonical vocabulary includes admin override, endpoint configuration, -provider reader, Cookbook/Hugging Face, maintained registries, heuristic, -probe, and unknown sources. It also defines explicit, provider-reported, -registry, heuristic, and unknown confidence values. +The canonical vocabulary includes admin override, endpoint configuration, provider reader, Cookbook/Hugging Face, maintained registries, heuristic, probe, and unknown sources. It also defines explicit, provider-reported, registry, heuristic, and unknown confidence values. -Those tokens make evidence representable; current `dev` does not implement a -global precedence, merge, expiry, or conflict-resolution engine. Assertions -generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass -to verified, fail to unsupported, and partial to claimed at the scope carried -by that object. +Those tokens make evidence representable; current `dev` does not implement a global precedence, merge, expiry, or conflict-resolution engine. Assertions generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass to verified, fail to unsupported, and partial to claimed at the scope carried by that object. ## Tests @@ -165,6 +132,8 @@ Focused tests pin: - OpenRouter, Google, Ollama, LM Studio, and llama.cpp mappings; - negative cases that avoid name-based media/capability inference. +The provider detector has narrow production consumers in `src/ai_interaction.py` and `routes/gallery/gallery_routes.py` for selecting OpenRouter media request behavior. `ModelCapabilityRecord` normalization remains disconnected from runtime model discovery and request builders; helper reuse must not be described as full canonical integration. + ## Current Gaps - Canonical records are not yet used by runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers. diff --git a/specs/model-providers/_readme.md b/specs/model-providers/_readme.md index 61d89b902..b2f300756 100644 --- a/specs/model-providers/_readme.md +++ b/specs/model-providers/_readme.md @@ -1,6 +1,6 @@ # Provider Capability Specs -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-14 ## Scope @@ -54,6 +54,7 @@ Current reader dispatch does not infer a provider from payload shape. It uses an - [github-copilot.md](github-copilot.md): account model-list observations and required runtime headers. - [chatgpt-subscription.md](chatgpt-subscription.md): Codex model identity and Responses event shape. - [cohere.md](cohere.md): native endpoint/catalog observations; not currently normalized. +- [featherless.md](featherless.md): plan-filtered catalog search and explicit picker pins without bulk discovery. ### Other provider identity and general/identity-only observations diff --git a/specs/model-providers/chatgpt-subscription.md b/specs/model-providers/chatgpt-subscription.md index a961c1714..3ab9e67fc 100644 --- a/specs/model-providers/chatgpt-subscription.md +++ b/specs/model-providers/chatgpt-subscription.md @@ -1,43 +1,40 @@ # ChatGPT Subscription Provider Shape -Last updated: dev@e57f60b | 2026-07-20 +Last updated: dev@86b886c | 2026-10-15 ## Scope -Canonical provider ID `chatgpt_subscription`; Codex Responses transport; -auth and runtime code in `src/chatgpt_subscription.py`, -`routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`. -There is no dedicated ChatGPT Subscription canonical reader on current `dev`. +Canonical provider ID `chatgpt_subscription`; Codex Responses transport; auth and runtime code in `src/chatgpt_subscription.py`, `routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`. There is no dedicated ChatGPT Subscription canonical reader on current `dev`. ## Catalog Shape -The account-scoped Codex models endpoint returns root `models[]`; `slug` is the -request identity and `visibility`/`priority` control availability/order. These -fields do not prove tools, reasoning, vision, or context. Null/malformed model -lists fail soft rather than crashing discovery (#5280/#5281). +The account-scoped Codex models endpoint returns root `models[]`; `slug` is the request identity and `visibility`/`priority` control availability/order. These fields do not prove tools, reasoning, vision, or context. Null/malformed model lists fail soft rather than crashing discovery (#5280/#5281). -The canonical generic reader does not accept `slug`-only items, so this runtime -catalog is not currently normalized into `ModelCapabilityRecord` values. +The canonical generic reader does not accept `slug`-only items, so this runtime catalog is not currently normalized into `ModelCapabilityRecord` values. ## Request And Response Shape -Transport uses a ChatGPT backend Responses endpoint, `input` items, flattened -function tools, streamed function-call argument events, exact `call_id`, and -`function_call_output` continuation. Parallel calls and encrypted reasoning -continuity require preserving typed output/history rather than coercing all -roles to text. This shape is supported by the existing adapter and the focused -tool-calling follow-up evidence in #5490; unmerged observations remain claimed -until integrated/reproduced. +Transport uses an inference-only ChatGPT backend Responses payload allowlist: model, instructions, input, stream, store, optional temperature and reasoning effort. It drops native tools, tool choice, previous-response/background fields and unrelated options. `build_responses_input()` flattens conversation history into text input/output items; multipart image blocks are not preserved. Native tool calls/results and encrypted typed reasoning continuity are not preserved as a general protocol. Agent tools therefore use the text protocol on this provider rather than a claimed native function-call continuation. -OAuth/device credentials and refresh are provider-session behavior. Expired -credentials should return an actionable reconnect error, not generic model -failure. +OAuth/device credentials and refresh are provider-session behavior. Expired credentials should return an actionable reconnect error, not generic model failure. ## Fallback And Safety -Only the explicit internal base/ChatGPT host selects this provider. Never send -subscription credentials to a custom OpenAI-compatible URL. Catalog slugs stay -identity-only unless account-scoped fields or probes supply capability. +Only the explicit internal base/ChatGPT host selects this provider. Never send subscription credentials to a custom OpenAI-compatible URL. Catalog slugs alone remain identity-only. Runtime catalog metadata can retain advertised reasoning levels, but the runtime also supplies maintained defaults and model-name fallback metadata; those are not canonical verified capability cards. + +## Account And Usage Ownership + +Device-flow setup supports multiple accounts using stable provider-auth IDs; display labels are not identity. Reconnect, delete, inventory and account usage are owner-scoped. Subscription endpoint inventory applies exact effective-owner equality even for admins, and refresh deduplication includes the provider-auth ID to keep accounts separate. + +`GET /api/chatgpt-subscription/accounts/{auth_id}/usage` returns normalized rate-limit telemetry for exactly one owned auth session. `src.chatgpt_subscription.UsageCache` keeps deep-copied normalized snapshots for 45 seconds, bounded to 256 auth-id entries with FIFO eviction rather than single-flight refresh, and ownership is checked before cache hits. Forced refresh replaces the snapshot; reconnect/deletion invalidates cached usage. Errors return credential-free unavailable payloads. `static/js/chatgptSubscriptionUsage.js` owns the frontend read/display flow. + +Runtime catalog metadata retains supported/default reasoning levels and is exposed through `/api/models` model metadata. `validate_reasoning_effort()` omits unsupported overrides; its maintained defaults and name-based fallback are runtime compatibility rules rather than canonical provider evidence. + +## Binding And Telemetry Limits + +New account setup creates a distinct auth/endpoint pair. Sessions persist endpoint id so the bound endpoint wins over labels; removed or disabled bound endpoints fail closed rather than borrowing another login. Legacy unbound sessions can bind the oldest enabled owner-visible URL-matching endpoint before resolving its exact owner-scoped auth session. Runtime bearer credentials are attached request-locally only to the registered subscription route. Forked session account identity and usage cache ownership remain independent of chat approval grants. + +Usage parsing bounds finite numeric telemetry, preserves missing values as unknown and shapes credential-free errors; a JWT claim can supply unverified routing metadata without becoming verified identity. Usage cache and optimistic UI expansion do not guarantee fresh quota or live provider acceptance. Session `effort:` is validated on model changes/partial generation settings, and reasoning effort partitions inference cache identity; metadata defaults/name fallbacks remain compatibility evidence. ## Current Gaps diff --git a/specs/model-providers/featherless.md b/specs/model-providers/featherless.md new file mode 100644 index 000000000..e49c3e332 --- /dev/null +++ b/specs/model-providers/featherless.md @@ -0,0 +1,26 @@ +# Featherless Provider Shape + +Last updated: dev@86b886c | 2026-10-15 + +## Scope + +Runtime provider ID `featherless`; OpenAI-compatible calls in `src/llm_core.py`, catalog and picker ownership in `routes/model_routes.py`, and setup/selection UI in `static/js/admin.js` and `static/js/providers.js`. No dedicated canonical capability reader exists. + +## Catalog And Picker + +Normal endpoint probing returns no bulk Featherless inventory. New rows use API endpoint kind and an explicit empty pin list; only selected model IDs become picker inventory. Admin `GET /api/model-endpoints/{ep_id}/catalog-search` queries `/v1/models` with `q`, `available_on_current_plan=true`, `status=active`, `conversational=true`, and bounded `page`/`per_page` (maximum 100). Its in-memory cache lasts 45 seconds and keys endpoint ID, normalized query and pagination. Upstream authorization, rate-limit and timeout failures become route errors rather than fabricated model cards. + +Reachability tries `/v1/plan` and then a one-item plan-filtered model query. Endpoint status and selected model inventory are distinct facts; a reachable account need not have any pinned models. + +## Request And Capability Boundaries + +Calls use the OpenAI-compatible runtime adapter. Model availability on the current plan does not prove native tools, reasoning, modality, or context. Per-model endpoint tool modes are operator configuration; canonical generic normalization remains identity-only. + +## Selection UI Limits + +The admin picker debounces queries after two characters, pages bounded catalog results and saves pins/tool modes optimistically. Initial search cancellation does not establish equivalent load-more generation guards, and ignored PATCH response status does not prove accepted persistence. Lowercased query cache identity, explicit empty pins and endpoint reachability remain separate from provider plan/capability acceptance. + +## Current Gaps + +- No native canonical reader maps plan/catalog evidence into capability records. +- Catalog search is a live provider dependency; cache and pins do not guarantee continued account availability. diff --git a/specs/model-providers/llama-cpp.md b/specs/model-providers/llama-cpp.md index 6073c5bbb..86d61bc57 100644 --- a/specs/model-providers/llama-cpp.md +++ b/specs/model-providers/llama-cpp.md @@ -1,17 +1,14 @@ # llama.cpp Provider Shape -Last updated: dev@e57f60b | 2026-07-20 +Last updated: dev@86b886c | 2026-10-14 ## Scope -Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages -compatibility plus native server metadata; reader -`src/model_capability_readers/llamacpp.py`. +Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages compatibility plus native server metadata; reader `src/model_capability_readers/llamacpp.py`. ## Metadata Shapes -`/v1/models` provides served identity and can include server model entries; -native `/props` is authoritative for the running model/server combination: +`/v1/models` provides served identity and can include server model entries; native `/props` is authoritative for the running model/server combination: - `model_alias`/`model_path`; - `default_generation_settings.n_ctx` and sampling `params`; @@ -20,25 +17,17 @@ native `/props` is authoritative for the running model/server combination: - `modalities.vision|audio`; - current server/build state. -Capability depends on weights, projection/model assets, chat template, parser, -and launch flags. It is endpoint evidence, not a checkpoint-name claim. -`/props` and `/v1/models` can be merged only for the same served identity. +Capability depends on weights, projection/model assets, chat template, parser, and launch flags. It is endpoint evidence, not a checkpoint-name claim. `/props` and `/v1/models` can be merged only for the same served identity. ## Request And Response Shape -llama-server supports several OpenAI-compatible tasks and native extensions. -Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an -explicit server model capability field or endpoint configuration. Tool and -reasoning correctness can depend on selected chat template and parser. +llama-server supports several OpenAI-compatible tasks and native extensions. Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an explicit server model capability field or endpoint configuration. Tool and reasoning correctness can depend on selected chat template and parser. ## Fallback And Safety -The registry selects llama.cpp through an explicit vendor or endpoint kind; it -does not auto-detect `/props` from payload shape. Port 8000 currently maps to -the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible. -llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local -endpoint behavior and never leak to strict cloud providers (#4640 and current -affinity tests). +The registry selects llama.cpp through an explicit vendor or endpoint kind; it does not auto-detect `/props` from payload shape. Port 8000 currently maps to the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible. llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local endpoint behavior and never leak to strict cloud providers (#4640 and current affinity tests). + +Runtime context lookup in `src/model_context.py` tries `/slots` first and falls back to `/props.default_generation_settings.n_ctx`, covering servers launched without `--slots`. This runtime path remains separate from canonical reader merging. ## Current Gaps diff --git a/specs/model-quirks.md b/specs/model-quirks.md index 3faafa3a0..3bab5f7b7 100644 --- a/specs/model-quirks.md +++ b/specs/model-quirks.md @@ -1,19 +1,12 @@ # Model Behavior Observations -Last updated: dev@2e2bb52 | 2026-08-16 +Last updated: dev@86b886c | 2026-10-14 ## Scope -This file records model- or provider+model-specific behavior observed in -Odysseus code, tests, Issues, PRs, commits, and provider documentation. It is a -compact evidence map, not a runtime matcher. General canonical rules belong in -[model-capability-canonical.md](model-capability-canonical.md); provider-wide -transport belongs in [the provider map](model-providers/_readme.md). +This file records model- or provider+model-specific behavior observed in Odysseus code, tests, Issues, PRs, commits, and provider documentation. It is a compact evidence map, not a runtime matcher. General canonical rules belong in [model-capability-canonical.md](model-capability-canonical.md); provider-wide transport belongs in [the provider map](model-providers/_readme.md). -The canonical capability layer intentionally has no -`src/model_behavior_quirks.py`. -Adding a registry before runtime call sites carry structured provider, model, -version, and dialect identity would create another model-name matching layer. +The canonical capability layer intentionally has no `src/model_behavior_quirks.py`. Adding a registry before runtime call sites carry structured provider, model, version, and dialect identity would create another model-name matching layer. ## General Observation Template @@ -27,9 +20,7 @@ Record only the fields supported by the evidence: - source, confidence, status, and reproduction date; - whether the behavior is already implemented in runtime code. -If exact structured identity is unavailable, keep the observation here and in -its current tested runtime location. Do not promote it through substring, -regex, prose, or serialized-prompt parsing in the canonical layer. +If exact structured identity is unavailable, keep the observation here and in its current tested runtime location. Do not promote it through substring, regex, prose, or serialized-prompt parsing in the canonical layer. ## Model-Specific Observation Map @@ -43,9 +34,7 @@ regex, prose, or serialized-prompt parsing in the canonical layer. | Ollama native `gpt-oss` reasoning level | `gpt-oss` served through Ollama native | `think` accepts low/medium/high and does not represent off | provider docs; deployment scoped | | Ollama compatibility disable observation | Ollama 0.20.6+, observed Qwen3.5 compatibility path | `reasoning_effort: none` was reported to disable reasoning | #5503; unmerged/low confidence until reproduced | -Issue and commit references are evidence identifiers, not runtime dependencies. -Open or unmerged observations remain provisional until reproduced or supported -by current provider documentation. +Issue and commit references are evidence identifiers, not runtime dependencies. Open or unmerged observations remain provisional until reproduced or supported by current provider documentation. ## Other Model-Level Observations @@ -73,13 +62,11 @@ by current provider documentation. #1704, #1478). Native engine metadata or a verified endpoint probe outranks a model-family name list. +Current `src.model_profiles` is a separate runtime schema/thinking profile helper: bounded `odysseus`/`ajax` tokens in the model ID leaf select compact schema and progressive thinking. It is not the absent canonical behavior registry. `src.utility_effort` validates utility effort against ChatGPT metadata that includes maintained/model-name defaults; callers must not describe those defaults as verified provider capability. + ## Promotion Gate -Before an observation becomes canonical runtime behavior, a consumer must -already have the necessary structured identity and tests must cover both its -positive scope and a neighboring negative scope. Request control and response -visibility remain separate: hiding reasoning text is not the same as disabling -reasoning at the provider (#2905). +Before an observation becomes canonical runtime behavior, a consumer must already have the necessary structured identity and tests must cover both its positive scope and a neighboring negative scope. Request control and response visibility remain separate: hiding reasoning text is not the same as disabling reasoning at the provider (#2905). ## Current Gaps diff --git a/specs/persistence.md b/specs/persistence.md index 39224f9cc..50249841b 100644 --- a/specs/persistence.md +++ b/specs/persistence.md @@ -1,6 +1,6 @@ # Persistence -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -13,8 +13,7 @@ This spec covers durable state in: - `core/models.py`; - `core/session_manager.py`; - `core/atomic_io.py`; -- `src/attachment_refs.py`, `src/upload_handler.py`, and - `routes/upload_routes.py` for durable upload references and retention; +- `src/attachment_refs.py`, `src/upload_handler.py`, and `routes/upload_routes.py` for durable upload references and retention; - JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers; - canonical `routes/email/email_helpers.py` scheduled-email storage; - `routes/backup_routes.py` and `scripts/odysseus-backup`; @@ -24,15 +23,9 @@ This spec covers durable state in: `core/database.py` owns SQLAlchemy models and startup migrations. `src/database.py` is a compatibility re-export for legacy imports. Route and service code commonly owns its own `SessionLocal()` lifecycle instead of using one central unit-of-work wrapper. -The default database is SQLite at `DATA_DIR/app.db`. `src.runtime_paths` and `src.constants` own the data-dir default: source runs use the repository `data/` directory, frozen builds default to `~/.odysseus/data`, and `ODYSSEUS_DATA_DIR` overrides both. SQLAlchemy can point at a non-SQLite `DATABASE_URL`, but current startup migrations/backfills are SQLite-first and often use `sqlite3`, `PRAGMA`, or SQLite catalog queries. External DBs are not fully migration-compatible unless those helpers are made backend-neutral. +The default database is SQLite at `DATA_DIR/app.db`. `src.runtime_paths` and `src.constants` own the data-dir default: source runs use the repository `data/` directory, frozen builds default to `~/.odysseus/data`, and `ODYSSEUS_DATA_DIR` overrides both. The core requirements include `psycopg2-binary` for PostgreSQL driver availability. SQLAlchemy can point at a non-SQLite `DATABASE_URL`, but current startup migrations/backfills are SQLite-first and often use `sqlite3`, `PRAGMA`, or SQLite catalog queries. External DBs are not fully migration-compatible unless those helpers are made backend-neutral. -After `Base.metadata.create_all()`, `init_db()` resolves file-backed SQLite -paths from SQLAlchemy's parsed engine URL and attempts to restrict the main -database plus existing `-journal`, `-wal`, and `-shm` sidecars to `0600` on -POSIX. Driver-qualified, query-tagged, and local `file:` URI forms are covered; -non-SQLite, in-memory SQLite, and Windows paths are skipped. A failed POSIX -chmod is logged because the database and sidecars can contain password/token -hashes and encrypted provider material. +After `Base.metadata.create_all()`, `init_db()` resolves file-backed SQLite paths from SQLAlchemy's parsed engine URL and attempts to restrict the main database plus existing `-journal`, `-wal`, and `-shm` sidecars to `0600` on POSIX. Driver-qualified, query-tagged, and local `file:` URI forms are covered; non-SQLite, in-memory SQLite, and Windows paths are skipped. A failed POSIX chmod is logged because the database and sidecars can contain password/token hashes and encrypted provider material. Timestamp defaults use `utcnow_naive()` so existing naive `DateTime` columns stay UTC without the deprecated `datetime.utcnow()` default. @@ -44,17 +37,15 @@ Current model families include: - email accounts, model endpoints, MCP servers, comparisons; - provider auth sessions for OAuth/device-flow-backed provider credentials; - API tokens, admin-global webhooks, user tools/tool data, integrations; -- crew members, scheduled tasks, task runs, notes; -- memory rows, calendar calendars, and calendar events. +- crew members, scheduled tasks, task runs, notification logs, background tool jobs, notes; +- memory rows, calendar calendars, calendar events, and email-calendar invitation revision/tombstone state. -Chat persistence stores model-readable text plus compact attachment-reference -lines in `chat_messages.content`, while structured references remain in message -metadata. Provider data URLs used by the live turn are not duplicated into the -durable transcript. The FTS migration recreates insert/update triggers to omit -inline media and scrubs legacy indexed rows that still contain data URLs. +Chat persistence stores model-readable text plus compact attachment-reference lines in `chat_messages.content`, while structured references remain in message metadata. Provider data URLs used by the live turn are not duplicated into the durable transcript. The FTS migration recreates insert/update triggers to omit inline media and scrubs legacy indexed rows that still contain data URLs. Current calendar/task persistence includes CalDAV remote identity columns (`CalendarCal.remote_href`, `CalendarCal.remote_etag`, `CalendarEvent.remote_href`, `CalendarEvent.remote_etag`), `CalendarEvent.caldav_sync_pending` for retryable writeback state, and `ScheduledTask.character_id` for built-in task persona selection. +`Note.gallery_id` links drawing notes to stable gallery images. `CalendarEvent` source-email identifiers preserve invitation provenance; `EmailCalendarInvitation` keeps owner/sender/source/recurrence revision and cancellation state for replay decisions. + `EmailAccount` includes encrypted password fields plus Google OAuth fields (`oauth_provider`, encrypted access/refresh tokens, token expiry) and optional `display_name`. Startup migrations add those OAuth/display columns idempotently for older databases. Email default-account state is serialized per owner. Startup normalizes legacy duplicate defaults and installs a per-owner unique default constraint/index; create, delete/promotion, set-default, demo teardown, and user rename perform their default transition in one locked transaction. Multi-owner rename locks are acquired in canonical order, so a stale concurrent default mutation fails closed instead of recreating multiple defaults. @@ -127,6 +118,20 @@ Docker bind-mounts `data/`, `logs/`, cache/local state, and optional Chroma stat ChromaDB/vector stores are optional durable storage outside `data/app.db`; missing Chroma degrades RAG, memory-vector, and tool-index features without blocking core SQLite/JSON persistence. Vector collections can be lane-suffixed for custom HTTP embeddings versus FastEmbed fallback. See `documents-rag-uploads.md`. +## Run Evidence And New Durable State + +`Session` adds `cwd`, endpoint id, memory-extraction/injection and skill-injection toggles, thinking mode, temperature/max-token overrides, and cumulative cost. `ModelEndpoint.model_tool_modes` stores per-model tool-schema preferences. `ScheduledTask.request_authority_json` stores the server-only admitted request snapshot. Startup migration helpers add these columns to legacy schemas. `BackgroundToolJob` holds origin owner/session/tool/query, status, payload/summary, and delivered message id; `NotificationLog` persists owner-attributed task completion/error notification text. + +`src.agent_runtime.effect_log` stores append-only claims, outcomes, and observations in `DATA_DIR/effects/.jsonl`, with launch-generation indexes alongside them. Claims are fsynced before backend invocation; persistence failure refuses execution. Reload appends interrupted outcomes for unsettled non-running claims, while eligible owned `RUNNING` process claims survive for background lifecycle settlement; it never manufactures success or fresh state. Appends merge durable history under an advisory lock before allocating positions. Cross-process locking and directory-fsync guarantees are POSIX-specific; other platforms have narrower guarantees. Claim files use restrictive creation, no-follow/regular-file/single-link checks, strict row validation and a tolerated torn final tail. Non-claim persistence can degrade to volatile evidence; durable claim failure still refuses invocation. Advisory-lock append merges durable rows, allocates positions and repairs only a torn tail; reader locking does not silently repair. Launch indexes use fsync/replace ordering, with narrower non-POSIX guarantees. These checks do not imply authenticated tamper-proof logs, constant-cost growth or identical index hardlink checks. This store is evidence, not operation authority. + +`data/containment_grants.json` and `data/bg_jobs.json` preserve containment/process identity across restart. Browser producer state resolves through `BROWSER_RESOURCES_DIR` in shared constants. These server control-plane stores are denied to native model filesystem operations; ownership and runtime integrity must remain separate from editable user artifacts. + +## Cleanup Counts And Chroma Volumes + +`src/cleanup_service.py` derives preview and deletion eligibility from durable message rows, not the session counter. Recent-session protection uses a limited ID query; final DELETE rechecks owner/archive/age/importance/message threshold. DELETE RETURNING identifies cleanup-owned deletions where supported; legacy SQLite uses rowcount plus survivor queries, with estimated space attribution bounded to the deleted count. Session-cache eviction occurs after commit. + +The standard, AMD and NVIDIA Compose files mount `chromadb-data` at `/data`; native Chroma uses `data/chroma/`. Pre-fix Docker stores may have live data in the old container writable layer despite an empty volume at `/chroma/chroma`; recreation can destroy that layer. `docs/chromadb-volume-migration.md` documents cold rescue, image preservation, empty-volume seeding and staging/count verification before cutover. This is an operator procedure, not startup migration or proof of data recovery. + ## Current Gaps - Migration behavior is centralized but long and manual. @@ -134,4 +139,4 @@ ChromaDB/vector stores are optional durable storage outside `data/app.db`; missi - Some JSON store shapes are only documented by manager code and tests. - Startup migrations lack a legacy-schema/idempotence test harness for owner backfills, encrypted-secret rewrites, and repeated runs. - JSON-store atomicity is inconsistent across stores, though shared atomic writers, upload metadata recovery, prefs, and strict memory mutations now have focused coverage. -- Agent filesystem tools currently allow broad `data/` access; secret-bearing files under `data/` need explicit deny coverage. +- New local stores require explicit ownership, backup, and control-plane access decisions; a filesystem root alone does not grant access to server state. diff --git a/specs/research.md b/specs/research.md index 6ea3a1907..67491d072 100644 --- a/specs/research.md +++ b/specs/research.md @@ -1,6 +1,6 @@ # Research -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-15 ## Scope @@ -50,8 +50,8 @@ Agent tools and the CLI read and mutate persisted research JSON directly. They a `src.deep_research.DeepResearcher` owns multi-round research work: - date/context setup; -- search provider selection and fallback through `src.search.providers` and `src.search.core`; -- URL/content fetching through `src.search.fetch_webpage_content`; +- search provider selection and fallback through `src.research_navigator.ResearchNavigator`, which dispatches the shared provider chain; +- URL/content fetching through `ResearchNavigator.fetch()` and the canonical service content fetcher, with structured unavailable results for disabled private-browser reads; - separate tracking of analyzed URLs, last search errors, and empty-round limits; - source summarization/extraction; - synthesis into final answers/reports; @@ -80,12 +80,7 @@ Spinoff/Discuss creates a new chat session from a saved report. It seeds the rep Research persistence uses `data/deep_research/.json`. Current JSON can include result/report text, raw report, sources, raw findings, stats, category, archived state, hidden images, owner, timestamps, and consumed state. -Route access to persisted report files is path-confined. Browser routes validate -session ids against `^[a-zA-Z0-9-]{1,128}$`, enumerate trusted `*.json` files -under the resolved research storage root, match by exact filename, reject -symlink/path escapes after `resolve().relative_to(root)`, and then perform owner -checks before detail/archive/delete/result-peek/spinoff reads or mutations. -Invalid ids return 400; missing or cross-owner reports return 404. +Route access to persisted report files is path-confined. Browser routes validate session ids against `^[a-zA-Z0-9-]{1,128}$`, enumerate trusted `*.json` files under the resolved research storage root, match by exact filename, reject symlink/path escapes after `resolve().relative_to(root)`, and then perform owner checks before detail/archive/delete/result-peek/spinoff reads or mutations. Invalid ids return 400; missing or cross-owner reports return 404. `src.visual_report` owns HTML report generation from markdown-like research output, heading/TOC processing, category styling, image injection, allowlist sanitization of untrusted rendered HTML, and client-side controls for hiding images and discussing reports. @@ -119,8 +114,7 @@ The active FastAPI app path uses `src.research_handler.ResearchHandler`. `services/research/service.py` is a public wrapper around a duplicate `services.research.research_handler.ResearchHandler`. That services handler remains compatibility/cleanup surface rather than canonical runtime truth; check parity before assuming it has every active-route field or policy behavior. -Its source extraction skips non-dict finding rows so one malformed cached or -generated entry does not discard later valid URL/title/summary sources. +Its source extraction skips non-dict finding rows so one malformed cached or generated entry does not discard later valid URL/title/summary sources. Search compatibility also matters: `src.search.core`, `src.search.providers`, and `src.search.content` alias the service search path so old imports stay live without a second fetch implementation. @@ -145,6 +139,12 @@ Existing useful coverage includes deep-research runtime/degraded tests, handler/ Coverage is still thin around live job route ownership, `/api/research/start` route behavior, SSE/result-peek/cancel edges, spinoff endpoint ownership, tool/CLI direct JSON access, remote-image policy, and frontend panel/jobs behavior. +## Bounded Navigation And Evidence + +`src.research_navigator.py` owns parsed `ResearchAction` requests, normalized `ResearchPage` responses, and source assessments. Search uses the shared provider chain off the event loop; ordinary fetch uses the canonical URL-safe content fetcher, while `browser_read` requests the private browser tool and currently receives the common denial for page actions. `DeepResearcher` chooses bounded navigation actions, records retrieval mode/provider use and source coverage, and can request a browser retry for weak fetches; the current disabled page-action policy prevents that retry from supplying page evidence. + +Small local model heuristics select a simpler fact/research path with smaller planning calls. Findings-window synthesis and extraction concurrency/timeouts still bound work; useful source text remains a dependency; private-browser page actions are currently unavailable. Scheduled research binds sealed task authority around the handler call; direct handler/route behavior must not be assumed to share that scheduling wrapper. Saved reports retain gathered evidence and fallback reports when model synthesis cannot complete. + ## Current Gaps - Consolidate, retire, or clearly deprecate `services/research/research_handler.py`. diff --git a/specs/runtime.md b/specs/runtime.md index 47b76a839..9295f27f3 100644 --- a/specs/runtime.md +++ b/specs/runtime.md @@ -1,6 +1,6 @@ # Runtime -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -13,10 +13,10 @@ This spec covers current app runtime wiring in: - `core/constants.py`; - `src/constants.py`; - `src/interactive_gate.py`; +- `src/process_reaper.py`, `src/process_lifecycle.py`, and `src/process_ownership.py`; - `src/host_docker_access.py`; - `core/middleware.py`; -- all route setup functions registered from `app.py`, including canonical - `routes/admin_wipe/`, `routes/cleanup/`, `routes/compare/`, `routes/contacts/`, `routes/document/`, `routes/gallery/`, `routes/history/`, `routes/mcp/`, `routes/memory/`, `routes/note/`, `routes/research/`, `routes/search/`, `routes/task/`, `routes/vault/`, and `routes/webhook/` packages plus top-level compatibility shims; +- all route setup functions registered from `app.py`, including canonical `routes/admin_wipe/`, `routes/cleanup/`, `routes/compare/`, `routes/contacts/`, `routes/document/`, `routes/gallery/`, `routes/history/`, `routes/mcp/`, `routes/memory/`, `routes/note/`, `routes/research/`, `routes/search/`, `routes/task/`, `routes/vault/`, and `routes/webhook/` packages plus top-level compatibility shims; - `routes/prefs_routes.py`, `routes/workspace_routes.py`, and `companion/routes.py`; - `src/generated_images.py` for generated-media file resolution; - `launcher.py`, `Odysseus.spec`, and platform launcher scripts where frozen/native startup changes runtime paths; @@ -30,9 +30,7 @@ This spec covers current app runtime wiring in: `app.py` separately owns runtime singletons and integration hooks for auth, vector RAG, TTS/STT, webhooks, scheduled tasks, MCP, assistant log globals, event bus wiring, AI interaction globals, API-token cache invalidation, and foreground activity tracking. `src.runtime_paths` owns source-versus-frozen app/data path resolution; `src.constants` derives `DATA_DIR` from `ODYSSEUS_DATA_DIR` or that runtime default. `core/constants.py` and `src/constants.py` are both live import paths and are not fully identical today, so new constants need explicit placement/compatibility decisions. -The shared upload handler is also installed on the session manager and tool -helper, and `app.py` injects it into attachment-bearing route factories so -durable writers and cleanup use one lifecycle owner. +The shared upload handler is also installed on the session manager and tool helper, and `app.py` injects it into attachment-bearing route factories so durable writers and cleanup use one lifecycle owner. ## Routes And Static Serving @@ -46,9 +44,9 @@ Admin wipe, cleanup, compare, contacts, documents, gallery, history, MCP, memory The SPA routes `/`, `/notes`, `/calendar`, `/cookbook`, `/email`, `/memory`, `/gallery`, `/tasks`, and `/library` all serve `static/index.html`. `static/` is served with revalidation for `.js`, `.css`, and `.html` because the frontend ships raw browser modules with no hashed build output. -Direct app-owned endpoints include `/api/generated-image/{filename}`, `/backgrounds`, `/login`, `/api/version`, `/api/health`, `/api/ready`, `/api/runtime`, and `/api/activity/heartbeat`. `/backgrounds` points at `static/backgrounds.html`; if that file is absent or the route remains auth-gated, that is route/static drift rather than an intentional public contract. +Direct app-owned endpoints include `/api/generated-image/{filename}`, `/backgrounds`, `/login`, `/api/version`, `/api/health`, `/api/ready`, `/api/runtime`, and `/api/activity/heartbeat`. The `/backgrounds` prototype route points at the absent `static/backgrounds.html` and is auth-gated when authentication is enabled despite its handler docstring describing it as public. This remains known route/static drift at the checked revision. -`/static/*` is auth-exempt and public. SPA HTML routes are auth-gated except `/login`, and they are nonce-injected dynamic `HTMLResponse` values outside the static mount. Generated images and videos are served from `data/generated_images` through the generated-image resolver with immutable/nosniff caching. +`/static/*` is auth-exempt and public. SPA HTML routes are auth-gated except `/login`, and they are nonce-injected dynamic `HTMLResponse` values outside the static mount. Generated images and videos are served from `data/generated_images` through the generated-image resolver with private, no-cache, must-revalidate/nosniff headers; access checks precede disk probing and database verification errors return 503 (see `gallery-editor-media.md`). ## Runtime Security Boundaries @@ -58,7 +56,7 @@ Security headers include HSTS and a restrictive `Permissions-Policy` that disabl `_TIMEOUT_EXEMPT_PREFIXES` owns hard-timeout bypass policy. It is prefix-based and currently exempts all subroutes under `/api/chat`, `/api/shell/stream`, `/api/research`, `/api/model/download`, `/api/model/probe`, `/api/model-endpoints`, `/api/cookbook/setup`, `/api/upload`, `/api/image`, and `/api/memory/audit`. Memory audit has its own longer inactivity timeout. -Generated-image path resolution fails closed for invalid names, path escape, and missing files. Ownership checks are best-effort when a current user exists: gallery rows owned by a different user return 404, rowless generated files are allowed, and DB/helper failures fail open. See `auth-security.md` for `LOCALHOST_BYPASS`, internal-tool loopback, proxy-header exclusion, and owner-impersonation policy. +Generated-image serving validates filename syntax before identity and access checks, then resolves the confined path and verifies file existence. With auth enabled, missing identity returns 401; recent registration, gallery ownership, linked-session ownership or an owned-chat metadata reference supplies the implemented access criterion. Missing or mismatched access returns 404, and database verification failures return 503. Explicit disabled-auth mode skips ownership verification. See `gallery-editor-media.md` for branch ordering and registry lifetime, and `auth-security.md` for `LOCALHOST_BYPASS`, internal-tool loopback, proxy-header exclusion, and owner-impersonation policy. ## Runtime Behavior @@ -70,12 +68,11 @@ Generated-image path resolution fails closed for invalid names, path escape, and ## Lifespan Startup -Upload cleanup first snapshots durable chat, document, gallery, note, and -calendar references and aborts on scan or upload-index integrity failure. +Upload cleanup first snapshots durable chat, document, gallery, note, and calendar references and aborts on scan or upload-index integrity failure. Startup purges leftover incognito sessions, reconciles default scheduled tasks before the task runner starts, and backfills legacy skill owners when possible. -Startup fire-and-forget work includes upload cleanup, background-job monitoring, MCP built-in registration and user-server connection, tool-index warmup, model-endpoint warmup, endpoint keepalive, Cookbook serve lifecycle monitoring, hourly null-owner sweeps, and nightly skill audit. The in-process task scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`; email polling is started from email route setup and gated separately by `ODYSSEUS_INPROCESS_POLLERS`. Foreground-gate knobs are `BACKGROUND_TASK_FOREGROUND_GATE`, `BACKGROUND_TASK_QUIET_MS`, `BACKGROUND_TASK_MAX_WAIT_SECONDS`, and `BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS`. +Startup fire-and-forget work includes upload cleanup, background-job monitoring, MCP built-in registration and user-server connection, tool-index warmup, model-endpoint warmup, endpoint keepalive, Cookbook serve lifecycle monitoring, and hourly null-owner sweeps. Skills Audit is scheduled per owner by `TaskScheduler`; app lifespan deliberately does not start a second ownerless audit. The in-process task scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`; email polling is started from email route setup and gated separately by `ODYSSEUS_INPROCESS_POLLERS`. Foreground-gate knobs are `BACKGROUND_TASK_FOREGROUND_GATE`, `BACKGROUND_TASK_QUIET_MS`, `BACKGROUND_TASK_MAX_WAIT_SECONDS`, and `BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS`. Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook manager, and disconnects MCP servers. @@ -90,10 +87,20 @@ Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook ma - `src.host_docker_access` treats host Docker access from inside the container as opt-in. Default Compose does not mount `/var/run/docker.sock`; `docker/host-docker.yml` plus `ODYSSEUS_ENABLE_HOST_DOCKER=true` are required before local container code considers the host Docker daemon available. - Chroma-backed consumers degrade independently: personal-doc RAG can return route-level 503s, semantic memory vectors can be dropped from chat/memory wiring, and the tool index can fall back when vector retrieval is unavailable. - RAG startup failure is throttled so failed clients do not poison later retries. -- MCP startup is asynchronous and non-critical. User-server connection is bounded, failures surface through MCP status routes, and builtin MCP calls can reconnect after crashes. -- `/api/health` is liveness only. `/api/ready` checks database reachability, writable data dir, and local-first storage metadata; it does not prove optional subsystem health for RAG, Chroma, MCP, memory vectors, tool index, or endpoint warmups. +- MCP startup is asynchronous and non-critical. User-server connection is bounded, failures surface through MCP status routes, and unbound builtin MCP calls can reconnect after crashes; incarnation-bound calls return failure rather than silently replacing the backend. +- `/api/health` is liveness only. `src.readiness.check_readiness()` reports database reachability, writable data dir, informational local-first storage metadata and tool-index state. Database/data-dir checks gate `/api/ready`; `ODYSSEUS_REQUIRE_TOOL_INDEX_READY` additionally makes tool-index readiness critical when enabled. Default readiness does not certify RAG, Chroma, MCP, memory vectors or endpoint warmups. - `/api/diagnostics/services` is an admin diagnostics endpoint for optional service health. It reports bounded, non-intrusive checks for ChromaDB, SearXNG, email accounts, ntfy, and model provider endpoints with `ok`/`degraded`/`down`/`disabled` style status values and strips secret-bearing URLs/errors. `/api/diagnostics/logs` returns a bounded tail of the app log for admin troubleshooting. +## Startup Process Reconciliation + +Lifespan calls `src.process_reaper` once to reconcile persisted containment grants and detached job identities from previous server runs. Verified orphan processes under abandoned containment grants are torn down; owned detached jobs intentionally survive restart. Unverifiable containment grants remain active and unsignalled, while `src.bg_jobs.disown_unverified()` marks foreign/unverifiable running job records failed without signalling and preserves pending follow-up. Retiring a record does not establish process death. `src.process_lifecycle` owns identity checks, TERM/KILL escalation, and observed-death receipts; caller-specific containment/browser/Cookbook policy remains outside it. + +## Process And Service-Home Limits + +Native launch readiness is a bounded functional containment probe plus exact supervisor/worker acknowledgement, not executable presence. Startup reconciliation retains unverified ownership and running detached job generations instead of fabricating death or renewed permission. Background delivery revalidates linkage after asynchronous continuation and retires exact observed-dead launch records; invalidated jobs do not prevent registry cleanup during shutdown. Missing procfs degrades discovery/command-line evidence and does not authorize signaling. + +`core.platform_compat.service_home()` uses Windows `Path.home()` without importing `pwd`. Successful POSIX real-UID passwd lookup ignores a child HOME override; a missing passwd entry or lookup OS error falls back to `Path.home()`, which can honor that override. Model-visible runtime/context metadata is bounded and untrusted; environment-key allowlists do not scrub explicitly supplied values. Readiness errors expose bounded error categories rather than raw exception details, and readiness still does not certify optional provider/browser/model health. + ## Current Gaps - `app.py` is still a large route registry and runtime orchestrator. There is no generated route manifest or smaller runtime composition layer yet. diff --git a/specs/search.md b/specs/search.md index 09fc8082a..c967aee8f 100644 --- a/specs/search.md +++ b/specs/search.md @@ -1,6 +1,6 @@ # Search -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-15 ## Scope @@ -64,13 +64,11 @@ Runtime behavior: - rejection of localhost, metadata, private, reserved, multicast, and link-local targets; - redirect revalidation on each hop; - one-time public DNS resolution per hop plus an `httpcore`/`httpx` pinned - transport that connects to the validated public IP while preserving the - original URL, Host header, and TLS SNI, closing DNS-rebinding time-of-check - drift; + transport that connects to the validated public IP while preserving the original URL, Host header, and TLS SNI, closing DNS-rebinding time-of-check drift; - metadata, Open Graph image, list, table, code block, PDF, and text extraction; - readable text extraction for `text/*`, Markdown, `.txt`, `.json`, `.jsonl`, and JSON content types; - central User-Agent behavior through `WEB_FETCH_USER_AGENT`; -- soft and hard download byte caps through `WEB_FETCH_SOFT_MAX_BYTES` and `WEB_FETCH_HARD_MAX_BYTES`, with declared-length and streaming-budget checks; requests prefer identity transfer encoding so compressed bodies cannot bypass the effective body cap; +- soft and hard download byte caps through `WEB_FETCH_SOFT_MAX_BYTES` and `WEB_FETCH_HARD_MAX_BYTES`, with declared-length and streaming-budget checks; decoded response bytes are capped even for compressed transfer encodings, and truncation is reported; - JS-heavy empty result hints; - cache writes; - empty/error result shape, including explicit HTTP-status failures instead of raising through callers. @@ -124,12 +122,28 @@ Deep research wraps fetched webpage content through `untrusted_context_message(" ## Optional And Platform Behavior -`ddgs` is optional; provider code has an HTML fallback. Search cache and analytics state live under the shared data dir and mkdir failures in read-only image layers are tolerated where possible. PDF extraction uses `pdfminer.six` only when installed. Native SearXNG defaults to `http://localhost:8080`; Docker uses the compose `searxng` service URL and pins the SearXNG image with a healthcheck. +`ddgs` is optional; provider code has an HTML fallback. Search cache and analytics state live under the shared data dir and mkdir failures in read-only image layers are tolerated where possible. PDF extraction prefers pypdf layout text with a plain-text compatibility fallback and page labels, then optional pdfminer; a truncated soft-budget PDF can retry within the hard cap when its declared size allows. Native SearXNG defaults to `http://localhost:8080`; Docker uses the compose `searxng` service URL and pins the SearXNG image with a healthcheck. Compose preserves retained SearXNG settings but runs `scripts/migrate_searxng_settings.py` before startup to add missing `use_default_settings: true` inheritance. The migration accepts only a regular single-document YAML mapping, preserves BOM/newline/style/ownership/mode, writes and directory-fsyncs atomically, and no-ops when the key exists. Compose treats migration failure as non-fatal so SearXNG health reports the retained-file problem instead of the wrapper command preventing startup. `httpx` and BeautifulSoup are required runtime dependencies for the active search/fetch path. +## Query Repair And Extractive Observations + +`services/search/core.py` normalizes conversational provider queries, filters low-relevance results, respects explicit site scope, retries bounded broadened queries after empty results, and reapplies relevance/ranking on cache hits. Completed empty provider responses advance through the chain instead of repeating the same request as if it were a transport error. Named scholarly work lookup can use arXiv/OpenAlex metadata before generic search, and exact arXiv identifiers have a direct source path. + +`src/search_intent.py` distinguishes requested publication windows from current reference information: current documentation/configuration alone does not imply recently published pages. `src/search_passages.py` owns literal, query-relevant excerpts and balanced bounded search observations across fetched sources, preserving explicit omission markers and source headers rather than prefix-truncating away later sources. These are extractive observations, not generated source claims. Deep research shares provider primitives through its navigator but retains its own result and failure shapes. + +## Retrieval Limits And Source Selection + +When SearXNG is primary and `searxng_yep` is absent from the configured chain, `_build_provider_chain()` inserts that separate candidate at index one, including when SearXNG is the only configured provider. An already configured YEP position is retained; other primary providers do not get the automatic insertion. Missing keyed providers are excluded. Exact-empty query relaxation is bounded and disabled for explicit operators, quotes or exclusions. Low-relevance filtering can retain scoped results rather than invent a confident match. Temporal qualifiers can exclude a year from product-number overlap checks, while genuine numeric model names remain significant. + +News category and publication window are independent: current events infer a day and news a week where no explicit window exists, while current reference documentation does not imply recent publication. Software release hints can retain general category; explicit windows survive JSON-to-HTML and news-to-general fallback along with language/engines/SafeSearch. The optional DuckDuckGo HTML request has a maximum five-second request timeout, not a bound on the complete multi-provider search. + +HTML extraction removes chrome from a parsed copy, prefers semantic main content and a sole substantial article (at least 200 stripped characters), and avoids ancestor duplication; multiple article cards retain main context. Thin-body and access-challenge rules are heuristics. An exact challenge title with corroborating short body is an unavailable page even under HTTP200 and is not cached as article evidence. XML/Atom preserves bounded raw text; failed arXiv category/date listings can return a suggested start-zero/max100 Atom URL without fetching it or claiming completeness. Extractor caches use the current semantic-links namespace rather than old parser results. + +Scholarly metadata uses a versioned User-Agent, a 12-second lookup timeout and shared 20-second monotonic budget, with at most three manually checked redirects and cleared original query parameters after redirect. This helper checks destinations but uses ordinary HTTP transport rather than claiming the separate outbound-fetch DNS connection pinning contract. Title/identifier matching remains heuristic metadata lookup, not independent verification of a paper. Direct browser-source citations and search snippets remain untrusted and insufficient to certify full-report evidence. + ## Current Gaps - Search route handlers need direct tests for request body formats, provider validation, provider availability, and route error/empty-result shapes. diff --git a/specs/settings-admin.md b/specs/settings-admin.md index ef468bf35..9b008ad19 100644 --- a/specs/settings-admin.md +++ b/specs/settings-admin.md @@ -1,6 +1,6 @@ # Settings And Admin Surfaces -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -20,7 +20,7 @@ This spec covers settings and admin surfaces in: - `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references; - `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`; - `src/agent_loop.py` for stale agent prompt references to settings APIs; -- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`; +- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn,agent,api,imageModels,speech,writingStyle}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`; - CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`. Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs. @@ -54,10 +54,10 @@ Other active stores include: Settings runtime: - `GET /api/auth/features` is public feature visibility metadata; -- `POST /api/auth/features` is admin-only; -- `GET /api/auth/settings` returns full settings to admins; -- non-admin or unauthenticated `GET /api/auth/settings` returns `scrub_settings()` output; -- `POST /api/auth/settings` is admin-only and only writes keys present in `DEFAULT_SETTINGS`. +- `POST /api/auth/features` requires admin unless auth is explicitly disabled; +- `GET /api/auth/settings` returns full settings to admins and the disabled-auth operator; +- with auth enabled, non-admin or unauthenticated `GET /api/auth/settings` returns `scrub_settings()` output; +- `POST /api/auth/settings` requires admin unless auth is explicitly disabled and only writes keys present in `DEFAULT_SETTINGS`. `src.settings_scrub` owns deep secret-key scrubbing for non-admin settings reads, including snake_case and camelCase secret-like key names. It preserves structure while blanking secret-shaped string values. @@ -75,7 +75,7 @@ Admin gates inherit the auth contracts in `auth-security.md`: normal deployments - bundled accessibility font selection such as OpenDyslexic and text-size variable application; - CSS variable application. -`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state. +`static/js/settings.js` coordinates compatibility exports and delegates Agent, image-model, speech and writing-style panel initialization to their named leaf modules; while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state. Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes. @@ -95,6 +95,14 @@ Runtime behavior: `scripts/odysseus-preset` is a local CLI for preset store maintenance and backup of `presets.json`. +Custom presets now carry optional persona continuity notes, a `general`/`health` memory schema, `thinking_mode` (`on`, `off`, or provider default), and `show_persona_name`. `PresetManager.update_persona_memory()` updates matching active and saved personas; these remain shared preset data. The default custom output limit is 32,768 tokens. + +## Writing Style And Admin Controls + +`document_writing_style` and `email_writing_style` are separate global settings. `POST /api/auth/settings/document-style/extract` requires an admin unless auth is explicitly disabled. It reads a bounded document upload, extracts prose, and calls the owner-resolved Utility model (Default Chat if unavailable) with at most 30,000 sample characters. Its prompt treats the sample as untrusted data; the response is a proposed style string, and the temporary file is removed in `finally`. `static/js/settings/writingStyle.js` owns the UI. This is a model inference path rather than a deterministic style parser. + +`auto_compact_threshold_percent` defaults to 85 and settings writes clamp it to 50–95. `utility_reasoning_effort` is an allowed per-user override; utility fallback entries can specify their own effort. Admin `PUT /api/auth/users/{username}/password` delegates to `AuthManager.reset_user_password`, accepts only existing non-admin targets, revokes their sessions after reset, and validates minimum length and the 72-byte UTF-8 limit. + ## Tools Settings `routes.model_routes` owns `/api/tools`, which writes `settings.json:disabled_tools` for global builtin tool toggles. @@ -150,8 +158,8 @@ Vault tool paths duplicate some route behavior and can return vault item secrets ## Security And Provenance -- Non-admin and unauthenticated settings reads are scrubbed. -- Admin settings reads, admin edit forms, vault flows, backup files, and local CLI artifacts can contain secrets and must remain admin-only or locally protected. +- With auth enabled, non-admin and unauthenticated settings reads are scrubbed; explicit disabled-auth operator reads return full settings. +- Full settings responses, admin edit forms, vault flows, backup files, and local CLI artifacts can contain secrets and need their applicable access and storage protections. - Backup artifacts are sensitive because settings may include API keys, passwords, tokens, and endpoint credentials. - Diagnostics and logs should avoid adding secret-bearing values. - Admin wipe is global per kind and crosses owners. @@ -171,14 +179,25 @@ Vault tool paths duplicate some route behavior and can return vault item secrets - Theme/preset frontend helpers tolerate malformed localStorage values. - CLI helpers are local maintenance surfaces and may bypass HTTP route policy. +## App Configuration Gates And Cleanup + +`routes/auth_routes.py` uses `_require_admin_user()` for settings writes, feature writes, document-style extraction and integration management. Explicit `AUTH_ENABLED=false` admits the operator without a session identity, and settings GET returns unscrubbed values in that mode; auth-enabled callers need an admin for writes and get scrubbed reads unless admin. User-store administration still requires a real admin identity. `static/js/settings.js` reports shortcut save failure with a toast; settings writes use the existing response-status-checking API helper. + +`src/cleanup_service.py` previews and deletes using durable `ChatMessage` row counts rather than `Session.message_count`; it selects only the configured most-recent IDs for protection. The DELETE rechecks owner, archive state, age, importance and actual message threshold. Supported databases use DELETE RETURNING to attribute deleted rows and estimated space; legacy SQLite has a rowcount/survivor fallback with bounded estimate attribution. Cache eviction follows commit and includes observed externally removed candidates; estimates are not a measurement of reclaimed database bytes. + ## Testing Notes Current targeted coverage includes settings store fallback/error paths, settings scrub, shared frontend config caching/invalidation/prefetch behavior, prefs no-clobber behavior, atomic preset store/migration/CLI/localStorage helpers, backup import cross-user dedup, backup CLI restore/list-race safety, cleanup owner scope, diagnostics admin-gate/source/service-health/log-tail checks, admin promote/demote, admin wipe gallery, font family derivation, theme helper behavior, vault password-not-in-argv checks, setup/auth regressions, reserved usernames, Google email OAuth route/helper behavior, and a token-budget `manage_settings` path. +## Panel Save And Vault Identity Details + +`static/js/settings/api.js` centralizes settings POSTs, rejects non-OK/403 responses and invalidates the shared cache in finally even on failure. Older per-panel unchecked-response behavior is superseded by this final helper; individual UI completion is not model/provider acceptance. Image model selectors retain the final mixed eligibility filter rather than an image-only historical rule. Sidebar build information fetches public version/build/source-commit metadata, hides failed/unknown responses and abbreviates hexadecimal commits for display; arbitrary overrides are not byte attestation. + +Vault tool bindings require an explicit matching configuration owner and pin backend/config/session incarnations plus exact observed item identities. `routes/vault/vault_routes.py` checks configuration ownership on save and clears session/unlock time when adopting ownerless legacy state. Same-owner configuration edits retain saved session/unlock fields while invalidating bindings whose configuration incarnation changes. Configuration reads and login/unlock/lock/logout use common `require_admin()` without that owner-binding check. Observed catalog names or selectors cannot renew a revoked tool backend; search observations can update selected entries without proving every remote account/session is fresh. + ## Current Gaps -- Add route tests for `/api/auth/settings`: anonymous/non-admin scrubbed reads, admin full reads, non-admin POST rejection, and unknown-key ignore behavior. -- Add route tests for `/api/auth/features` admin writes. +- Settings/features route tests cover disabled-auth operation, auth-enabled read scrubbing, admin/non-admin writes, numeric validation and ignoring unknown keys; real auth-middleware integration and broader store-failure combinations still need coverage. - Add `/api/tools` and `manage_settings` tests for secret write refusal, enum/integer coercion failures, structured-setting refusal, reset/default behavior, endpoint/model resolution, and tool enable/disable aliases. - Add backup tests for secret-bearing export policy, owner-scoped exported sections, invalid import handling, skills dedup, settings/features merge, and admin gates. - Add diagnostics tests for broader error redaction and sensitive output limits. @@ -187,4 +206,4 @@ Current targeted coverage includes settings store fallback/error paths, settings - Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior. - Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names. - Decide whether backup/import should preserve explicit owner fields or force imported owner ownership. -- Continue decomposing the still-large `static/js/settings.js` (5,583 lines) and `static/js/admin.js` (4,122 lines) without duplicating registry ownership. The shell and navigation concerns now live in `static/js/settings/`; what remains in `settings.js` is panel code, and `initUnifiedIntegrations()` alone is ~2,140 lines of it. `admin.js` has no extracted shell of its own yet. +- Settings panel code now has additional domain modules, but `static/js/settings.js` and `static/js/admin.js` remain substantial coordinators; future extraction must preserve registry and lifecycle ownership. diff --git a/specs/shell-mcp.md b/specs/shell-mcp.md index cd0087891..a137f16ef 100644 --- a/specs/shell-mcp.md +++ b/specs/shell-mcp.md @@ -1,6 +1,6 @@ # Shell And MCP -Last updated: dev@2e2bb52 | 2026-08-16 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -9,6 +9,8 @@ This spec covers shell and MCP behavior in: - shell routes in `routes/shell_routes.py`; - the standalone shell helper in `services/shell/service.py`; - agent shell/background execution in `src/tool_execution.py`, `src/agent_tools/subprocess_tools.py`, `src/bg_jobs.py`, and `src/bg_monitor.py`; +- `src/containment.py`, `src/containment_worker.py`, `src/process_ownership.py`, `src/process_lifecycle.py`, and `src/process_reaper.py`; +- `src/browser_identity.py`, `src/browser_lifecycle.py`, `src/browser_observation.py`, and `src/agent_runtime/process_resources.py`; - app wiring and startup/shutdown in `app.py`; - MCP configuration routes in canonical `routes/mcp/mcp_routes.py`, with `routes/mcp_routes.py` as a compatibility shim; - MCP runtime state in `src/mcp_manager.py`; @@ -49,11 +51,11 @@ Those endpoints probe local or SSH-remote packages, prepend user install bins fo ## Agent Shell And Background Jobs -`src.tool_execution` owns agent-side `bash` execution and the `#!bg` marker. A `bash` block whose first line is `#!bg` starts a detached background job instead of holding the chat stream open. On Windows, request-scoped workspace shell execution prefers Git Bash when available so POSIX-style agent commands and path confinement use the intended shell instead of `cmd.exe` parsing. +`src.tool_execution` owns policy/dispatch for agent-side `bash` execution and the `#!bg` marker; `src.agent_tools.subprocess_tools` owns native shell/Python/host-bridge handlers. A `bash` block whose first line is `#!bg` starts a detached background job instead of holding the chat stream open. Native enforcing execution is unavailable on unsupported platforms, including Windows; compatibility/admin shell selection is a separate path and does not establish native containment. `src.bg_jobs` owns disk-backed job state under `data/bg_jobs.json` and `data/bg_jobs/*`. It stores wrapper scripts, logs, exit-code files, timestamps, status, and capped result text. -`src.bg_monitor` owns polling and auto-continuation. When a job finishes, it injects the job result into the session, drains the agent stream, persists only the assistant continuation plus `bg_result` metadata, and marks the job followed up. +`src.bg_monitor` owns polling and auto-continuation. A completed job follows up only after exact owner/request/thread/generation linkage is checked, including revalidation after draining the continuation stream. Busy sessions retry; missing/terminal authority does not renew itself. Only after assistant persistence is follow-up marked delivered; suppression of an unfollowable result is distinct from delivery. Runtime behavior: @@ -75,10 +77,7 @@ Runtime behavior: `src.mcp_manager.McpManager` owns live connection state, stdio/SSE/Streamable HTTP transports, sessions, tool schemas, qualified names, and tool calls. HTTP route operations update both database state and live manager state where applicable. Streamable HTTP connects in a background task, can report `connecting` or `needs_auth`, and surfaces an authorization URL when the OAuth client flow redirects. Enabled configured servers connect concurrently at startup; each server has its own 20-second connection timeout and records `timeout` state without delaying siblings. The startup task has no second outer timeout. -Stdio and SSE connection setup registers the session, exit stack, tool list, -and status as one completed unit. If initialization or tool discovery fails -before registration, the partial `AsyncExitStack` is closed so transports do -not leak into later reconnect attempts. +Stdio and SSE connection setup registers the session, exit stack, tool list, and status as one completed unit. If initialization or tool discovery fails before registration, the partial `AsyncExitStack` is closed so transports do not leak into later reconnect attempts. `src.agent_tools.admin_tools.do_manage_mcp()` is the agent/admin tool path for MCP config and is re-exported lazily through `src.tool_implementations` for compatibility. It is narrower than the HTTP routes: add is stdio-only, command values are checked against an allowlist/denylist before persistence, and enable/disable primarily flips DB config. `scripts/odysseus-mcp` is config-only; it reads and mutates database rows, redacts env values by default, and does not report live manager connection state. @@ -93,20 +92,11 @@ Python stdio built-ins: - RAG; - email. -The optional browser built-in uses `npx -y @playwright/mcp@latest --headless --caps vision`. It is cache-gated by checking npm's `_npx` cache for the requested package and falling back to `npx --no-install`; uncached/missing browser MCP is logged with install guidance and skipped rather than blocking startup or downloading packages at boot. Python built-ins are omitted from OpenAI function schemas because native/code-block paths already describe those capabilities; the browser built-in is exposed through MCP function schemas when connected. +The optional browser built-in uses `npx -y @playwright/mcp@latest --headless --caps vision`. It is cache-gated by checking npm's `_npx` cache for the requested package and falling back to `npx --no-install`; uncached/missing browser MCP is logged with install guidance and skipped rather than blocking startup or downloading packages at boot. Image, memory and RAG Python built-ins normally defer to native/code-block descriptions; email remains a qualified MCP schema/prompt exception. Connected browser schemas do not authorize page execution. -Built-in Python servers prepend the app root to inherited `PYTHONPATH` rather -than replacing the environment, so container/dev site-packages remain visible -on initial connect and automatic reconnect. They can be reconnected once on -tool-call failure. User-configured MCP servers return the call failure instead -of automatic reconnect. +Built-in Python servers prepend the app root to inherited `PYTHONPATH` rather than replacing the environment, so container/dev site-packages remain visible on initial connect and automatic reconnect. `McpManager.call_tool()` can reconnect a built-in once after an exception only when the call has no bound backend identity. Bound calls return the failure so a fresh connection cannot silently replace the sealed backend incarnation. User-configured MCP servers return the call failure instead of automatic reconnect. Browser calls have a bounded timeout; timed-out calls are abandoned without retry because page state is unknown. -The built-in email MCP server is owner-aware when an owner is supplied by the -caller or configured through `ODYSSEUS_MCP_EMAIL_OWNER` / -`ODYSSEUS_EMAIL_OWNER`; if owner-scoped email accounts exist and no owner is -available, email MCP fails closed instead of exposing global accounts. Other -built-in servers remain process-global/admin trust-boundary tools unless their -own subsystem spec says otherwise. +The built-in email MCP server is owner-aware when an owner is supplied by the caller or configured through `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`; if owner-scoped email accounts exist and no owner is available, email MCP fails closed instead of exposing global accounts. Other built-in servers remain process-global/admin trust-boundary tools unless their own subsystem spec says otherwise. ## Agent MCP Exposure @@ -128,9 +118,7 @@ After model-visible external/workspace context, arbitrary MCP actions classify f ## Degraded And Platform Behavior - `app.py` starts the background monitor and MCP startup tasks asynchronously; MCP startup is non-critical to app readiness. -- Configured MCP servers start concurrently with a per-server 20-second bound; - timeout state is stored per server and partial connection resources are - closed before returning. +- Configured MCP servers start concurrently with a per-server 20-second bound; timeout state is stored per server and partial connection resources are closed before returning. - Missing Python `mcp` dependency degrades attempted MCP connections to error status. - Missing or uncached browser NPX package is optional and log-only during built-in startup; startup should not perform an implicit package download. - Windows does not support POSIX PTY/tmux paths; streaming falls back to pipes or detached logfile behavior. @@ -141,16 +129,13 @@ After model-visible external/workspace context, arbitrary MCP actions classify f ## Security And Provenance - Admin shell is intentional host command execution; do not expose shell routes or shell tools to regular users. -- `_require_admin()` gates shell routes and MCP config routes. The internal-tool loopback can be admin-equivalent only after auth middleware validates the internal token and loopback client. +- `routes.shell_routes._require_admin()` gates shell and selected Cookbook native controls, rejecting internal-tool identity/header administration. Auth-disabled operator access requires direct loopback without forwarding or disallowed browser-origin metadata; an Authorization header alone is not excluded by this transport helper. Auth-enabled administration requires a present auth manager and human admin. MCP configuration instead uses common `core.middleware.require_admin()`, which admits a matching internal token/sentinel or explicit auth-disabled mode; it does not inherit the shell gate's transport/resource rules. - `_reject_cross_site()` currently applies to `/api/cookbook/packages`; `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP write/OAuth routes do not call it directly. - Shell helper paths use argv-based SSH, reject option-like hosts, validate SSH ports through shared helpers, restrict remote venv characters, and allowlist package installs. - Non-admin/public tool policy blocks `bash`, `python`, file tools, `manage_mcp`, and all `mcp__*` tools. - MCP stdio server registration is arbitrary host process execution and is admin-only. - MCP OAuth key/token file paths supplied through routes are confined under `data/mcp_oauth`; generic Streamable HTTP OAuth token state is encrypted in the database. -- Built-in MCP servers are local/admin trust-boundary tools and are not - automatically equivalent to owner-scoped HTTP route behavior. Email MCP is - the current exception with explicit owner filtering; other built-ins need - their own owner policy before being treated as scoped surfaces. +- Built-in MCP servers are local/admin trust-boundary tools and are not automatically equivalent to owner-scoped HTTP route behavior. Email MCP is the current exception with explicit owner filtering; other built-ins need their own owner policy before being treated as scoped surfaces. - MCP output is external-untrusted tool output and arms the high-impact continuation gate when model-visible. Current MCP text output is still not centrally capped before model re-entry. ## Testing Notes @@ -159,15 +144,54 @@ Current targeted coverage includes Windows PTY import degradation, PTY unsupport The shell/MCP audit ran the targeted venv subset with 78 passing tests and one warning. +## Contained Execution And Native Browser Identity + +Model-reachable native shell/Python execution uses `src.containment.agent_spec()` with enforcing filesystem, process-tree, and wall-clock requirements. Missing required mechanisms return containment-unavailable results before code runs; optional network/memory/process-count dimensions are recorded separately and must not be assumed enforced. Bubblewrap provides the Linux filesystem/namespace path when available. Direct admin HTTP shell routes remain a distinct operator surface. Host/client bridges declare external execution rather than claiming local sandbox enforcement. + +Process identity is PID plus start token, not PID alone. `src.process_ownership` probes identities; `src.process_lifecycle` protects the server process group and reports death only after observation. Durable callers require positive PID/start-token identity; the generic tree helper also retains a compatibility mode without that requirement, so this is not a universal signaling guarantee. Background jobs persist launch generation, containment, and process identity so stop/restart paths can refuse stale targets. + +`private_browser` currently admits only `session_info`. All page navigation, snapshots, screenshots, close/batch/recovery actions and raw global Playwright browser operations are denied unconditionally at the common boundary before binding or dispatch. `src.browser_identity` and lifecycle/observation modules retain owner/thread/session/incarnation machinery and metadata-only prompt support, but these do not enable page execution. The pinned agent-browser 0.35 Linux x64/arm64 discovery, clean environment and read-only bounded CDP observations do not amount to usable browser acceptance; metadata lookup does not adopt or create an unrelated launch. + +`McpManager` additionally registers backend incarnations and exposes server-owned resource observations for admitted calls. Built-in connection owner tasks keep AnyIO transport scopes alive until disconnect; changed connections invalidate sealed backend identities. Existing transport configuration and schema discovery remain manager-owned. + +## Launch, Cleanup And Bridge Boundaries + +Linux native containment requires a functional bounded bubblewrap mount/PID probe, not merely an installed executable. The supervisor/worker ready acknowledgement holds user code until containment publication, durable job state, sidecar and exact launch receipt are verified. Filesystem, process-tree and wall-clock dimensions are required; missing mechanisms fail before code execution. Captured init/leader PID plus start tokens and optional pidfd support cleanup; both deaths must be observed. Unknown identity remains unverified, foreign identity is never a signal target, and no later PID lookup can manufacture a launch receipt. Startup timeout includes bootstrap/stdin; cancellation shields spawn cleanup. Optional network/memory/process-count dimensions are not implied enforcement. + +Native background work uses generation-bound launches rather than agent tmux. Operator HTTP tmux remains a compatibility surface. PTY cleanup freezes known descendants before signaling and protects the server group; missing launch PID/token does not trigger PID/group recapture. Unverifiable containment grants remain active; detached jobs with foreign/unverifiable ownership can retire failed without signalling, retaining follow-up history rather than declaring successful cleanup. Publication retirement checks exact launch identity and its caller-specific lifetime rule; foreground startup recovery can also remove publications whose receipt is genuinely absent. Unreadable or malformed nonempty startup state is retained. Dead launch retention/pruning and delivered result retention are bounded lifecycle policies, not proof of task success. Store transaction locks serialize background read/modify/write, not every application store. + +`core.platform_compat.service_home()` uses `Path.home()` on Windows and lazy POSIX real-UID passwd lookup. Successful POSIX lookup keeps a child HOME override from choosing the service cache; the missing-entry/OS-error fallback uses `Path.home()` and can honor that override. Default native child environments allow selected keys with fixed HOME/terminal values; an explicitly supplied subprocess environment is not value-scrubbed. This is separate from inherited built-in MCP environments. + +Host shell/file bridges declare external execution and normalize the admitted URL authority while replacing the operation path and dropping query/fragment components. Every bridge run/read/write/patch/poll/cancel client uses `trust_env=False`. HTTP admission accepts the explicit loopback/host-docker/LAN/CGNAT or actual Docker-gateway cases, not every private network or arbitrary TLS host; this bridge rule does not apply to every other HTTP client. SSH/external raw shell calls cannot borrow native launch/stop authority. Cookbook and scheduled native launches carry sealed backend capabilities; missing capabilities fail closed. + +MCP add-server argument text must decode to a JSON array before mutation; an empty value means an empty array, without an element-type guarantee. Bound backend calls cannot silently reconnect into a different incarnation. Browser timeout abandonment remains no-retry: EOF or partial transport state is not exactly-once execution proof. Email MCP owner and session ContextVars are reset in finally; its owner-filtered listing/index behavior is documented with email ownership. + +## Compatibility Process Evidence + +Python execution confinement validates the read-only interpreter-prefix mounts it relies on instead of treating an arbitrary interpreter path as admitted. Process inspection helpers retain platform-specific uncertainty: unavailable procfs inspection is not proof that a process died, and generic compatibility liveness helpers are not substitutes for the stored exact launch identities required by background execution cleanup. + +## Result Publication And Continuation + +Native execution reports containment, teardown and capture truncation independently. Verified timeout uses exit 124; unverified process death is teardown failure rather than successful cleanup, and Python child-runtime failure cannot hide behind exit zero. Detached supervisors publish result JSON before exit metadata; setup failure releases an unstarted grant and falls back to stderr when its failure log cannot be written. Successful exit does not imply complete captured output. + +PTY teardown freezes its leader identity and verified session group. FOREIGN identity is not signalled, absent/unverifiable identity does not prove death, and a gone leader can permit cleanup of its recorded surviving group without rediscovering a replacement PID. Timeout SSE reports surviving processes; quiet disconnect/error paths log failed cleanup. Generic compatibility tree helpers still require durable callers to request identity verification. + +Background historical lookup does not reap unrelated cached handles: selected running records need their exact frozen PID/start token. Continuation validates immutable job, owner, request, thread and authority linkage before and after awaits, settles the linked launch effect from lifecycle facts rather than output, and persists delivery before marking followed-up. Missing sessions or invalid linkage become terminal_unfollowable; busy sessions/managers retry, and a changed snapshot prevents stale suppression. This is not an atomic lock over the entire chat session. + +Foreground startup retirement retains unreadable/malformed stores. With an existing receipt it requires exact linkage and an observed GONE/FOREIGN manager; live or unverifiable managers retain publication even after child teardown. A genuinely missing receipt also permits retirement of a valid foreground publication because attachment cannot resume. Ordinary producer-finally retirement uses verified teardown. One-use launch reservations are shared across approval copies, so deleting publication does not renew dispatch. Retirement is bookkeeping, not authority to signal or proof of process-tree death. Protected-root overlays are refused after normalization; an external bridge response heuristic does not attest local effects. + +## Image MCP Ownership + +`mcp_servers/image_gen_server.py` reads the hidden trusted-dispatch `_odysseus_owner`, trims blank values to null, stamps `GalleryImage.owner`, and registers a fresh image after gallery commit when an owner exists. There is no image-owner environment fallback. `src/tool_execution.py` removes caller-reserved attribution fields before injecting server owner context for legacy and qualified image calls; direct fallbacks retain the trusted owner. Generated-file access uses the independent fail-closed policy in `gallery-editor-media.md`. + ## Current Gaps - Decide whether `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP config/OAuth writes should call `_reject_cross_site()` directly. -- Add route-level shell exec/stream tests for admin gate, cross-site behavior, empty command, plain exec, timeout, PTY, tmux, and Windows detached fallback. -- Add background job tests for launch isolation, output truncation, done/failed/timeout/died states, pending follow-ups, and result text. +- Shell route/service and process-lifecycle tests cover selected execution paths; PTY/tmux/Windows behavior remains platform-dependent. +- Background identity, containment, and follow-up paths have focused tests; restart/platform integration still needs separate validation. - Add route-level MCP CRUD/OAuth/disabled-tool tests with a fake manager and temp database. - Add hard per-server disabled MCP execution checks or document disabled tools as prompt/schema filtering only. -- Make MCP tool indexing sensitive to disabled-map changes, not only manager generation. -- Fix stale outer prompt/cache behavior when MCP disabled tools change. +- MCP prompt descriptions cache against both disabled maps and manager generation; downstream index refresh still needs coordinated invalidation. - Add one central truncation layer for MCP result text and images before model re-entry; untrusted-result marking and exact-action continuation approval are now implemented. - Decide whether `McpServer.env` and OAuth key files need masking, encryption, and chmod beyond admin-only access. - Decide whether built-in MCP servers should become owner-aware or remain documented as admin/global compatibility surfaces. diff --git a/specs/speech.md b/specs/speech.md index 9dab0703a..d5cd55024 100644 --- a/specs/speech.md +++ b/specs/speech.md @@ -1,6 +1,6 @@ # Speech -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@86b886c | 2026-10-15 ## Scope @@ -16,7 +16,7 @@ This spec covers speech behavior in: - settings routes in `routes/auth_routes.py`; - model endpoint cleanup in `routes/model_routes.py`; - settings/tool aliases in `src/tool_implementations.py`; -- frontend modules `static/js/voiceRecorder.js`, `static/js/tts-ai.js`, `static/app.js`, `static/js/chat.js`, `static/js/slashCommands.js`, `static/js/keyboard-shortcuts.js`, `static/js/settings.js`, and `static/index.html`; +- frontend modules `static/js/voiceRecorder.js`, `static/js/tts-ai.js`, `static/app.js`, `static/js/chat.js`, `static/js/slashCommands.js`, `static/js/keyboard-shortcuts.js`, `static/js/settings.js`, `static/js/settings/speech.js`, and `static/index.html`; - optional dependency declarations in `requirements-optional.txt`; - runtime cache path `data/tts_cache/`; - tests covering speech service toggles, TTS speed/cache, STT temp cleanup, upload limits, settings scrubbing, and model endpoint cleanup. @@ -80,7 +80,7 @@ Route behavior: Speech providers are global settings under `data/settings.json`, with defaults in `src/settings.py`. Settings reads are scrubbed for non-admin callers, writes are admin-only, and `manage_settings` can change non-secret speech settings through aliases. -Visible UI state is not complete: backend and JS speech settings exist, the TTS settings card is currently hidden, and the STT settings JS exits when its removed DOM nodes are absent. +`static/js/settings/speech.js` owns TTS/STT settings initialization and provider selectors; the Settings coordinator imports those initializers. Visible UI state is not complete: backend and JS speech settings exist, the TTS settings card is currently hidden, and the STT settings JS exits when its removed DOM nodes are absent. `routes.model_routes` clears `tts_provider` and `stt_provider` references when a referenced model endpoint is deleted. diff --git a/specs/testing-devops.md b/specs/testing-devops.md index 46d0fed18..9f0a13d78 100644 --- a/specs/testing-devops.md +++ b/specs/testing-devops.md @@ -1,6 +1,6 @@ # Testing And Devops -Last updated: dev@e71f8ce | 2026-08-25 +Last updated: dev@c74f475 | 2026-10-15 ## Scope @@ -15,7 +15,7 @@ This spec covers development and validation surfaces in: - `scripts/`, `scripts/odysseus`, `scripts/_lib/cli.py`, `scripts/_completion/*`, `scripts/pr_blocker_audit.py`, and `scripts/odysseus-*`; - GPU helper scripts `scripts/check-docker-gpu.sh` and `scripts/check-docker-amd-gpu.sh`; - `.github/` templates, workflows, and description-check scripts; -- contributor workflow docs in `CONTRIBUTING.md` and `docs/pr-blocker-audit.md`; +- contributor workflow docs in `CONTRIBUTING.md` and `website/pr-blocker-audit.md`; - platform launchers `launch-windows.ps1`, `launcher.py`, `Odysseus.spec`, `build-windows-portable.ps1`, `start-macos.sh`, `build-macos-app.sh`, and `update_windows.bat`; - setup/service files such as `setup.py`, `install-service.sh`, and `odysseus-ui.service`. @@ -37,7 +37,7 @@ Activated-venv `python -m pytest ` is equivalent. System/global `pyte `tests/conftest.py` inserts the repo root on `sys.path` and conditionally stubs missing heavy/runtime dependencies such as SQLAlchemy, FastAPI, Starlette, Pydantic, httpx, bcrypt, and pyotp. Tests that need real dependencies use explicit imports/skips. Tests that stub `sys.modules`, environment variables, globals, or parent packages must restore them with `monkeypatch` or an equivalent cleanup pattern. -The suite currently contains roughly 728 `test_*.py` files. Treat that count as a moving source metric, not a target; focused regression tests are still preferred for narrow changes. +The suite currently contains a growing set of `test_*.py` files. Treat that count as a moving source metric, not a target; focused regression tests are still preferred for narrow changes. Focused regression tests are preferred for narrow behavior changes. Broaden tests when touching shared contracts such as auth, owner filtering, OAuth/token custody, tool output, context building, provider calls, persistence, frontend rendering, or route/API shapes. @@ -45,7 +45,7 @@ Focused regression tests are preferred for narrow behavior changes. Broaden test ## JS And UI Tests -The repo has no frontend build pipeline, npm test script, or type-check script. `package.json` owns Node dependencies for Bombadil and the Anthropic SDK, and `package-lock.json` owns npm integrity/version state. +`frontend/modules.json` maps canonical `frontend/src/` modules to committed outputs under `static/`. `scripts/build_frontend.py --check` verifies output freshness/publication attributes; `scripts/check_frontend.mjs` checks architecture/import boundaries. Root `package.json` owns Bombadil, Playwright and Acorn plus photo-editor browser-test scripts; the lockfile owns npm integrity/version state. This is an incremental source migration rather than a bundler-wide conversion. Current frontend/JS validation includes: @@ -57,13 +57,14 @@ Use `node --check static/js/.js` for syntax checks on changed JS f ## Dependencies -`requirements.txt` owns core runtime and test dependencies, including pytest, pytest-asyncio, MCP, Chroma HTTP client, fastembed, qrcode, and core parsing/search/calendar dependencies. +`requirements.txt` owns core runtime and test dependencies, including `psycopg2-binary`, pytest, pytest-asyncio, MCP, Chroma HTTP client, fastembed, qrcode, and core parsing/search/calendar dependencies. `requirements-optional.txt` owns optional feature dependencies: - `faster-whisper` for local STT; - `kokoro==0.9.4` and `soundfile` for local TTS on Python 3.11-3.12 only; Kokoro is deliberately skipped on Python 3.13+ because its package metadata excludes those runtimes, and a CUDA-capable torch/GPU is still required at runtime; - `ddgs` for DDG library support, while provider code can fall back to HTML scraping; +- `rapidocr==3.9.2` and `onnxruntime>=1.20,<2` for local OCR, and `psd-tools` for PSD previews/layer inspection; - `PyMuPDF` for PDF forms/rendering with AGPL implications for a network-served app; - `markitdown[docx,pptx,xlsx,xls]` for Office/EPUB extraction, pinned to a release older than 30 days. @@ -96,6 +97,8 @@ Compose variants forward `ODYSSEUS_TTS_CACHE_MAX_BYTES`, defaulting in the servi Docker does not mount the host Docker socket by default. Mounting it would grant powerful host access and is outside the default trust boundary. `docker/host-docker.yml` is the explicit opt-in overlay and sets `ODYSSEUS_ENABLE_HOST_DOCKER=true`; tests guard that the default and GPU compose files do not enable host Docker accidentally. +Compose selects `ODYSSEUS_IMAGE` (default GHCR `latest`) alongside a local build and publishes container port 7000 on default host port 7011. SearXNG is tag-and-digest pinned. `docker/host-workspace.yml` explicitly mounts a selected host workspace; `docker/host-network.yml` opts into Linux host networking, resets published ports, and starts uvicorn on the configured host bind/port. These overlays expand access beyond default deployment. The image includes Chromium, Noto CJK fonts, network inspection utilities, pinned `agent-browser@0.35.0`, and redistribution notices. Entrypoint ownership repair touches HuggingFace cache roots without traversing existing model files. + ## GPU And Platform Base `docker-compose.yml` plus `docker/gpu.nvidia.yml` or `docker/gpu.amd.yml` are the GPU source of truth. Top-level `docker-compose.gpu-nvidia.yml` and `docker-compose.gpu-amd.yml` are standalone mirrors for stack-management UIs that accept one compose file. `tests/test_gpu_compose_standalone.py` guards drift between those forms. @@ -145,7 +148,7 @@ When route/API behavior changes, check whether a matching CLI script depends on ## GitHub Metadata -`.github/` owns issue/PR templates, a copyable PR review template, description-check workflows, security/governance workflows, Docker publishing, and CI. Current CI runs on pushes to `main` and `dev` plus pull requests, compiles Python with `python -m compileall`, syntax-checks first-party JS with `node --check`, emits focused-test guidance for changed code, and runs the configured `python -m pytest -q` scope as an authoritative failing job; pytest still skips documentation-only changes. +`.github/` owns issue/PR templates, a copyable PR review template, description-check workflows, security/governance workflows, Docker publishing, and CI. Current CI runs on pushes to `main` and `dev` plus pull requests, compiles Python, checks committed frontend outputs, validates frontend architecture/syntax, emits report-only focused-test guidance, and partitions pytest across four file-based shards through `tests/_shards.py`. Pytest setup requires functional bubblewrap PID/mount containment, FFmpeg and Chromium. Its prose-only bypass excludes `website/` and `assets/branding/`; those published-file contracts remain tested. `CONTRIBUTING.md` owns the branch model: PRs target `dev`; `main` is the curated user-running branch fast-forwarded from stable `dev` commits. Contributors who accidentally target `main` should retarget the PR base without rebasing. @@ -169,9 +172,9 @@ Issue description checks: - update a bot comment and swap status labels; - remove the workflow-owned review label when an issue closes so closed issues do not retain stale readiness state. -Security metadata includes container Trivy SARIF upload, Dockerfile lint, dependency review, secret scan, workflow security linting, GitHub default-setup CodeQL, Dependabot metadata, and hardened PR/issue description checks that avoid unsafe head-branch execution. `docs/security-ci.md` documents CodeQL as a dynamic GitHub default-setup workflow; the repo should not add a checked-in CodeQL workflow while that default setup is active. +Security metadata includes container Trivy SARIF upload, Dockerfile lint, dependency review, secret scan, workflow security linting, GitHub default-setup CodeQL, Dependabot metadata, and hardened PR/issue description checks that avoid unsafe head-branch execution. `website/security-ci.md` documents CodeQL as a dynamic GitHub default-setup workflow; the repo should not add a checked-in CodeQL workflow while that default setup is active. -`scripts/pr_blocker_audit.py` is a read-only maintainer/contributor triage helper documented in `docs/pr-blocker-audit.md`. It can fetch or ingest open PR metadata, estimate hot files and possible duplicate groups, and emit Markdown, JSON, or terminal reports. Its duplicate/blocker output is advisory, not an authority that a PR is blocked. +`scripts/pr_blocker_audit.py` is a read-only maintainer/contributor triage helper documented in `website/pr-blocker-audit.md`. It can fetch or ingest open PR metadata, estimate hot files and possible duplicate groups, and emit Markdown, JSON, or terminal reports. Its duplicate/blocker output is advisory, not an authority that a PR is blocked. Before posting PRs or issues, compare drafts against current templates on latest `main` or current `dev` as appropriate for the target. Keep unpublished drafts and raw related-search exports out of tracked implementation specs unless intentionally promoted. @@ -201,18 +204,50 @@ Run the app for user-facing or integration changes. Unit tests and syntax checks `tests/helpers/` owns reusable test scaffolding. `cli_loader.load_script()` loads CLI files without running their `main()` entrypoint. `db_stubs` owns small DB stand-ins for tests that should not import a real app database. `import_state` owns conservative `sys.modules` and parent-module-attribute restoration for tests that install fake modules or import route files under alternate stubs. `tests/README.md` documents helper conventions and review expectations. +`tests/test_runtime_behavior_regressions.py` drives the agent loop with a fake streaming model to check offered tools, clarification/plan policy, explicit native request scope and mocked browser cleanup. Negative-web cases require a model call, an answer delta and terminal completion while withholding web tools. These deterministic runtime assertions do not establish live-model or browser acceptance. + +## Harness And Publication Boundaries + +`tests/conftest.py` defaults the static origin to an ephemeral loopback port and publishes/restores `ODYSSEUS_TEST_STATIC_ORIGIN`; an explicit validated port can still collide. Threaded serving avoids idle-connection head-of-line blocking. Per-process random private data/mail/embedding/cache/temp paths and optional xdist basetemp avoid sharing runtime state; explicit basetemp wins. Disposable database helpers use isolated factories/cleanup rather than ORM reloads. A teardown guard removes and fails newly leaked bare `src`/`core` modules, using exact ModuleType/falsey-file detection; preexisting modules and mocks are outside that guard. The Unix socket path length fixture is character-bounded, not a universal byte-length proof. + +`tests/_shards.py` partitions collected files deterministically by weights and lexical tie-breaks across four CI shards; deselection occurs after collection/import. Optional parallel smoke and serial-only guards do not make every test xdist-safe. Pytest CI uses Node 20, locked npm dependencies, cached Chromium downloads keyed by OS/lockfile, system browser libraries, FFmpeg and a functional unprivileged bubblewrap probe. It uses setup-uv cache without automatic prune and pytest skip reasons/duration reporting. These workflow provisions are not evidence that every browser, media or containment test passed on another host. + +`tests/helpers/document_source.py` provides ordered implementation membership and lexical construct extraction; email JS helper enumeration is immediate rather than recursive. Stylesheet helpers follow shell order and cache identities; keyframe/hygiene/import/procfs guards are bounded textual or AST checks, not full semantic control-flow/browser proofs. The computed-style fixture, browser artifacts and reported historical suite counts remain specific to their recorded environments. + +`scripts/odysseus-dev` owns separate development data/database/browser-cache paths, path-hashed port blocks, service/reserved-port refusal, readiness login and fingerprinted stop state. Hash collisions, released fallback-port races and environment-derived defaults prevent a universal isolation guarantee. `scripts/odysseus-smoke` exercises deterministic model/email HTTP scenarios and reports declared gaps; it is not full browser/product acceptance and cleanup/reused-instance state remains scoped. `scripts/ref_parity_audit.py` compares heuristic added-line probes and exact filtered tree-path sets, not behavioral equivalence; mutable refs can be reread during a run. `scripts/generate_env_reference.py` checks generated reference bytes and handwritten notes from bounded AST/helper/regex discovery, not exhaustive runtime environment use; malformed Python can yield no detected rows and source citations are file-based. + +`.github/workflows/deploy-pages.yml` builds `website/` into `_site`; preview encoding targets website assets and branding lives under `assets/branding/`. Docker excludes both website and branding source roots. The macOS app script uses moved branding with best-effort `sips` icon generation; no native build or license clearance follows from source inspection. Docker publishing emits version/short-SHA trace tags and inspects those plus latest, without registry-enforced immutability. Trivy jobs use the Docker Buildx driver and free local build cache before vulnerability database work; this is CI lifecycle behavior, not an instruction to prune controller state. + +Description checks retain the public numeric Linked Issue requirement; an exact maintainer-preview repository allows a beginning `N/A` value while still requiring the section after comment stripping. This checks syntax, not issue existence or branch rulesets. Dependency-review blocking applies to canonical repository pull requests; fork pip-audit advice is a separate policy. None of these workflows grants publication authority. + +Maintained frontend publication requires repository-owned LF text attributes on both mapped paths, checked through isolated Git attribute evaluation. It validates the complete plan before writes, copies bytes without rewriting imports or bundling and never deletes unowned static assets; publication is not a transactional live deployment. Acorn checks manifest-selected dependency direction, canonical URL identity, used legacy exceptions, exact precache arrays and cycles, not all legacy browser behavior. + +## Chroma Mount And Focused Safety Regressions + +All three Compose variants mount `chromadb-data:/data`, matching the current image configuration rather than pre-1.0 `/chroma/chroma`. Existing writable-layer data needs rescue before container recreation; `docs/chromadb-volume-migration.md` describes writer shutdown/cold backup, preserved backup image, SQLite/HNSW checks, empty-volume seeding, isolated staging and count comparison. `website/backup-restore.md` links that procedure. Static YAML/document tests cover declared paths and safety markers; they do not execute or certify migration against a live Chroma image. + +Focused regressions now cover invalid history truncation preserving messages, failed bulk Trash moves/selective expunge, disabled-auth app configuration gates, durable cleanup message counts/delete-boundary races and generated-image ownership/cache/attribution. Test presence and historical counts do not establish a new successful run; runtime validation evidence is separate. + +## Notes Extraction Validation Surface + +Notes tests include domain/characterization/application/infrastructure/reminder/workflow contracts, recurrence differential cases across timezones, raw-state cloneability, draft timing and selected-context acceptance. Browser fixtures exercise canonical/facade identity, desktop/mobile/tablet interactions, teardown and first-time offline graph loading; source guards now read feature modules through `tests/helpers/notes_source.py`. `scripts/verify_frontend_runtime.py` checks all manifest outputs in a Python-only distribution, while `scripts/verify_notes_browser_runtime.py` probes an authenticated temporary native app. These require their declared dependencies/browser environment; committed receipts and screenshots are historical evidence, not a fresh acceptance run. Runtime serving still requires no frontend build or Node installation. + +## Skills Extraction And Upload Contract Tests + +`tests/test_skills_audit_characterization.py` freezes 22 route declarations, 28 Python interfaces and canonical OpenAPI (17 paths, 22 operations, five schemas) from committed fixtures; constrained fields, supported default factories, model configuration and aliases are checked structurally. Unsupported factories fail without execution. `tests/test_skills_audit_services.py` exercises real route/service wiring with offline model/agent/manager substitutes, including prompt seams, owner/request authority, one-use approvals and cancellation/stream closure. Fixtures do not prove arbitrary validators, live execution or authorization by themselves. ReDoS guards now scan/import `services/skills/evaluation.py`. The Gitleaks exception is constrained to one exact historical digest, the generic-api-key rule and two named paths; this maintenance does not claim a new full-history scan. + +`tests/test_upload_no_login.py` covers disabled-auth access after setup, missing/unconfigured manager failures, token ownership/scopes, legacy ownerless uploads and OCR preservation on rejection. Its loopback-host parametrization does not itself change TestClient transport identity, so source behavior and narrower assertions remain the evidence for absence of a loopback bypass. + ## Current Gaps - Fresh install smoke coverage across Linux native, Docker, macOS native/app, Windows native, WSL/Git Bash, missing Node/npm, missing Chroma service, and GPU overlays remains a roadmap item. -- There is no frontend build/type-check/npm test pipeline. -- CI now covers Python compile, first-party JS syntax, focused-test guidance, - and pytest smoke; it does not cover Docker compose validation, launcher smoke - tests, browser/module-graph execution, or platform installs. +- Frontend source/output and architecture gates exist; they do not establish full browser or platform correctness. +- Real deployment, GPU and cross-platform install verification still require separate runs beyond CI gates. - Optional dependency behavior is broad; remaining gaps include local STT missing-`faster-whisper`, Kokoro's Python/GPU degraded matrix, and provider/OAuth combinations not covered by focused tests. - GitHub description-check scripts and `scripts/pr_blocker_audit.py` need continued local fixtures for section parsing, placeholder stripping, label swaps, workflow-safe behavior, and duplicate/hot-file heuristics. - Spec bootstrap rules lack meta tests for reading `_readme.md`, spec shape, `.env*` handling, draft/report placement, and shared helper conventions. - NVIDIA helper install/`.env` mutation paths and real Docker/GPU startup are not covered by local tests. - Bash/Zsh completion behavior is not covered. -- There is no canonical full-suite known-failing/flaky ledger. +- `tests/KNOWN_FAILURES.md` records observed environment-dependent full-suite failures and prerequisites; its measured basis is historical, so verify applicability rather than treating it as a passing waiver. - There is no central CLI redaction/sensitive-output regression matrix across backup, logs, mail, MCP, tasks, and webhook scripts. - Dependency/image pinning policy is mixed: Python requirements are mostly unpinned, SearXNG is pinned, Chroma image currently uses `latest`, npm uses a lockfile, and browser MCP uses cache-gated `@playwright/mcp@latest`. diff --git a/src/agent_loop.py b/src/agent_loop.py index f3609b41b..257cc441d 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -6933,6 +6933,7 @@ _AGENT_RULES = """\ - If the user asks for multiple specific emails and you call `read_email` more than once, your final answer MUST include every successfully read email, clearly separated and linked by UID. Do not answer with only the last email you read. - Multiple email accounts: if tool output says "Other accounts" or the user asks "my Gmail?", "other inbox?", "work mail?", "custom domain mail?", or names any mailbox/account, DO NOT answer from memory. Call `list_email_accounts` if needed, then call `list_emails`/`read_email`/`bulk_email` with the exact `account` value for that mailbox. Account names are user-defined labels; if the user typo-matches a known account, use the closest listed account instead of claiming it does not exist. NEVER use `app_api` or `/api/email/accounts` to discover email accounts; that route is owner-filtered in tool context and can falsely return empty. - User identity facts/preferences ("my name is ", "I live in ", "I prefer concise replies", "call me ") → use `manage_memory` with action=add. NEVER use `manage_contact` for facts about the user unless the user explicitly says to create/update a contact and provides contact details such as an email or phone. +- When the prompt contains "Selected notes and todos", treat that content as already-provided reference context. Use it directly when it answers the request; do not call `manage_notes` to list/search/view the same notes, and do not ask for permission merely to use the selected context. Call `manage_notes` only when the user requests a note change or the selected context is insufficient and a fresh lookup is needed. - "Create/add/write a note" / "notes" / "todos" / "remind me to X at