mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 10:52:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58773fe4ef | ||
|
|
a8c147b238 | ||
|
|
ec5bcd2b9a | ||
|
|
137daab16e | ||
|
|
41cace1337 | ||
|
|
10377f0c95 | ||
|
|
2c8e00ca05 |
@@ -147,8 +147,15 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt
|
||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||
# cache keeps each shard from re-downloading the whole requirement set.
|
||||
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
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'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
@@ -157,15 +164,25 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
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
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- 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'
|
||||
run: |
|
||||
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
|
||||
ffmpeg -version | head -n 1
|
||||
|
||||
@@ -174,8 +191,6 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends bubblewrap
|
||||
bwrap --version
|
||||
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
|
||||
kernel.apparmor_restrict_unprivileged_userns
|
||||
@@ -223,4 +238,4 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --shard "$PYTEST_SHARD"
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
@@ -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.
|
||||
Omission is not a finding of infringement and does not grant permission to
|
||||
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.
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -53,7 +59,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
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
@@ -27,8 +27,23 @@ echo " port: $PORT"
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
|
||||
# Use the macOS default application icon; no branding-derived artwork is bundled.
|
||||
echo " icon: macOS default"
|
||||
# ── Icon (best effort) -- center-crop the branding image to a square .icns ──
|
||||
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 "$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>CFBundlePackageType</key> <string>APPL</string>
|
||||
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key> <string>odysseus</string>
|
||||
<key>LSMinimumSystemVersion</key> <string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key> <true/>
|
||||
<key>LSUIElement</key> <false/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Optional dependencies — install only if you use the corresponding feature.
|
||||
# Local OCR for screenshots, scans, labels, and coordinate-grounded text extraction.
|
||||
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).
|
||||
#
|
||||
# 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
|
||||
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
||||
# the dependency-age discussion in issue #485.
|
||||
markitdown[docx,pptx,xlsx,xls]==0.1.6
|
||||
markitdown[docx,pptx,xlsx,xls]==0.1.8
|
||||
|
||||
# Photoshop PSD opening / flattened previews / layer inspection.
|
||||
psd-tools
|
||||
|
||||
+4
-4
@@ -3,9 +3,9 @@ uvicorn
|
||||
python-multipart
|
||||
python-dotenv
|
||||
httpx
|
||||
httpcore>=1.0,<2.0
|
||||
pydantic>=2.13.4
|
||||
pydantic-settings>=2.14.1
|
||||
httpcore>=1.0.9,<2.0
|
||||
pydantic>=2.13.5
|
||||
pydantic-settings>=2.15.0
|
||||
SQLAlchemy
|
||||
pypdf
|
||||
pypdfium2
|
||||
@@ -46,7 +46,7 @@ bcrypt
|
||||
# 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
|
||||
# servers are migrated together.
|
||||
mcp<2
|
||||
mcp<3
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
croniter
|
||||
|
||||
@@ -391,53 +391,26 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
async def truncate_session(request: Request, session_id: str):
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Request body must be valid JSON")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
# Resolve before_msg_id / message_id alias with explicit presence checks.
|
||||
# None (JSON null) and empty string are both treated as "not provided here"
|
||||
# and fall through to the alias, preserving the original OR-chain semantics.
|
||||
# Any other non-string value (False, 0, True, 123, list, …) is rejected
|
||||
# immediately so falsy non-strings cannot bypass type validation.
|
||||
raw_msg_id = body.get("before_msg_id") if "before_msg_id" in body else None
|
||||
if raw_msg_id is None or raw_msg_id == "":
|
||||
raw_msg_id = body.get("message_id")
|
||||
if raw_msg_id is not None and not isinstance(raw_msg_id, str):
|
||||
raise HTTPException(400, "Message ID must be a string")
|
||||
before_msg_id = (raw_msg_id or "").strip()
|
||||
if "keep_count" not in body and not before_msg_id:
|
||||
raise HTTPException(400, "keep_count or before_msg_id required")
|
||||
raw_count = body.get("keep_count", 0)
|
||||
# Keep integer-string clients working, but do not silently convert
|
||||
# booleans or fractional numbers into destructive message counts.
|
||||
if type(raw_count) not in (int, str):
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
try:
|
||||
keep_count = int(raw_count)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
if keep_count < 0:
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
body = await request.json()
|
||||
keep_count = int(body.get("keep_count", 0))
|
||||
before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip()
|
||||
deleted_sft_pairs: list[dict[str, str]] = []
|
||||
# keep_count is always >= 0 here; the block is unconditional.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
if before_msg_id:
|
||||
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
|
||||
if resolved_keep_count is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved_keep_count
|
||||
for pos, row in enumerate(all_db_messages):
|
||||
row._will_delete_for_sft = pos >= keep_count
|
||||
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
|
||||
finally:
|
||||
db.close()
|
||||
if keep_count >= 0:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
if before_msg_id:
|
||||
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
|
||||
if resolved_keep_count is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved_keep_count
|
||||
for pos, row in enumerate(all_db_messages):
|
||||
row._will_delete_for_sft = pos >= keep_count
|
||||
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
|
||||
finally:
|
||||
db.close()
|
||||
result = session_manager.truncate_messages(session_id, keep_count)
|
||||
_remove_deleted_sft_trace_rows(
|
||||
owner=effective_user(request),
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -81,7 +81,7 @@ Storage/secrets policy:
|
||||
- other static assets use cache-first with background refresh;
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -801,15 +801,18 @@ class LsTool:
|
||||
return {"error": f"ls: {e}", "exit_code": 1}
|
||||
|
||||
def _ls():
|
||||
from src.agent_runtime.resources import _control_plane_snapshot
|
||||
if not os.path.isdir(root):
|
||||
return None, f"ls: {root}: not a directory"
|
||||
rows = []
|
||||
snapshot = _control_plane_snapshot()
|
||||
try:
|
||||
with os.scandir(root) as it:
|
||||
for entry in it:
|
||||
if entry.name.startswith("."):
|
||||
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
|
||||
try:
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
@@ -864,10 +867,12 @@ class GlobTool:
|
||||
return {"error": f"glob: {e}", "exit_code": 1}
|
||||
|
||||
def _glob():
|
||||
from src.agent_runtime.resources import _control_plane_snapshot
|
||||
base = os.path.abspath(root)
|
||||
if not os.path.isdir(base):
|
||||
return None, f"glob: {root}: not a directory"
|
||||
rbase = os.path.realpath(base)
|
||||
snapshot = _control_plane_snapshot()
|
||||
norm_pat = pattern.replace("\\", "/")
|
||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||
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 —
|
||||
# otherwise glob would surface secret paths that read_file /
|
||||
# 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
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
@@ -894,7 +900,7 @@ class GlobTool:
|
||||
cap = _CODENAV_MAX_HITS * 5
|
||||
try:
|
||||
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[:] = []
|
||||
continue
|
||||
# Prune skipped dirs before descending (unlike rglob which
|
||||
@@ -905,7 +911,7 @@ class GlobTool:
|
||||
d for d in dns
|
||||
if d not in _CODENAV_SKIP_DIRS
|
||||
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:
|
||||
full = os.path.join(dp, name)
|
||||
@@ -913,7 +919,8 @@ class GlobTool:
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||
# 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
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
@@ -981,6 +988,7 @@ class GrepTool:
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from src.agent_runtime.resources import _control_plane_snapshot
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
rg = shutil.which("rg")
|
||||
@@ -992,6 +1000,7 @@ class GrepTool:
|
||||
if bound is not None:
|
||||
bound.validate()
|
||||
files = []
|
||||
snapshot = _control_plane_snapshot()
|
||||
|
||||
def check_deadline():
|
||||
if time.monotonic() >= deadline:
|
||||
@@ -1002,7 +1011,7 @@ class GrepTool:
|
||||
if os.path.islink(path):
|
||||
return
|
||||
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
|
||||
if bound is not None:
|
||||
try:
|
||||
@@ -1037,7 +1046,7 @@ class GrepTool:
|
||||
while pending_directories:
|
||||
check_deadline()
|
||||
directory = pending_directories.pop()
|
||||
if not _can_traverse_tool_path(directory):
|
||||
if not _can_traverse_tool_path(directory, snapshot=snapshot):
|
||||
continue
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
@@ -1052,8 +1061,8 @@ class GrepTool:
|
||||
raise ValueError("grep: directory identity changed during enumeration")
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if (entry.name not in _CODENAV_SKIP_DIRS
|
||||
and _can_traverse_tool_path(canonical)
|
||||
and (bound is None or _is_denied_tool_path(canonical)
|
||||
and _can_traverse_tool_path(canonical, snapshot=snapshot)
|
||||
and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
|
||||
or _visible_bound_resource(canonical))):
|
||||
pending_directories.append(canonical)
|
||||
else:
|
||||
|
||||
+32
-9
@@ -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]:
|
||||
"""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
|
||||
within the last 25 minutes. Mirrors `action_ping_events` for calendar.
|
||||
`due_date` falls in the current ±90-second window and hasn't been delivered
|
||||
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.
|
||||
"""
|
||||
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 pathlib import Path as _P
|
||||
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
|
||||
# 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)
|
||||
if not due:
|
||||
continue
|
||||
# Inside the ±5min window?
|
||||
# Inside the due window?
|
||||
if abs((due - now).total_seconds()) > window.total_seconds():
|
||||
continue
|
||||
# Recently pinged? Skip.
|
||||
last = cache.get(n.id)
|
||||
browser_recent = False
|
||||
if last:
|
||||
try:
|
||||
last_channel = None
|
||||
if isinstance(last, dict):
|
||||
last_channel = last.get("channel")
|
||||
last = last.get("at")
|
||||
last_dt = _dt.fromisoformat(str(last))
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
||||
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:
|
||||
pass
|
||||
# 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
|
||||
try:
|
||||
from routes.note_routes import dispatch_reminder
|
||||
await dispatch_reminder(
|
||||
result = await dispatch_reminder(
|
||||
title=title, note_body=body, note_id=n.id,
|
||||
owner=n.owner or owner or "",
|
||||
queue_browser=not browser_recent,
|
||||
)
|
||||
cache[n.id] = now.isoformat()
|
||||
sent.append(title)
|
||||
if result.get("skipped"):
|
||||
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:
|
||||
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.
|
||||
for stale in [k for k in cache if k not in seen_ids]:
|
||||
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}")
|
||||
|
||||
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])
|
||||
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
|
||||
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
|
||||
|
||||
@@ -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):
|
||||
"""Keep useful domain guidance while withholding arbitrary diagnostics."""
|
||||
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
|
||||
args = {}
|
||||
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
|
||||
semantic_scope = None
|
||||
try:
|
||||
@@ -6840,11 +6879,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
prior_outcome = browser_navigation_outcomes.get(requested_url)
|
||||
if requested_url and prior_outcome and prior_outcome[1] >= 2:
|
||||
calls += 1
|
||||
raise ValueError(
|
||||
validator_error = (
|
||||
f'Opening {requested_url} twice reached the same page '
|
||||
f'({prior_outcome[0]}). Do not repeat it; use the current '
|
||||
'page evidence or a different navigation strategy.'
|
||||
)
|
||||
raise ValueError(validator_error)
|
||||
if tool_type == 'web_search':
|
||||
web_search_attempts += 1
|
||||
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; '
|
||||
'the tool is disabled for this turn.'
|
||||
)
|
||||
validator_error = semantic_error
|
||||
raise ValueError(semantic_error)
|
||||
if canonical(name) == 'bash':
|
||||
command = str(args.get('command') or '')
|
||||
@@ -6946,6 +6987,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
calls += 1
|
||||
suppressed_tool_until_round['bash'] = round_number + 1
|
||||
round_recovery_messages.append(sensitive_error)
|
||||
validator_error = sensitive_error
|
||||
raise ValueError(sensitive_error)
|
||||
misused_native_tool = shell_native_tool_command_misuse(
|
||||
command, round_offered,
|
||||
@@ -6959,6 +7001,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
'with its offered schema.'
|
||||
)
|
||||
round_recovery_messages.append(recovery)
|
||||
validator_error = recovery
|
||||
raise ValueError(recovery)
|
||||
semantic_scope = semantic_repeat_scope(name, args)
|
||||
if (
|
||||
@@ -7015,10 +7058,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
if canonical(name) in permanently_suppressed_tools:
|
||||
calls += 1
|
||||
terminal_suppression_violation = True
|
||||
raise ValueError(
|
||||
validator_error = (
|
||||
f'{name} was disabled after repeated identical calls; '
|
||||
'no further execution was attempted.'
|
||||
)
|
||||
raise ValueError(validator_error)
|
||||
success_repeat_limit = (
|
||||
private_browser_success_repeat_limit(args)
|
||||
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:
|
||||
round_recovery_messages.append(artifact_code_error)
|
||||
validator_error = artifact_code_error
|
||||
raise ValueError(artifact_code_error)
|
||||
decision = evaluate_preview_call(
|
||||
name, args, latest_user,
|
||||
@@ -7316,8 +7361,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
logging.getLogger(__name__).warning(
|
||||
'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 = {
|
||||
'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',
|
||||
'exit_code': 1,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
def _is_denied_tool_path(resolved: str) -> bool:
|
||||
"""Apply every path deny to a canonical traversal result."""
|
||||
def _is_denied_tool_path(resolved: str, *, snapshot=None) -> bool:
|
||||
"""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
|
||||
return (
|
||||
_is_sensitive_path(resolved)
|
||||
or _is_app_state_path(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."""
|
||||
if _is_sensitive_path(resolved):
|
||||
return False
|
||||
if not _is_app_state_path(resolved):
|
||||
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(
|
||||
_path_within(readable, resolved)
|
||||
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 |
@@ -13,6 +13,7 @@
|
||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||
browser-default styling that didn't match the Odysseus theme. -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
(function(){
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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="manifest" href="static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
function computeAppBasePath() {
|
||||
|
||||
@@ -7,5 +7,10 @@
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"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
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// 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
|
||||
// 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.css',
|
||||
...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;
|
||||
|
||||
@@ -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('pageerror', e => errors.push(e.message));
|
||||
await page.goto(origin + '/website/index.html');
|
||||
assert.equal(await page.locator('video, .preview-panel, .sec-bg-tint').count(), 0);
|
||||
assert.equal(await page.locator('.feature-description').count(), 8);
|
||||
for (const description of await page.locator('.feature-description .desc').allTextContents()) assert(description.trim());
|
||||
// Landing page contains expected preview panels, preview videos, and background video
|
||||
assert.equal(await page.locator('.preview-panel').count(), 8);
|
||||
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]) {
|
||||
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.deepEqual(mediaRequests, []);
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
// Manifest has valid icon entries that return successfully
|
||||
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
|
||||
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']) {
|
||||
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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
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():
|
||||
"""On default macOS a case-variant path opens the same file, and realpath
|
||||
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,
|
||||
"indexed into personal docs by routes/personal_routes.py, and listed as "
|
||||
"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):
|
||||
"""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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -30,23 +30,49 @@ def test_retained_artifacts_match_distributed_provenance_and_notices():
|
||||
|
||||
def test_removed_resource_ledger_has_no_runtime_references():
|
||||
ledger = (ROOT / 'PUBLICATION_ASSET_DECISIONS.md').read_text()
|
||||
removed = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
|
||||
assert len(removed) == 21
|
||||
# Inspect tracked text, including files omitted by parity-audit exclusions.
|
||||
san_entries = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
|
||||
assert len(san_entries) == 21
|
||||
|
||||
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')
|
||||
for path in removed:
|
||||
assert not (ROOT / path).exists()
|
||||
for path in still_omitted:
|
||||
assert not (ROOT / path).exists(), f"omitted asset must not exist: {path}"
|
||||
filename = Path(path).name
|
||||
for name in tracked:
|
||||
if name == "PUBLICATION_ASSET_DECISIONS.md":
|
||||
continue # Historical ledger entries are intentionally non-resource references.
|
||||
if name in {"PUBLICATION_ASSET_DECISIONS.md", "tests/test_publication_plan_b.py"}:
|
||||
continue # Historical ledger and test assertions are intentionally non-resource references.
|
||||
file = ROOT / name
|
||||
if not file.is_file():
|
||||
continue
|
||||
try:
|
||||
content = file.read_text()
|
||||
content = file.read_text(encoding="utf-8")
|
||||
except UnicodeError:
|
||||
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):
|
||||
@@ -113,12 +139,20 @@ def test_distribution_paths_include_all_notices():
|
||||
ignore = (ROOT / '.dockerignore').read_text()
|
||||
assert '!ACKNOWLEDGMENTS.md' in ignore
|
||||
manifest = json.loads((ROOT / 'static/manifest.json').read_text())
|
||||
assert not manifest.get('icons')
|
||||
assert manifest.get('icons')
|
||||
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():
|
||||
"""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'):
|
||||
pytest.skip('Node is required')
|
||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=ROOT, capture_output=True)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Regression guard for the README title presentation.
|
||||
|
||||
The original ASCII-art banner needed a code fence to preserve whitespace.
|
||||
Publication now opens with a centered text heading after omitting the wordmark
|
||||
artwork. Keep the title identity and the historical ASCII-fencing guard.
|
||||
Keep the wordmark title identity and the historical ASCII-fencing guard.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,9 +18,9 @@ def _fenced_segments(text: str):
|
||||
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])
|
||||
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():
|
||||
|
||||
@@ -222,3 +222,45 @@ def test_untrusted_validation_detail_cannot_masquerade_as_curated_guidance(detai
|
||||
assert "TAKEOVER_SECRET" not in public
|
||||
assert "/srv/private" 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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+195
-20
@@ -273,12 +273,87 @@
|
||||
.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; }
|
||||
|
||||
/* Feature descriptions */
|
||||
.feature-descriptions { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 36px; }
|
||||
.feature-description { padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); }
|
||||
.feature-description .t { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.feature-description .ico { color: var(--accent); }
|
||||
.feature-description .desc { display: block; margin-top: 8px; color: var(--muted); line-height: 1.6; }
|
||||
/* Previews: expanding hover carousel that plays a video on hover/tap */
|
||||
.previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
|
||||
.preview-panel {
|
||||
position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
|
||||
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 */
|
||||
.start {
|
||||
@@ -523,28 +598,62 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURE DESCRIPTIONS -->
|
||||
<section id="features-overview">
|
||||
<!-- PREVIEWS: hover/tap to expand + play -->
|
||||
<section id="previews">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<h2 class="h">Explore the workspace</h2>
|
||||
<p class="sub center">Tools for local AI and everyday work.</p>
|
||||
<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>
|
||||
<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 class="feature-descriptions">
|
||||
<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 & 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="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="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>
|
||||
<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="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 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 & Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</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"><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="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="previews">
|
||||
<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="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 & Agents ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="chat.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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat & Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</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 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</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 & 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 & 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>
|
||||
</section>
|
||||
|
||||
<!-- 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="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>
|
||||
@@ -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.
|
||||
(function () {
|
||||
var els = document.querySelectorAll('.hero, section');
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -762,7 +762,7 @@ src/ llm_core, agent_loop, agent_tools, chat_processor, search/
|
||||
routes/ chat, session, document, memory, model … endpoints
|
||||
services/ docs, memory, search, hwfit (Cookbook) …
|
||||
static/ index.html + app.js + style.css + js/ (modular front-end)
|
||||
website/ text-only landing page (index.html)
|
||||
website/ landing page (index.html) + preview clips
|
||||
```
|
||||
|
||||
## Data
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user