Compare commits

..
Author SHA1 Message Date
dependabot[bot] 58773fe4ef chore(deps): bump the python group across 1 directory with 6 updates
Updates the requirements on [onnxruntime](https://github.com/microsoft/onnxruntime), [markitdown](https://github.com/microsoft/markitdown), [httpcore](https://github.com/encode/httpcore), [pydantic](https://github.com/pydantic/pydantic), [pydantic-settings](https://github.com/pydantic/pydantic-settings) and [mcp](https://github.com/modelcontextprotocol/python-sdk) to permit the latest version.

Updates `onnxruntime` to 1.30.0
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.20.0...v1.30.0)

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

Updates `httpcore` to 1.0.9
- [Release notes](https://github.com/encode/httpcore/releases)
- [Changelog](https://github.com/encode/httpcore/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpcore/compare/1.0.0...1.0.9)

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

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

Updates `mcp` to 2.3.0
- [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases)
- [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md)
- [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v0.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: onnxruntime
  dependency-version: 1.30.0
  dependency-type: direct:production
  dependency-group: python
- dependency-name: markitdown
  dependency-version: 0.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: httpcore
  dependency-version: 1.0.9
  dependency-type: direct:production
  dependency-group: python
- dependency-name: pydantic
  dependency-version: 2.13.5
  dependency-type: direct:production
  dependency-group: python
- dependency-name: pydantic-settings
  dependency-version: 2.15.0
  dependency-type: direct:production
  dependency-group: python
- dependency-name: mcp
  dependency-version: 2.3.0
  dependency-type: direct:production
  dependency-group: python
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-08 17:38:00 +00:00
Robert Vind-Gardoș a8c147b238 fix(reminders): retry failed note delivery through the configured channel (#6566) 2026-10-07 15:38:41 +02:00
Léo ec5bcd2b9a perf(tools): take one control-plane snapshot per grep/glob/ls scan (#6551)
The #6503 merge added _control_plane_path() to _is_denied_tool_path(),
which grep, glob and ls call per enumerated entry. Without a snapshot
argument every call rebuilt _control_plane_snapshot() and stat'ed every
protected state file again. A 5,000-file grep went from 0.75 s to ~7 s,
and around 20k files it hits the grep timeout.

Thread an optional snapshot through _is_denied_tool_path() and
_can_traverse_tool_path(), and build it once per scan, the way
process_resources already does for its launch-boundary walk. Root
resolution and bound-resource resolution still observe fresh state.
2026-10-07 12:57:40 +01:00
Léo 137daab16e fix(preview): return validator-authored tool errors to the model (#6554)
The compact preview reduces every pre-execution tool error to a fixed
allowlist so exception text cannot reach the client. Messages our own
validators build per call are not on that list, so the model got "The
tool call could not be validated..." instead of the pdf_extract path
hint, the email uid provenance error, the artifact-Python target
message, or which schema argument was wrong. That is the default path
for the tool-profile models, Qwen3.5-9B included.

Each validator site now records its message in a per-call
validator_error before raising, and only that text is returned
verbatim. A schema failure gets a hint rebuilt from the offered schema
and the call's own arguments (missing property, expected type, allowed
values), never from the caught ValidationError. Everything else,
including all execution failures, still goes through
_public_preview_tool_error.
2026-10-07 12:52:39 +01:00
Léo 41cace1337 test(confinement): give user-content carve-out cases stable ids (#6555)
test_allows_user_content_the_app_hands_to_the_model is parametrized on
UPLOAD_DIR and friends, which sit under the per-worker data dir. pytest
used those paths as test ids, so every xdist worker collected different
names and `pytest -n 4` (documented in tests/README.md) aborted at
collection. CI shards with --shard and never saw it.
2026-10-07 12:51:58 +01:00
Léo 10377f0c95 ci: cut per-shard setup time in the pytest matrix (#6557)
Each of the four pytest shards spends ~84 s on setup before pytest
starts, and repeats all of it: pip resolves and installs ~108 packages
(~24 s, even with the pip cache hit), Playwright downloads Chromium and
the headless shell with no cache (~11 s), and three separate
apt-get update calls run.

- Install requirements with uv (astral-sh/setup-uv, pinned) and cache
  its downloads keyed on requirements.txt. uv resolves the same 108
  pins as pip for this requirements.txt.
- Cache ~/.cache/ms-playwright keyed on package-lock.json so a hit
  skips the browser downloads; --with-deps still installs system libs.
- Install FFmpeg and bubblewrap in one apt pass. The containment step
  keeps its bwrap probes unchanged.
- Add --durations=25 so the slowest tests per shard are visible; the
  shard planner only weights the slow marker, and shard times vary
  from 154 s to 296 s within one run.
2026-10-07 13:07:40 +02:00
Alexandre Teixeira 2c8e00ca05 fix(publication): restore project-origin branding and demo assets (#6561)
* chore(publication): restore cleared first-party assets

* fix(branding): restore publication asset integrations

* fix(publication): tighten restoration provenance
2026-10-07 02:08:34 +01:00
39 changed files with 711 additions and 235 deletions
+22 -7
View File
@@ -147,8 +147,15 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip # uv resolves the same versions as pip but installs ~5x faster, and its
- run: pip install -r requirements.txt # 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:
enable-cache: true
cache-dependency-glob: requirements.txt
prune-cache: false
- run: uv pip install --system -r requirements.txt
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
@@ -157,15 +164,25 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
# Browser binaries are keyed on the lockfile's Playwright version. On a
# hit, --with-deps still installs the system libraries but skips the
# browser downloads.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
- run: mkdir -p data # sqlite DB lives at ./data/app.db - run: mkdir -p data # sqlite DB lives at ./data/app.db
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
- name: Install FFmpeg for media integration tests # One apt pass for FFmpeg (media integration tests) and bubblewrap
# (containment, verified in the next step).
- name: Install FFmpeg and bubblewrap
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap
command -v ffmpeg command -v ffmpeg
ffmpeg -version | head -n 1 ffmpeg -version | head -n 1
@@ -174,8 +191,6 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends bubblewrap
bwrap --version bwrap --version
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \ sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
kernel.apparmor_restrict_unprivileged_userns kernel.apparmor_restrict_unprivileged_userns
@@ -223,4 +238,4 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
env: env:
PYTEST_SHARD: ${{ matrix.shard }} PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --shard "$PYTEST_SHARD" run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
+21
View File
@@ -67,3 +67,24 @@ SAN-191 reconciles references, font preferences, catalogs, tests and packaging.
The service-worker cache version changes so activation deletes prior app caches. The service-worker cache version changes so activation deletes prior app caches.
Omission is not a finding of infringement and does not grant permission to Omission is not a finding of infringement and does not grant permission to
restore the removed originals. restore the removed originals.
## Post-publication restoration review
The historical SAN entries recorded above reflect the conservative posture adopted during publication migration. As noted in SAN-191, omission was not a finding of infringement. A subsequent maintainer review traced these 14 project-origin assets through repository history, recorded their repository provenance, and restored their byte-identical historical versions previously distributed by the project.
The original SHA-256 hashes are recorded below. Ambiguous third-party assets (such as vendor marks and third-party libraries) remain deliberately omitted.
- RESTORE-001: `assets/branding/odysseus-browser.jpg` (SHA-256: `96bc894113d3db1a8e19535047e85fef7bfe7e643d45973d6effb945dce667df`): README interface screenshot introduced by pewdiepie-archdaemon in commit "Refresh README screenshot".
- RESTORE-002: `assets/branding/odysseus-wordmark.png` (SHA-256: `5e20708f9709735c66692c0cffc74734d61cfa256d48f9095c6870bcd53f4552`): README wordmark artwork introduced by pewdiepie-archdaemon in commit "Refresh README presentation".
- RESTORE-003: `assets/branding/odysseus.jpg` (SHA-256: `cd5e87b12f1e9e7baca6a6c5e95012130c9e26c6e31526e864eaf17096fcd05e`): Project branding image from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon, used by build-macos-app.sh for launcher icon generation.
- RESTORE-004: `website/bg.webm` (SHA-256: `157f732539a6072d55164da23d65afa9423782728dd067154867fcebb176fb35`): Landing page background video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-005: `website/chat.webm` (SHA-256: `d69f319192cec2cac1831e08ee99ae902398f1b163021e42b3fda05a0d7ab4d5`): Chat demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-006: `website/compare.webm` (SHA-256: `90d8c68dbc2ac3029b258598a7e2c2c5c8f5d6d702b9d31f0c729e5517093f51`): Model comparison demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-007: `website/document.webm` (SHA-256: `754e4ae071e9def5151749a03ad284cd41385354620fc8afea3c5c4f50f64a0d`): Document editor demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-008: `website/gallery.webm` (SHA-256: `b3bbe99c55b5cd1de32cb8e84fa31044761b7724a76a4bae4d4b0acaed7f3500`): Image gallery demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-009: `website/notes.webm` (SHA-256: `2a82b501271c4b60927e575bb22f8c986e3d1a4089a5518f84b46e534dea37d7`): Notes demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-010: `website/research.webm` (SHA-256: `2495a1a6cc1ea037f7460d065e3af6aacf091b240edebc436ce4bca5e83dab87`): Deep research demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-011: `website/theme.webm` (SHA-256: `6bd5a4140e9eeb9e06bee217319d331e9c8c873763fcb9040425fb38878b8410`): Theme demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-012: `static/icons/icon-192.png` (SHA-256: `d9f54e07d2a8dca6302125d84436176a26a056745c5adb29ef521345c161e359`): Project PWA icon (192x192) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
- RESTORE-013: `static/icons/icon-512.png` (SHA-256: `785872b140da58087f23539ed187fe0f69de6eb9690abf995b10c93b5b518388`): Project PWA icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
- RESTORE-014: `static/icons/icon-maskable-512.png` (SHA-256: `7ed567fe0de6b6b451eec35c8fb2b465d84a3b23945135c19c4fa388ac976449`): Project PWA maskable icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
+8 -2
View File
@@ -1,4 +1,6 @@
<h1 align="center">Odysseus</h1> <p align="center">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
<p align="center"> <p align="center">
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows. A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
@@ -15,6 +17,10 @@
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a> <a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
</p> </p>
<p align="center">
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
</p>
--- ---
## Quick Start ## Quick Start
@@ -53,7 +59,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo ## Demo
The [Odysseus landing page](https://odysseus-dev.github.io/odysseus/) gives a text-only overview of each feature. Its source lives under [`website/`](website/). A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
## Contributing ## Contributing
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+18 -2
View File
@@ -27,8 +27,23 @@ echo " port: $PORT"
rm -rf "$APP" rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# Use the macOS default application icon; no branding-derived artwork is bundled. # ── Icon (best effort) -- center-crop the branding image to a square .icns ──
echo " icon: macOS default" if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
# let sips emit the .icns directly: more robust across macOS versions than
# building an .iconset by hand.
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns"
else
echo " icon: (skipped - conversion failed)"
fi
rm -rf "$TMPIMG"
else
echo " icon: (skipped - no assets/branding/odysseus.jpg)"
fi
cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses" cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses"
cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/" cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/"
@@ -45,6 +60,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>CFBundleShortVersionString</key><string>1.0</string> <key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleExecutable</key> <string>$APP_NAME</string> <key>CFBundleExecutable</key> <string>$APP_NAME</string>
<key>CFBundleIconFile</key> <string>odysseus</string>
<key>LSMinimumSystemVersion</key> <string>11.0</string> <key>LSMinimumSystemVersion</key> <string>11.0</string>
<key>NSHighResolutionCapable</key> <true/> <key>NSHighResolutionCapable</key> <true/>
<key>LSUIElement</key> <false/> <key>LSUIElement</key> <false/>
+2 -2
View File
@@ -1,7 +1,7 @@
# Optional dependencies — install only if you use the corresponding feature. # Optional dependencies — install only if you use the corresponding feature.
# Local OCR for screenshots, scans, labels, and coordinate-grounded text extraction. # Local OCR for screenshots, scans, labels, and coordinate-grounded text extraction.
rapidocr==3.9.2 rapidocr==3.9.2
onnxruntime>=1.20,<2 onnxruntime>=1.30.0,<2
# The app handles their absence gracefully (clear error message on first use). # The app handles their absence gracefully (clear error message on first use).
# #
# Note: chromadb-client + fastembed moved to requirements.txt — RAG, semantic # Note: chromadb-client + fastembed moved to requirements.txt — RAG, semantic
@@ -46,7 +46,7 @@ PyMuPDF
# magika (onnxruntime), already a core dep via fastembed. We avoid the # magika (onnxruntime), already a core dep via fastembed. We avoid the
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per # [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
# the dependency-age discussion in issue #485. # the dependency-age discussion in issue #485.
markitdown[docx,pptx,xlsx,xls]==0.1.6 markitdown[docx,pptx,xlsx,xls]==0.1.8
# Photoshop PSD opening / flattened previews / layer inspection. # Photoshop PSD opening / flattened previews / layer inspection.
psd-tools psd-tools
+4 -4
View File
@@ -3,9 +3,9 @@ uvicorn
python-multipart python-multipart
python-dotenv python-dotenv
httpx httpx
httpcore>=1.0,<2.0 httpcore>=1.0.9,<2.0
pydantic>=2.13.4 pydantic>=2.13.5
pydantic-settings>=2.14.1 pydantic-settings>=2.15.0
SQLAlchemy SQLAlchemy
pypdf pypdf
pypdfium2 pypdfium2
@@ -46,7 +46,7 @@ bcrypt
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a # Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
# breaking rewrite, so keep fresh installs on the maintained v1 line until the # breaking rewrite, so keep fresh installs on the maintained v1 line until the
# servers are migrated together. # servers are migrated together.
mcp<2 mcp<3
pyotp pyotp
qrcode[pil] qrcode[pil]
croniter croniter
+19 -46
View File
@@ -391,53 +391,26 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
async def truncate_session(request: Request, session_id: str): async def truncate_session(request: Request, session_id: str):
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
try: body = await request.json()
body = await request.json() keep_count = int(body.get("keep_count", 0))
except json.JSONDecodeError: before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip()
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]] = [] deleted_sft_pairs: list[dict[str, str]] = []
# keep_count is always >= 0 here; the block is unconditional. if keep_count >= 0:
db = SessionLocal() db = SessionLocal()
try: try:
all_db_messages = db.query(DbChatMessage).filter( all_db_messages = db.query(DbChatMessage).filter(
DbChatMessage.session_id == session_id DbChatMessage.session_id == session_id
).order_by(DbChatMessage.timestamp).all() ).order_by(DbChatMessage.timestamp).all()
if before_msg_id: if before_msg_id:
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id) resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
if resolved_keep_count is None: if resolved_keep_count is None:
raise HTTPException(404, "Message not found") raise HTTPException(404, "Message not found")
keep_count = resolved_keep_count keep_count = resolved_keep_count
for pos, row in enumerate(all_db_messages): for pos, row in enumerate(all_db_messages):
row._will_delete_for_sft = pos >= keep_count row._will_delete_for_sft = pos >= keep_count
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages) deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
finally: finally:
db.close() db.close()
result = session_manager.truncate_messages(session_id, keep_count) result = session_manager.truncate_messages(session_id, keep_count)
_remove_deleted_sft_trace_rows( _remove_deleted_sft_trace_rows(
owner=effective_user(request), owner=effective_user(request),
+2 -2
View File
@@ -20,7 +20,7 @@ This spec covers the current browser app in:
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed. `/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
The static PWA icon files (192px, 512px, maskable) were deliberately omitted from publication, and `static/icons/` is intentionally absent. `static/manifest.json` declares no `icons`, and neither `static/index.html` nor `static/login.html` ships a static `apple-touch-icon` link. Icons come from generated inline SVG at runtime: the `data:image/svg+xml` favicon, the `apple-touch-icon` link that `static/index.html` and `static/js/theme.js` create, and per-route Blob manifests whose `icons` point at that SVG. Do not reintroduce references to icon files that are not shipped. `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.
## Current Call Sites Include ## Current Call Sites Include
@@ -81,7 +81,7 @@ Storage/secrets policy:
- other static assets use cache-first with background refresh; - other static assets use cache-first with background refresh;
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes. - `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
`static/manifest.json` owns default PWA metadata and intentionally has no `icons` entry. Route-specific manifests can be generated as Blob URLs when supported; their icons are the route's generated inline SVG, not files. `static/manifest.json` owns default PWA metadata, referencing the restored icons under `static/icons/`. Route-specific manifests can be generated as Blob URLs when supported; dynamic icons use the route's generated inline SVG, while the default manifest uses the static icons.
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained. KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
+18 -9
View File
@@ -801,15 +801,18 @@ class LsTool:
return {"error": f"ls: {e}", "exit_code": 1} return {"error": f"ls: {e}", "exit_code": 1}
def _ls(): def _ls():
from src.agent_runtime.resources import _control_plane_snapshot
if not os.path.isdir(root): if not os.path.isdir(root):
return None, f"ls: {root}: not a directory" return None, f"ls: {root}: not a directory"
rows = [] rows = []
snapshot = _control_plane_snapshot()
try: try:
with os.scandir(root) as it: with os.scandir(root) as it:
for entry in it: for entry in it:
if entry.name.startswith("."): if entry.name.startswith("."):
continue continue
if _is_denied_tool_path(os.path.realpath(entry.path)) or not _visible_bound_resource(entry.path): if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
or not _visible_bound_resource(entry.path)):
continue continue
try: try:
is_dir = entry.is_dir(follow_symlinks=False) is_dir = entry.is_dir(follow_symlinks=False)
@@ -864,10 +867,12 @@ class GlobTool:
return {"error": f"glob: {e}", "exit_code": 1} return {"error": f"glob: {e}", "exit_code": 1}
def _glob(): def _glob():
from src.agent_runtime.resources import _control_plane_snapshot
base = os.path.abspath(root) base = os.path.abspath(root)
if not os.path.isdir(base): if not os.path.isdir(base):
return None, f"glob: {root}: not a directory" return None, f"glob: {root}: not a directory"
rbase = os.path.realpath(base) rbase = os.path.realpath(base)
snapshot = _control_plane_snapshot()
norm_pat = pattern.replace("\\", "/") norm_pat = pattern.replace("\\", "/")
# Fast path: literal pattern (no wildcards) → direct path lookup. # Fast path: literal pattern (no wildcards) → direct path lookup.
if not any(c in norm_pat for c in "*?["): if not any(c in norm_pat for c in "*?["):
@@ -884,7 +889,8 @@ class GlobTool:
# .ssh/id_rsa, …) falls through to the walk, which skips it — # .ssh/id_rsa, …) falls through to the walk, which skips it —
# otherwise glob would surface secret paths that read_file / # otherwise glob would surface secret paths that read_file /
# grep already refuse to touch. # grep already refuse to touch.
if inside and os.path.exists(cand) and not _is_denied_tool_path(cand) and _visible_bound_resource(cand): if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
and _visible_bound_resource(cand)):
return [cand], None return [cand], None
# Literal not at exact path — fall through to walk so # Literal not at exact path — fall through to walk so
# e.g. "foo.py" still matches at any depth (like rglob). # e.g. "foo.py" still matches at any depth (like rglob).
@@ -894,7 +900,7 @@ class GlobTool:
cap = _CODENAV_MAX_HITS * 5 cap = _CODENAV_MAX_HITS * 5
try: try:
for dp, dns, fns in os.walk(base): for dp, dns, fns in os.walk(base):
if not _can_traverse_tool_path(os.path.realpath(dp)): if not _can_traverse_tool_path(os.path.realpath(dp), snapshot=snapshot):
dns[:] = [] dns[:] = []
continue continue
# Prune skipped dirs before descending (unlike rglob which # Prune skipped dirs before descending (unlike rglob which
@@ -905,7 +911,7 @@ class GlobTool:
d for d in dns d for d in dns
if d not in _CODENAV_SKIP_DIRS if d not in _CODENAV_SKIP_DIRS
and d not in _SENSITIVE_BASENAMES and d not in _SENSITIVE_BASENAMES
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d))) and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)), snapshot=snapshot)
] ]
for name in fns + dns: for name in fns + dns:
full = os.path.join(dp, name) full = os.path.join(dp, name)
@@ -913,7 +919,8 @@ class GlobTool:
if regex.fullmatch(rel) or regex.fullmatch(name): if regex.fullmatch(rel) or regex.fullmatch(name):
# Skip deny-listed sensitive files (.env, id_rsa, # Skip deny-listed sensitive files (.env, id_rsa,
# known_hosts, …) the same way grep does. # known_hosts, …) the same way grep does.
if _is_denied_tool_path(os.path.realpath(full)) or not _visible_bound_resource(full): if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
or not _visible_bound_resource(full)):
continue continue
try: try:
mtime = os.stat(full).st_mtime mtime = os.stat(full).st_mtime
@@ -981,6 +988,7 @@ class GrepTool:
import subprocess import subprocess
import threading import threading
from src.agent_runtime.resources import _control_plane_snapshot
from src.constants import DATA_DIR from src.constants import DATA_DIR
rg = shutil.which("rg") rg = shutil.which("rg")
@@ -992,6 +1000,7 @@ class GrepTool:
if bound is not None: if bound is not None:
bound.validate() bound.validate()
files = [] files = []
snapshot = _control_plane_snapshot()
def check_deadline(): def check_deadline():
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
@@ -1002,7 +1011,7 @@ class GrepTool:
if os.path.islink(path): if os.path.islink(path):
return return
canonical = os.path.realpath(path) canonical = os.path.realpath(path)
if not _path_within(canonical, base) or _is_denied_tool_path(canonical): if not _path_within(canonical, base) or _is_denied_tool_path(canonical, snapshot=snapshot):
return return
if bound is not None: if bound is not None:
try: try:
@@ -1037,7 +1046,7 @@ class GrepTool:
while pending_directories: while pending_directories:
check_deadline() check_deadline()
directory = pending_directories.pop() directory = pending_directories.pop()
if not _can_traverse_tool_path(directory): if not _can_traverse_tool_path(directory, snapshot=snapshot):
continue continue
with os.scandir(directory) as entries: with os.scandir(directory) as entries:
for entry in entries: for entry in entries:
@@ -1052,8 +1061,8 @@ class GrepTool:
raise ValueError("grep: directory identity changed during enumeration") raise ValueError("grep: directory identity changed during enumeration")
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
if (entry.name not in _CODENAV_SKIP_DIRS if (entry.name not in _CODENAV_SKIP_DIRS
and _can_traverse_tool_path(canonical) and _can_traverse_tool_path(canonical, snapshot=snapshot)
and (bound is None or _is_denied_tool_path(canonical) and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
or _visible_bound_resource(canonical))): or _visible_bound_resource(canonical))):
pending_directories.append(canonical) pending_directories.append(canonical)
else: else:
+32 -9
View File
@@ -2297,10 +2297,10 @@ async def action_audit_skills(owner: str, **kwargs) -> Tuple[str, bool]:
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]: async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
"""Background note-due scanner. Fires a reminder for any note whose """Background note-due scanner. Fires a reminder for any note whose
`due_date` falls in the current ±5-minute window and hasn't been pinged `due_date` falls in the current ±90-second window and hasn't been delivered
within the last 25 minutes. Mirrors `action_ping_events` for calendar. through its configured channel in the last 25 minutes.
State (`data/note_pings.json`): {note_id: iso_ts_of_last_ping}. Pruned Per-owner state: {note_id: {at, channel}}, with legacy timestamp support. Pruned
on each run by dropping entries for notes that are gone/archived/replied. on each run by dropping entries for notes that are gone/archived/replied.
""" """
try: try:
@@ -2309,6 +2309,11 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
from datetime import datetime as _dt, timezone as _tz, timedelta as _td from datetime import datetime as _dt, timezone as _tz, timedelta as _td
from pathlib import Path as _P from pathlib import Path as _P
from core.database import SessionLocal as _SL, Note as _N from core.database import SessionLocal as _SL, Note as _N
from src.settings import load_settings
channel = load_settings().get("reminder_channel", "browser")
external_channel = channel in ("email", "ntfy", "webhook")
delivery_key = f"{channel}_sent" if external_channel else "browser_sent"
# Per-owner state file so cache-pruning doesn't cross-delete other # Per-owner state file so cache-pruning doesn't cross-delete other
# users' entries (review C4). Legacy path kept as fallback so a # users' entries (review C4). Legacy path kept as fallback so a
@@ -2373,20 +2378,27 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
due = _parse_due(n.due_date) due = _parse_due(n.due_date)
if not due: if not due:
continue continue
# Inside the ±5min window? # Inside the due window?
if abs((due - now).total_seconds()) > window.total_seconds(): if abs((due - now).total_seconds()) > window.total_seconds():
continue continue
# Recently pinged? Skip. # Recently pinged? Skip.
last = cache.get(n.id) last = cache.get(n.id)
browser_recent = False
if last: if last:
try: try:
last_channel = None
if isinstance(last, dict): if isinstance(last, dict):
last_channel = last.get("channel")
last = last.get("at") last = last.get("at")
last_dt = _dt.fromisoformat(str(last)) last_dt = _dt.fromisoformat(str(last))
if last_dt.tzinfo is None: if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=_tz.utc) last_dt = last_dt.replace(tzinfo=_tz.utc)
if last_dt >= reping_cutoff: if last_dt >= reping_cutoff:
continue if not external_channel or last_channel == channel:
continue
# Browser-only receipts do not prove external
# delivery, but the fallback must not be queued twice.
browser_recent = last_channel in (None, "browser")
except Exception: except Exception:
pass pass
# Compose + dispatch. # Compose + dispatch.
@@ -2410,15 +2422,26 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
body = "\n\n".join(p for p in body_parts if p) or title body = "\n\n".join(p for p in body_parts if p) or title
try: try:
from routes.note_routes import dispatch_reminder from routes.note_routes import dispatch_reminder
await dispatch_reminder( result = await dispatch_reminder(
title=title, note_body=body, note_id=n.id, title=title, note_body=body, note_id=n.id,
owner=n.owner or owner or "", owner=n.owner or owner or "",
queue_browser=not browser_recent,
) )
cache[n.id] = now.isoformat() if result.get("skipped"):
sent.append(title) continue
if result.get(delivery_key):
sent.append(title)
else:
logger.warning("ping_notes: %s delivery failed for %s", channel, n.id)
except Exception as e: except Exception as e:
logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}") logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}")
# Dispatch owns delivery receipts. Reload before pruning so this
# scanner cannot replace a fresh channel receipt with a timestamp.
try:
cache = _json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
except Exception:
pass
# Prune cache entries for notes that no longer exist. # Prune cache entries for notes that no longer exist.
for stale in [k for k in cache if k not in seen_ids]: for stale in [k for k in cache if k not in seen_ids]:
cache.pop(stale, None) cache.pop(stale, None)
@@ -2429,7 +2452,7 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
logger.warning(f"ping_notes: cache write failed: {e}") logger.warning(f"ping_notes: cache write failed: {e}")
if not sent: if not sent:
raise TaskNoop(f"scanned {len(notes)} note(s), none due in ±{WINDOW_SEC}s") raise TaskNoop(f"scanned {len(notes)} note(s), no reminders delivered")
preview = "; ".join(sent[:3]) preview = "; ".join(sent[:3])
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else "" extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
return f"Pinged {len(sent)} note(s): {preview}{extra}", True return f"Pinged {len(sent)} note(s): {preview}{extra}", True
+53 -3
View File
@@ -96,6 +96,41 @@ _PUBLIC_PREVIEW_TOOL_ERRORS = {message: message for message in (
)} )}
def _schema_argument_hint(args, schema):
"""Describe a schema mismatch from the schema and the call's own arguments.
Rebuilt from a fresh validation rather than read off the caught exception,
so nothing the exception carries can reach the model.
"""
if not isinstance(schema, dict):
return ''
try:
validator = jsonschema.validators.validator_for(schema)(schema)
error = jsonschema.exceptions.best_match(validator.iter_errors(args))
except jsonschema.exceptions.SchemaError:
return ''
if error is None:
return ''
location = '.'.join(str(part) for part in error.absolute_path)[:80]
field = f"'{location}' " if location else ''
if error.validator == 'required' and isinstance(error.instance, dict):
missing = [str(name) for name in error.validator_value if name not in error.instance]
prefix = f'{location}.' if location else ''
hint = 'Missing required argument: ' + ', '.join(f"'{prefix}{name}'" for name in missing) + '.'
elif error.validator == 'type':
expected = error.validator_value
expected = ' or '.join(expected) if isinstance(expected, list) else str(expected)
hint = f'Argument {field}must be of type {expected}.'
elif error.validator == 'enum':
allowed = ', '.join(str(value) for value in error.validator_value[:12])
hint = f'Argument {field}must be one of: {allowed}.'
elif error.validator == 'additionalProperties':
hint = 'Remove arguments the offered tool schema does not define.'
else:
return ''
return f'{hint} Correct the call using the offered tool schema.'
def _public_preview_tool_error(exc, *, execution_attempted=False): def _public_preview_tool_error(exc, *, execution_attempted=False):
"""Keep useful domain guidance while withholding arbitrary diagnostics.""" """Keep useful domain guidance while withholding arbitrary diagnostics."""
if execution_attempted: if execution_attempted:
@@ -6782,6 +6817,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
result, desc, policy_denied, block = None, name, False, None result, desc, policy_denied, block = None, name, False, None
args = {} args = {}
execution_attempted = False execution_attempted = False
# Guidance a preview validator authored for this call. It
# is the only failure text returned verbatim; exception
# text goes through _public_preview_tool_error.
validator_error = ''
call_signature = None call_signature = None
semantic_scope = None semantic_scope = None
try: try:
@@ -6840,11 +6879,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
prior_outcome = browser_navigation_outcomes.get(requested_url) prior_outcome = browser_navigation_outcomes.get(requested_url)
if requested_url and prior_outcome and prior_outcome[1] >= 2: if requested_url and prior_outcome and prior_outcome[1] >= 2:
calls += 1 calls += 1
raise ValueError( validator_error = (
f'Opening {requested_url} twice reached the same page ' f'Opening {requested_url} twice reached the same page '
f'({prior_outcome[0]}). Do not repeat it; use the current ' f'({prior_outcome[0]}). Do not repeat it; use the current '
'page evidence or a different navigation strategy.' 'page evidence or a different navigation strategy.'
) )
raise ValueError(validator_error)
if tool_type == 'web_search': if tool_type == 'web_search':
web_search_attempts += 1 web_search_attempts += 1
if not native_workspace_enabled and web_search_attempts > 3: if not native_workspace_enabled and web_search_attempts > 3:
@@ -6938,6 +6978,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'This exact invalid call was repeated after two validation failures; ' 'This exact invalid call was repeated after two validation failures; '
'the tool is disabled for this turn.' 'the tool is disabled for this turn.'
) )
validator_error = semantic_error
raise ValueError(semantic_error) raise ValueError(semantic_error)
if canonical(name) == 'bash': if canonical(name) == 'bash':
command = str(args.get('command') or '') command = str(args.get('command') or '')
@@ -6946,6 +6987,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
calls += 1 calls += 1
suppressed_tool_until_round['bash'] = round_number + 1 suppressed_tool_until_round['bash'] = round_number + 1
round_recovery_messages.append(sensitive_error) round_recovery_messages.append(sensitive_error)
validator_error = sensitive_error
raise ValueError(sensitive_error) raise ValueError(sensitive_error)
misused_native_tool = shell_native_tool_command_misuse( misused_native_tool = shell_native_tool_command_misuse(
command, round_offered, command, round_offered,
@@ -6959,6 +7001,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'with its offered schema.' 'with its offered schema.'
) )
round_recovery_messages.append(recovery) round_recovery_messages.append(recovery)
validator_error = recovery
raise ValueError(recovery) raise ValueError(recovery)
semantic_scope = semantic_repeat_scope(name, args) semantic_scope = semantic_repeat_scope(name, args)
if ( if (
@@ -7015,10 +7058,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if canonical(name) in permanently_suppressed_tools: if canonical(name) in permanently_suppressed_tools:
calls += 1 calls += 1
terminal_suppression_violation = True terminal_suppression_violation = True
raise ValueError( validator_error = (
f'{name} was disabled after repeated identical calls; ' f'{name} was disabled after repeated identical calls; '
'no further execution was attempted.' 'no further execution was attempted.'
) )
raise ValueError(validator_error)
success_repeat_limit = ( success_repeat_limit = (
private_browser_success_repeat_limit(args) private_browser_success_repeat_limit(args)
if tool_type == 'private_browser' else 1 if tool_type == 'private_browser' else 1
@@ -7111,6 +7155,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
) )
if artifact_code_error: if artifact_code_error:
round_recovery_messages.append(artifact_code_error) round_recovery_messages.append(artifact_code_error)
validator_error = artifact_code_error
raise ValueError(artifact_code_error) raise ValueError(artifact_code_error)
decision = evaluate_preview_call( decision = evaluate_preview_call(
name, args, latest_user, name, args, latest_user,
@@ -7316,8 +7361,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
'Clean v3 tool call failed: %s', exc, exc_info=True, 'Clean v3 tool call failed: %s', exc, exc_info=True,
) )
if not validator_error and isinstance(exc, jsonschema.ValidationError) and schema:
validator_error = _schema_argument_hint(args, schema['function']['parameters'])
result = { result = {
'error': _public_preview_tool_error(exc, execution_attempted=execution_attempted), 'error': (
validator_error if validator_error and not execution_attempted
else _public_preview_tool_error(exc, execution_attempted=execution_attempted)
),
'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments', 'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments',
'exit_code': 1, 'exit_code': 1,
} }
+9 -5
View File
@@ -964,24 +964,28 @@ def _is_hardlinked_regular_file(resolved: str) -> bool:
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1 return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
def _is_denied_tool_path(resolved: str) -> bool: def _is_denied_tool_path(resolved: str, *, snapshot=None) -> bool:
"""Apply every path deny to a canonical traversal result.""" """Apply every path deny to a canonical traversal result.
Directory scans pass one ``_control_plane_snapshot()`` for the whole walk;
rebuilding it per entry made grep/ls/glob linear in protected-state stats.
"""
from src.agent_runtime.resources import _control_plane_path from src.agent_runtime.resources import _control_plane_path
return ( return (
_is_sensitive_path(resolved) _is_sensitive_path(resolved)
or _is_app_state_path(resolved) or _is_app_state_path(resolved)
or _is_hardlinked_regular_file(resolved) or _is_hardlinked_regular_file(resolved)
or _control_plane_path(resolved) or _control_plane_path(resolved, snapshot=snapshot)
) )
def _can_traverse_tool_path(resolved: str) -> bool: def _can_traverse_tool_path(resolved: str, *, snapshot=None) -> bool:
"""Allow walking a denied state parent only to reach safe carve-outs.""" """Allow walking a denied state parent only to reach safe carve-outs."""
if _is_sensitive_path(resolved): if _is_sensitive_path(resolved):
return False return False
if not _is_app_state_path(resolved): if not _is_app_state_path(resolved):
from src.agent_runtime.resources import _control_plane_path from src.agent_runtime.resources import _control_plane_path
return not _control_plane_path(resolved) return not _control_plane_path(resolved, snapshot=snapshot)
return any( return any(
_path_within(readable, resolved) _path_within(readable, resolved)
for readable in _agent_readable_data_subdirs() for readable in _agent_readable_data_subdirs()
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+1
View File
@@ -13,6 +13,7 @@
in email bodies — was wrapping random digits in <a href="tel:..."> with in email bodies — was wrapping random digits in <a href="tel:..."> with
browser-default styling that didn't match the Odysseus theme. --> browser-default styling that didn't match the Odysseus theme. -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}"> <script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now(); window._odysseusLoadTime = Date.now();
(function(){ (function(){
+1
View File
@@ -6,6 +6,7 @@
<title>Odysseus — Login</title> <title>Odysseus — Login</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="manifest" href="static/manifest.json"> <link rel="manifest" href="static/manifest.json">
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}"> <script nonce="{{CSP_NONCE}}">
(function(){ (function(){
function computeAppBasePath() { function computeAppBasePath() {
+6 -1
View File
@@ -7,5 +7,10 @@
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#282c34", "background_color": "#282c34",
"theme_color": "#282c34" "theme_color": "#282c34",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
} }
+5 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh. // - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached. // - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes. // Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v657-publication-plan-b'; const CACHE_NAME = 'odysseus-v658-publication-assets-restore';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them // KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting. // gives offline math fallback glyphs instead of proper typesetting.
@@ -134,6 +134,10 @@ const PRECACHE = [
'/static/lib/katex/katex.min.js', '/static/lib/katex/katex.min.js',
'/static/lib/katex/katex.min.css', '/static/lib/katex/katex.min.css',
...KATEX_FONTS, ...KATEX_FONTS,
'/static/manifest.json',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/static/icons/icon-maskable-512.png',
]; ];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design; // Lazily-imported panel modules (js/panels.js). Not in index.html by design;
+41 -7
View File
@@ -14,23 +14,57 @@ const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); }); page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.goto(origin + '/website/index.html'); await page.goto(origin + '/website/index.html');
assert.equal(await page.locator('video, .preview-panel, .sec-bg-tint').count(), 0); // Landing page contains expected preview panels, preview videos, and background video
assert.equal(await page.locator('.feature-description').count(), 8); assert.equal(await page.locator('.preview-panel').count(), 8);
for (const description of await page.locator('.feature-description .desc').allTextContents()) assert(description.trim()); assert.equal(await page.locator('section#previews video').count(), 7);
assert.equal(await page.locator('section#how video.sec-bg').count(), 1);
assert.equal(await page.locator('section#how .sec-bg-tint').count(), 1);
// Expected video source paths exist and return 200
const expectedVideos = [
'chat.webm',
'research.webm',
'compare.webm',
'document.webm',
'notes.webm',
'gallery.webm',
'theme.webm',
'bg.webm',
];
const videoSources = await page.locator('video source').evaluateAll(els => els.map(s => s.getAttribute('src')));
assert.deepEqual(videoSources.sort(), expectedVideos.slice().sort());
for (const src of expectedVideos) {
const videoRes = await page.request.get(origin + '/website/' + src);
assert.equal(videoRes.status(), 200, `Video source ${src} should return 200`);
}
// Feature and video layout does not overflow at desktop or mobile widths
for (const width of [1280, 390]) { for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
assert(await page.locator('.feature-description').first().isVisible()); assert(await page.locator('.preview-panel').first().isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
} }
assert.deepEqual(mediaRequests, []);
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
// Manifest has valid icon entries that return successfully
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json(); const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
assert.equal(manifest.display, 'standalone'); assert.equal(manifest.display, 'standalone');
assert(!manifest.icons); assert(Array.isArray(manifest.icons) && manifest.icons.length === 3);
for (const icon of manifest.icons) {
assert(icon.src, 'Manifest icon entry must have src');
const iconUrl = new URL(icon.src, origin + '/static/manifest.json').href;
const iconRes = await page.request.get(iconUrl);
assert.equal(iconRes.status(), 200, `Manifest icon ${icon.src} should return 200`);
}
// Apple touch icon references in app HTML point to real files
for (const filename of ['index.html', 'login.html']) { for (const filename of ['index.html', 'login.html']) {
const html = await (await page.request.get(origin + '/static/' + filename)).text(); const html = await (await page.request.get(origin + '/static/' + filename)).text();
assert(!/<link[^>]+rel=["']apple-touch-icon/.test(html)); const match = html.match(/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/);
assert(match, `missing apple-touch-icon in ${filename}`);
const iconUrl = new URL(match[1], origin + '/').href;
const res = await page.request.get(iconUrl);
assert.equal(res.status(), 200, `Apple touch icon in ${filename} should return 200`);
} }
// A small host DOM runs the actual export function and markdown renderer. // A small host DOM runs the actual export function and markdown renderer.
+38 -1
View File
@@ -176,6 +176,41 @@ def test_native_file_tools_hide_control_plane_hardlink_alias(tmp_path, monkeypat
assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"] assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"]
def test_native_file_tools_take_one_control_plane_snapshot_per_scan(tmp_path, monkeypatch):
"""The deny check must not rebuild the control-plane snapshot per entry.
Each rebuild stats every protected state file, so a per-entry rebuild made
a 5,000-file grep about seven times slower and timed out near 20k files.
"""
resources = importlib.import_module("src.agent_runtime.resources")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
for index in range(3):
directory = workspace / f"d{index}"
directory.mkdir(parents=True)
for item in range(20):
(directory / f"f{item}.txt").write_text("NEEDLE\n" if item == 0 else "x\n")
snapshots = []
original = resources._control_plane_snapshot
def counting_snapshot():
snapshots.append(1)
return original()
monkeypatch.setattr(resources, "_control_plane_snapshot", counting_snapshot)
for tool, args, expected in (
(LsTool(), {"path": str(workspace / "d0")}, "f19.txt"),
(GlobTool(), {"pattern": "**/*.txt", "path": str(workspace)}, "f19.txt"),
(GrepTool(), {"pattern": "NEEDLE", "path": str(workspace)}, "f0.txt:1:NEEDLE"),
):
snapshots.clear()
result = asyncio.run(tool.execute(json.dumps(args), {}))
assert expected in result.get("output", ""), result
# One fresh check resolves the search root; the walk shares one more.
assert len(snapshots) <= 2, (type(tool).__name__, len(snapshots))
def test_blocks_app_state_on_a_case_insensitive_filesystem(): def test_blocks_app_state_on_a_case_insensitive_filesystem():
"""On default macOS a case-variant path opens the same file, and realpath """On default macOS a case-variant path opens the same file, and realpath
does not canonicalise case there the way it does on Windows. does not canonicalise case there the way it does on Windows.
@@ -331,7 +366,9 @@ def test_allows_files_in_the_agent_workspace():
(PERSONAL_UPLOADS_DIR, (PERSONAL_UPLOADS_DIR,
"indexed into personal docs by routes/personal_routes.py, and listed as " "indexed into personal docs by routes/personal_routes.py, and listed as "
"an absolute path by manage_rag"), "an absolute path by manage_rag"),
]) # The directories live under a per-worker data dir, so they cannot be test ids:
# xdist requires every worker to collect the same names.
], ids=["uploads", "mail_attachments", "personal", "personal_uploads"])
def test_allows_user_content_the_app_hands_to_the_model(directory, why): def test_allows_user_content_the_app_hands_to_the_model(directory, why):
"""Carving these out is not convenience. The app gives the model these """Carving these out is not convenience. The app gives the model these
paths and tells it to read them, so denying them breaks the feature.""" paths and tells it to read them, so denying them breaks the feature."""
-98
View File
@@ -1,98 +0,0 @@
"""Invalid truncation requests must leave saved chat history intact."""
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from tests.helpers.database import isolated_session_database
@pytest.fixture
def history_client(tmp_path, monkeypatch):
from core.models import ChatMessage
from routes.history import history_routes
from routes import session_routes
with isolated_session_database(tmp_path) as (manager, database):
monkeypatch.setattr(history_routes, "SessionLocal", database.SessionLocal)
monkeypatch.setattr(session_routes, "SessionLocal", database.SessionLocal)
monkeypatch.setenv("AUTH_ENABLED", "true")
manager.create_session("truncate-test", "Test", "http://example.test", "test", owner="alice")
for text in ("first", "second", "third"):
manager.add_message("truncate-test", ChatMessage("user", text))
app = FastAPI()
app.state.auth_manager = SimpleNamespace(is_configured=True)
@app.middleware("http")
async def owner(request, call_next):
request.state.current_user = request.headers.get("X-Test-Owner", "alice")
return await call_next(request)
app.include_router(history_routes.setup_history_routes(manager))
with TestClient(app, raise_server_exceptions=False) as client:
yield client, manager, database
def saved_messages(database):
with database.SessionLocal() as db:
return [row.content for row in db.query(database.ChatMessage).filter(
database.ChatMessage.session_id == "truncate-test"
).order_by(database.ChatMessage.timestamp).all()]
@pytest.mark.parametrize("payload", [
{}, {"unrelated": 1}, {"before_msg_id": ""},
{"keep_count": None}, {"keep_count": False}, {"keep_count": True},
{"keep_count": 1.9}, {"keep_count": -1}, {"keep_count": "bad"},
{"keep_count": []}, {"keep_count": {}},
{"before_msg_id": True}, {"before_msg_id": ["message"]},
{"before_msg_id": False}, {"before_msg_id": 0}, {"message_id": 0},
[], None, "two", 2, False,
])
def test_invalid_target_preserves_history(history_client, payload):
client, manager, database = history_client
import json
response = client.post("/api/session/truncate-test/truncate", content=json.dumps(payload), headers={"Content-Type": "application/json"})
assert response.status_code == 400, response.text
assert saved_messages(database) == ["first", "second", "third"]
assert [m.content for m in manager.get_session("truncate-test").history] == ["first", "second", "third"]
def test_malformed_json_preserves_history(history_client):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", content="{", headers={"Content-Type": "application/json"})
assert response.status_code == 400, response.text
assert saved_messages(database) == ["first", "second", "third"]
@pytest.mark.parametrize("count,expected", [(0, []), (2, ["first", "second"]), (9, ["first", "second", "third"]), ("2", ["first", "second"])])
def test_explicit_count_remains_supported(history_client, count, expected):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", json={"keep_count": count})
assert response.status_code == 200, response.text
assert saved_messages(database) == expected
@pytest.mark.parametrize("field", ["before_msg_id", "message_id"])
def test_message_id_boundary_remains_supported(history_client, field):
client, _, database = history_client
with database.SessionLocal() as db:
message_id = db.query(database.ChatMessage).filter(database.ChatMessage.content == "second").one().id
response = client.post("/api/session/truncate-test/truncate", json={field: message_id})
assert response.status_code == 200, response.text
assert saved_messages(database) == ["first"]
def test_missing_message_id_does_not_clear_history(history_client):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", json={"before_msg_id": "missing"})
assert response.status_code == 404
assert saved_messages(database) == ["first", "second", "third"]
def test_owner_gate_still_precedes_validation(history_client):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", json={}, headers={"X-Test-Owner": "bob"})
assert response.status_code == 404
assert saved_messages(database) == ["first", "second", "third"]
+125
View File
@@ -0,0 +1,125 @@
"""Background note reminders retry failed primary delivery without losing receipts."""
import datetime
import json
from types import SimpleNamespace
import httpx
import pytest
from tests.helpers.database import disposable_database
@pytest.fixture
def reminder_scan(tmp_path, monkeypatch):
from core import database
from routes import note_routes
from src import builtin_actions, integrations, settings
instant = datetime.datetime(2026, 10, 7, 12, tzinfo=datetime.timezone.utc)
class Clock(datetime.datetime):
@classmethod
def now(cls, tz=None):
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
calls, notices = [], []
statuses = [503, 200]
channel = {"reminder_channel": "webhook", "reminder_webhook_integration_id": "audit", "reminder_webhook_payload_template": '{"message":"{{message}}"}'}
def deliver(request):
calls.append(request)
return httpx.Response(statuses.pop(0))
real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(deliver), **kw))
monkeypatch.setattr(settings, "load_settings", lambda: channel)
monkeypatch.setattr(integrations, "load_integrations", lambda: [{"id": "audit", "preset": "ntfy", "base_url": "https://reminder.example.test", "enabled": True}])
monkeypatch.setattr(note_routes, "_scheduler_ref", SimpleNamespace(add_notification=lambda **kw: notices.append(kw)))
monkeypatch.setattr(note_routes, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(builtin_actions, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(datetime, "datetime", Clock)
monkeypatch.setattr("src.url_safety.check_outbound_url", lambda *a, **kw: (True, ""))
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
with factory() as db:
db.add(database.Note(id="note-1", owner="alice", title="Due note", due_date=instant.isoformat()))
db.commit()
yield SimpleNamespace(
scan=lambda: builtin_actions.action_ping_notes("alice"),
path=tmp_path / "note_pings_alice.json", calls=calls, notices=notices,
statuses=statuses, channel=channel, instant=instant, factory=factory,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("channel", ["webhook", "ntfy"])
async def test_failed_primary_is_retried_and_receipt_keeps_channel(reminder_scan, channel):
from src.builtin_actions import TaskNoop
scan = reminder_scan
scan.channel["reminder_channel"] = channel
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.calls) == 1
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
message, ok = await scan.scan()
assert ok is True and "Pinged 1" in message
assert len(scan.calls) == 2
assert len(scan.notices) == 1
assert json.loads(scan.path.read_text())["note-1"]["channel"] == channel
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.calls) == 2
@pytest.mark.asyncio
async def test_legacy_browser_receipt_allows_external_retry(reminder_scan):
scan = reminder_scan
scan.path.write_text(json.dumps({"note-1": scan.instant.isoformat()}))
scan.statuses[:] = [200]
message, ok = await scan.scan()
assert ok is True and "Pinged 1" in message
assert len(scan.calls) == 1 and scan.notices == []
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "webhook"
@pytest.mark.asyncio
async def test_browser_delivery_is_deduped_and_typed(reminder_scan):
from src.builtin_actions import TaskNoop
scan = reminder_scan
scan.channel["reminder_channel"] = "browser"
_, ok = await scan.scan()
assert ok is True
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.notices) == 1 and scan.calls == []
@pytest.mark.asyncio
async def test_total_delivery_failure_does_not_checkpoint(reminder_scan, monkeypatch):
from routes import note_routes
from src.builtin_actions import TaskNoop
scan = reminder_scan
monkeypatch.setattr(note_routes, "_scheduler_ref", None)
with pytest.raises(TaskNoop):
await scan.scan()
assert "note-1" not in json.loads(scan.path.read_text())
_, ok = await scan.scan()
assert ok is True and len(scan.calls) == 2
@pytest.mark.asyncio
async def test_pruning_retains_dispatcher_receipt_and_other_due_note(reminder_scan):
from core import database
scan = reminder_scan
with scan.factory() as db:
db.add(database.Note(id="later", owner="alice", title="Later", due_date=(scan.instant + datetime.timedelta(days=1)).isoformat()))
db.commit()
scan.path.write_text(json.dumps({"gone": "old", "later": {"at": scan.instant.isoformat(), "channel": "email"}}))
scan.statuses[:] = [200]
_, ok = await scan.scan()
assert ok is True
cache = json.loads(scan.path.read_text())
assert set(cache) == {"note-1", "later"}
assert cache["note-1"]["channel"] == "webhook"
assert cache["later"]["channel"] == "email"
+45 -11
View File
@@ -30,23 +30,49 @@ def test_retained_artifacts_match_distributed_provenance_and_notices():
def test_removed_resource_ledger_has_no_runtime_references(): def test_removed_resource_ledger_has_no_runtime_references():
ledger = (ROOT / 'PUBLICATION_ASSET_DECISIONS.md').read_text() ledger = (ROOT / 'PUBLICATION_ASSET_DECISIONS.md').read_text()
removed = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M) san_entries = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
assert len(removed) == 21 assert len(san_entries) == 21
# Inspect tracked text, including files omitted by parity-audit exclusions.
restore_entries = re.findall(r'^- RESTORE-\d+: `([^`]+)` \(SHA-256: `([a-f0-9]{64})`\)', ledger, re.M)
assert len(restore_entries) == 14
restored_paths = {path for path, _ in restore_entries}
for path, expected_sha in restore_entries:
target = ROOT / path
assert target.is_file(), f"restored asset missing on disk: {path}"
actual_sha = hashlib.sha256(target.read_bytes()).hexdigest()
assert actual_sha == expected_sha, f"sha256 mismatch for {path}"
still_omitted = [path for path in san_entries if path not in restored_paths]
assert len(still_omitted) == 7
expected_omitted_names = {
'GohuFont.ttf',
'html2pdf.bundle.min.js',
'qrcode.min.js',
'ollama-mark.png',
'ollama-mark-crop.png',
'sglang-logo.png',
'sglang-mark.png',
}
assert {Path(p).name for p in still_omitted} == expected_omitted_names
# Inspect tracked text for references to still-omitted resources.
tracked = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0') tracked = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0')
for path in removed: for path in still_omitted:
assert not (ROOT / path).exists() assert not (ROOT / path).exists(), f"omitted asset must not exist: {path}"
filename = Path(path).name
for name in tracked: for name in tracked:
if name == "PUBLICATION_ASSET_DECISIONS.md": if name in {"PUBLICATION_ASSET_DECISIONS.md", "tests/test_publication_plan_b.py"}:
continue # Historical ledger entries are intentionally non-resource references. continue # Historical ledger and test assertions are intentionally non-resource references.
file = ROOT / name file = ROOT / name
if not file.is_file(): if not file.is_file():
continue continue
try: try:
content = file.read_text() content = file.read_text(encoding="utf-8")
except UnicodeError: except UnicodeError:
continue continue
assert Path(path).name not in content, (path, name) assert filename not in content, (path, name)
def test_cold_catalog_and_refresh_use_mutable_data(monkeypatch, tmp_path): def test_cold_catalog_and_refresh_use_mutable_data(monkeypatch, tmp_path):
@@ -113,12 +139,20 @@ def test_distribution_paths_include_all_notices():
ignore = (ROOT / '.dockerignore').read_text() ignore = (ROOT / '.dockerignore').read_text()
assert '!ACKNOWLEDGMENTS.md' in ignore assert '!ACKNOWLEDGMENTS.md' in ignore
manifest = json.loads((ROOT / 'static/manifest.json').read_text()) manifest = json.loads((ROOT / 'static/manifest.json').read_text())
assert not manifest.get('icons') assert manifest.get('icons')
assert manifest['display'] == 'standalone' assert manifest['display'] == 'standalone'
expected_icons = [
{"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
{"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
{"src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
]
assert manifest['icons'] == expected_icons
for icon in manifest['icons']:
assert (ROOT / 'static' / icon['src']).is_file()
def test_browser_publication_behaviors(): def test_browser_publication_behaviors():
"""Real Chromium DOM: text-only website, saved fonts, escaped print/math.""" """Real Chromium DOM: restored website media, saved fonts, escaped print/math."""
if not shutil.which('node'): if not shutil.which('node'):
pytest.skip('Node is required') pytest.skip('Node is required')
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=ROOT, capture_output=True) probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=ROOT, capture_output=True)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Regression guard for the README title presentation. """Regression guard for the README title presentation.
The original ASCII-art banner needed a code fence to preserve whitespace. The original ASCII-art banner needed a code fence to preserve whitespace.
Publication now opens with a centered text heading after omitting the wordmark Keep the wordmark title identity and the historical ASCII-fencing guard.
artwork. Keep the title identity and the historical ASCII-fencing guard.
""" """
from pathlib import Path from pathlib import Path
@@ -19,9 +18,9 @@ def _fenced_segments(text: str):
return parts[1::2] return parts[1::2]
def test_readme_opens_with_text_title(): def test_readme_opens_with_wordmark_title():
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15]) head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
assert head.startswith('<h1 align="center">Odysseus</h1>'), "README must open with the Odysseus text title" assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
def test_reintroduced_ascii_banner_stays_fenced(): def test_reintroduced_ascii_banner_stays_fenced():
+42
View File
@@ -222,3 +222,45 @@ def test_untrusted_validation_detail_cannot_masquerade_as_curated_guidance(detai
assert "TAKEOVER_SECRET" not in public assert "TAKEOVER_SECRET" not in public
assert "/srv/private" not in public assert "/srv/private" not in public
assert "PRIVATE_TOKEN" not in public assert "PRIVATE_TOKEN" not in public
@pytest.mark.asyncio
@pytest.mark.parametrize("tool, arguments, hint", [
# Authored by normalized_native_function_argument_error.
("pdf_extract", {"url": "Attention Is All You Need"},
"pdf_extract requires a public http(s) PDF URL"),
# Rebuilt from the schema, not from the caught ValidationError.
("manage_notes", {"action": "explode"},
"Argument 'action' must be one of: list, search, view"),
("python", {},
"Missing required argument: 'code'."),
])
async def test_preview_returns_validator_guidance_to_the_model(monkeypatch, tool, arguments, hint):
module = _preview_provider(monkeypatch, [
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call-1", "function": {
"name": tool, "arguments": json.dumps(arguments),
}}]}}]},
{"choices": [{"delta": {"content": "The requested call failed."}}]},
] * 8)
async def execute(*args, **kwargs):
raise AssertionError("an invalid call must not execute")
monkeypatch.setattr(module, "execute_tool_block", execute)
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == tool)
chunks = await _client_chunks(_preview_generator(module, [schema]), False)
tool_event = next(e for e in _events(chunks) if e.get("type") == "tool_output")
assert tool_event["error_category"] == "invalid_tool_arguments"
assert hint in tool_event["output"]
assert "could not be validated" not in tool_event["output"]
def test_schema_hint_ignores_the_caught_exception_text():
from src.clean_agent_preview import _schema_argument_hint
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == "manage_notes")
# A ValidationError raised for arguments that are actually valid yields no
# hint, so the caller falls back to the curated schema message.
assert _schema_argument_hint({"action": "list"}, schema["function"]["parameters"]) == ""
hint = _schema_argument_hint({"action": SENSITIVE}, schema["function"]["parameters"])
assert "must be one of" in hint
assert "TAKEOVER_SECRET" not in hint
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+195 -20
View File
@@ -273,12 +273,87 @@
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; } .shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
.shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; } .shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; }
/* Feature descriptions */ /* Previews: expanding hover carousel that plays a video on hover/tap */
.feature-descriptions { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 36px; } .previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
.feature-description { padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); } .preview-panel {
.feature-description .t { display: flex; align-items: center; gap: 8px; font-weight: 700; } position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
.feature-description .ico { color: var(--accent); } border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
.feature-description .desc { display: block; margin-top: 8px; color: var(--muted); line-height: 1.6; } background: linear-gradient(180deg, var(--panel), var(--panel2));
transition: flex-grow .5s cubic-bezier(.2,.7,.2,1), height .5s cubic-bezier(.2,.7,.2,1), border-color .25s ease;
}
.previews:hover .preview-panel { flex-grow: 0.55; height: 300px; }
.preview-panel:hover, .preview-panel:focus-visible, .preview-panel.is-active { flex-grow: 3.4 !important; height: 480px !important; border-color: var(--accent); }
.preview-panel .ph {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
color: var(--muted); font-size: 12.5px; opacity: 0.7; text-align: center; padding: 8px;
}
.preview-panel video {
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
z-index: 1; opacity: 0; transition: opacity .3s ease; background: transparent;
}
.preview-panel.has-video video { opacity: 1; }
/* These clips have their action on the left, so show the left edge instead of
the centered crop. */
.preview-panel:has(source[src="document.webm"]) video,
.preview-panel:has(source[src="notes.webm"]) video { object-position: right center; }
.preview-panel .label {
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; padding: 14px 16px;
background: linear-gradient(0deg, rgba(0,0,0,0.82), transparent);
color: var(--heading);
display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
}
.preview-panel .label .t { display: flex; align-items: center; gap: 8px; white-space: nowrap; font-weight: 700; font-size: 14px; }
.preview-panel .label .ico { color: var(--accent); flex-shrink: 0; }
.preview-panel .label .desc {
font-weight: 400; font-size: 12.5px; line-height: 1.35; color: rgba(255,255,255,0.82);
white-space: normal; max-height: 0; opacity: 0; overflow: hidden;
transition: max-height .4s ease, opacity .4s ease;
}
.preview-panel:hover .label .desc, .preview-panel:focus-visible .label .desc, .preview-panel.is-active .label .desc { max-height: 64px; opacity: 1; }
@media (max-width: 760px) {
.previews { flex-direction: column; height: auto; touch-action: pan-y; }
.preview-panel { height: 190px; flex: none; width: 100%; }
.preview-panel.is-active { height: 280px !important; }
.previews:hover .preview-panel, .preview-panel:hover { flex: none !important; }
.preview-panel .label .desc { max-height: 64px; opacity: 1; }
}
/* Fullscreen video background for a section: treated as an ambient, cinematic
backdrop (soft blur + slow drift) so it sets a mood without fighting the copy. */
.has-bg-video { position: relative; overflow: hidden; }
.has-bg-video .sec-bg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; z-index: 0; pointer-events: none;
/* blur softens the busy frame; the extra scale hides the blurred edges */
filter: blur(4px) saturate(1.08) brightness(0.92);
transform: scale(1.12);
transform-origin: 55% 45%;
animation: bg-drift 36s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes bg-drift {
from { transform: scale(1.12) translate(0, 0); }
to { transform: scale(1.2) translate(-2.5%, -1.5%); }
}
.has-bg-video .sec-bg-tint {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background:
radial-gradient(900px 520px at 78% 18%, rgba(224,108,117,0.16), transparent 60%),
radial-gradient(760px 520px at 8% 88%, rgba(53,90,102,0.30), transparent 58%),
linear-gradient(180deg, rgba(17,17,17,0.86), rgba(17,17,17,0.62) 42%, rgba(17,17,17,0.92)),
radial-gradient(1200px 680px at 50% 46%, rgba(17,17,17,0.18), rgba(17,17,17,0.74));
}
.has-bg-video .wrap { position: relative; z-index: 2; }
/* Lift the copy off the moving backdrop. */
.has-bg-video .eyebrow,
.has-bg-video .h { text-shadow: 0 2px 22px rgba(0,0,0,0.7); }
.has-bg-video .sub { color: #b9e6f4; text-shadow: 0 1px 14px rgba(0,0,0,0.75); }
.hero.has-bg-video h1, .hero.has-bg-video .wordmark,
.hero.has-bg-video .lede, .hero.has-bg-video .slogan { text-shadow: 0 2px 22px rgba(0,0,0,0.72); }
@media (prefers-reduced-motion: reduce) {
.has-bg-video .sec-bg { animation: none; transform: scale(1.12); }
}
/* Get started */ /* Get started */
.start { .start {
@@ -523,28 +598,62 @@
</div> </div>
</section> </section>
<!-- FEATURE DESCRIPTIONS --> <!-- PREVIEWS: hover/tap to expand + play -->
<section id="features-overview"> <section id="previews">
<div class="wrap"> <div class="wrap">
<div class="center"> <div class="center">
<h2 class="h">Explore the workspace</h2> <div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>See it in action</div>
<p class="sub center">Tools for local AI and everyday work.</p> <h2 class="h">Hover or tap to take a closer look</h2>
<p class="sub center">Each panel expands and plays its preview when you hover or tap it. Swipe on mobile to move through them.</p>
</div> </div>
<div class="feature-descriptions"> <div class="previews">
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat &amp; Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</span></div> <div class="preview-panel" tabindex="0">
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</span><span class="desc">Download, serve, and manage local models across your machines.</span></div> <div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>[ Chat &amp; Agents ]</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg>Deep Research</span><span class="desc">Ask once: it searches, reads sources, and writes back a cited report.</span></div> <video muted loop playsinline preload="none"><source src="chat.webm" type="video/webm"></video>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Compare</span><span class="desc">Send one prompt to many models at once and watch them answer side by side.</span></div> <div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat &amp; Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>Documents</span><span class="desc">A document editor that puts you first — work on what you want, with AI help when you want it.</span></div> </div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg>Notes &amp; Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</span></div> <div class="preview-panel" tabindex="0">
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg>Image Gallery</span><span class="desc">Generate, edit, remove backgrounds, and inpaint in your own gallery.</span></div> <div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg>Themes</span><span class="desc">Restyle and make it yours — edit your own, or ask the agent to make one.</span></div> <div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</span><span class="desc">Download, serve, and manage local models across your machines.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg><span>[ Deep Research ]</span></div>
<video muted loop playsinline preload="none"><source src="research.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg>Deep Research</span><span class="desc">Ask once: it searches, reads sources, and writes back a cited report.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg><span>[ Compare ]</span></div>
<video muted loop playsinline preload="none"><source src="compare.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Compare</span><span class="desc">Send one prompt to many models at once and watch them answer side by side.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg><span>[ Documents ]</span></div>
<video muted loop playsinline preload="none"><source src="document.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>Documents</span><span class="desc">A document editor that puts you first - work on what you want, with AI help when you want it.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg><span>[ Notes &amp; Tasks ]</span></div>
<video muted loop playsinline preload="none"><source src="notes.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg>Notes &amp; Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg><span>[ Image Gallery ]</span></div>
<video muted loop playsinline preload="none"><source src="gallery.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg>Image Gallery</span><span class="desc">Generate, edit, remove backgrounds, and inpaint in your own gallery.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg><span>[ Themes ]</span></div>
<video muted loop playsinline preload="none"><source src="theme.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg>Themes</span><span class="desc">Restyle and make it yours - edit your own, or ask the agent to make one.</span></div>
</div>
</div> </div>
</div> </div>
</section> </section>
<!-- HOW IT STARTED --> <!-- HOW IT STARTED -->
<section id="how"> <section id="how" class="has-bg-video">
<video class="sec-bg" autoplay muted loop playsinline preload="auto"><source src="bg.webm" type="video/webm"></video>
<div class="sec-bg-tint"></div>
<div class="wrap"> <div class="wrap">
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1z"/></svg>How it actually started</div> <div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1z"/></svg>How it actually started</div>
<h2 class="h">Uncompromised local LLM experience.</h2> <h2 class="h">Uncompromised local LLM experience.</h2>
@@ -681,6 +790,72 @@
} }
})(); })();
// Previews: hovering/tapping a panel expands it (CSS) and plays its video; the
// video only becomes visible once it actually starts playing, so missing
// files just leave the labeled placeholder.
(function () {
var panels = [].slice.call(document.querySelectorAll('.preview-panel'));
if (!panels.length) return;
var active = -1;
function playPanel(p) {
var v = p.querySelector('video');
if (!v) return;
var pr = v.play();
if (pr && pr.catch) pr.catch(function () {});
}
function pausePanel(p) {
var v = p.querySelector('video');
if (v) v.pause();
}
function setActive(i, shouldPlay) {
active = (i + panels.length) % panels.length;
panels.forEach(function (panel, k) {
var on = k === active;
panel.classList.toggle('is-active', on);
panel.setAttribute('aria-expanded', on ? 'true' : 'false');
if (!on) pausePanel(panel);
});
if (shouldPlay !== false) playPanel(panels[active]);
}
panels.forEach(function (p, i) {
var v = p.querySelector('video');
if (v) {
v.addEventListener('playing', function () { p.classList.add('has-video'); });
v.addEventListener('pause', function () { /* keep last frame */ });
}
p.setAttribute('aria-expanded', 'false');
p.addEventListener('mouseenter', function () { setActive(i); });
p.addEventListener('focus', function () { setActive(i); });
p.addEventListener('mouseleave', function () {
if (!window.matchMedia || !window.matchMedia('(hover: none)').matches) {
p.classList.remove('is-active');
p.setAttribute('aria-expanded', 'false');
pausePanel(p);
}
});
p.addEventListener('blur', function () { pausePanel(p); });
p.addEventListener('click', function () { setActive(i); });
});
var strip = document.querySelector('.previews');
var sx = null, sy = null;
if (strip) {
strip.addEventListener('touchstart', function (e) {
if (!e.touches.length) return;
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}, { passive: true });
strip.addEventListener('touchend', function (e) {
if (sx === null || sy === null || !e.changedTouches.length) return;
var dx = e.changedTouches[0].clientX - sx;
var dy = e.changedTouches[0].clientY - sy;
if (Math.abs(dx) > 42 && Math.abs(dx) > Math.abs(dy) * 1.25) {
setActive((active < 0 ? 0 : active) + (dx < 0 ? 1 : -1));
}
sx = sy = null;
}, { passive: true });
}
})();
// Domino reveal: fade/slide each section in as it scrolls into view. // Domino reveal: fade/slide each section in as it scrolls into view.
(function () { (function () {
var els = document.querySelectorAll('.hero, section'); var els = document.querySelectorAll('.hero, section');
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -762,7 +762,7 @@ src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) … services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end) static/ index.html + app.js + style.css + js/ (modular front-end)
website/ text-only landing page (index.html) website/ landing page (index.html) + preview clips
``` ```
## Data ## Data
Binary file not shown.