mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 02:42:22 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f6f4f6e3 | ||
|
|
1cc56cb9d7 | ||
|
|
b5b76fe46d | ||
|
|
0e2c08300e | ||
|
|
afecd44ddf | ||
|
|
8feb50e894 | ||
|
|
a8c147b238 | ||
|
|
ec5bcd2b9a | ||
|
|
137daab16e | ||
|
|
41cace1337 | ||
|
|
10377f0c95 | ||
|
|
2c8e00ca05 |
@@ -10,6 +10,10 @@
|
||||
entrypoint.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
|
||||
# Maintained frontend modules and this lane's byte-identical deployment output.
|
||||
frontend/src/** text eol=lf
|
||||
static/js/actionMenuOrder.js text eol=lf
|
||||
|
||||
# Windows-native scripts stay CRLF.
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
@@ -81,6 +81,8 @@ jobs:
|
||||
python-version: "3.11"
|
||||
# Byte-compile sources — catches syntax errors without installing deps.
|
||||
- run: python -m compileall -q app.py core routes src services scripts tests
|
||||
- name: Check committed frontend deployment modules
|
||||
run: python scripts/build_frontend.py --check
|
||||
|
||||
node-syntax:
|
||||
name: JS syntax (node --check)
|
||||
@@ -92,13 +94,19 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- name: Check frontend architecture
|
||||
run: node scripts/check_frontend.mjs
|
||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
||||
- name: node --check
|
||||
run: |
|
||||
shopt -s globstar nullglob
|
||||
for f in static/app.js static/js/**/*.js; do
|
||||
for f in static/app.js static/js/**/*.js frontend/src/**/*.js; do
|
||||
node --check "$f"
|
||||
done
|
||||
node --check scripts/check_frontend.mjs
|
||||
- name: Test frontend architecture gates and stateless proof
|
||||
run: node --test tests/frontendArchitecture.test.mjs
|
||||
|
||||
python-tests:
|
||||
name: Python tests (pytest ${{ matrix.shard }})
|
||||
@@ -147,8 +155,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 +172,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 +199,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 +246,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/>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ADR 001: Feature-first native ESM with controlled deployment output
|
||||
|
||||
Date: 2026-10-13. Status: implemented F1 proof, proposed for maintainer adoption.
|
||||
Baseline: official `upstream/dev` at `8feb50e89468235cd3e0986da80edbd3acaf2c40`.
|
||||
Scope: source ownership and distribution foundation. Notes implementation is deferred.
|
||||
|
||||
## Decision
|
||||
|
||||
Maintain migrated JavaScript in `frontend/src/`. Publish native ES modules to
|
||||
explicitly registered paths in `static/js/`, and commit source and output together.
|
||||
Use `python scripts/build_frontend.py` to copy bytes without rewriting imports,
|
||||
bundling, minification, source maps, timestamps or generated headers. A checked-in
|
||||
`frontend/modules.json` maps source paths to deployment paths. All other static
|
||||
files remain authored legacy modules or deployment assets until a reviewed slice
|
||||
transfers their ownership. Do not claim that the whole static tree is generated.
|
||||
|
||||
The proof transfers maintenance of the stateless action-menu ordering helper to
|
||||
`frontend/src/shared/actionMenuOrder.js`. Its output remains
|
||||
`/static/js/actionMenuOrder.js`, with the same three exports and identical bytes.
|
||||
All seven current consumers, HTML, service worker and runtime entry points retain
|
||||
their existing URLs. There is no wrapper, extra request or second browser module
|
||||
instance. The source copy is a development input, not a second runtime entry point.
|
||||
|
||||
Use `frontend/src/app/` for composition, `features/<feature>/index.js` for feature
|
||||
public APIs, and responsibility directories only when implementation requires
|
||||
them. The [handbook](handbook.md) defines dependency, ownership and lifecycle rules.
|
||||
F1 creates only the shared proof, without empty application or feature scaffolding.
|
||||
|
||||
## Evidence and alternatives
|
||||
|
||||
Both options were executed against this helper, rather than assumed to work.
|
||||
Measurements are local compatibility observations, not general performance claims.
|
||||
|
||||
| Property | Controlled native ESM copy, selected | esbuild 0.25.12 controlled ESM build |
|
||||
| --- | --- | --- |
|
||||
| Proof output | 1,688 bytes, identical SHA-256 to existing runtime | 1,554 bytes with `--bundle --format=esm --target=es2022 --platform=browser` |
|
||||
| Repeatability | Clean temporary publication and idempotent rerun tested | Two independent compiler invocations emitted identical bytes |
|
||||
| Runtime contract | Existing exports, imports, cache entry and module identity unchanged | Export names, icon and ordering matched in an executed Node import |
|
||||
| Tooling | Python standard library; no production dependency | Compiler and platform binary needed for artifact production |
|
||||
| Local cost | Copy check: 0.03 s, 15,596 KB maximum RSS | Cached `npx` ESM transform: 0.58 s, 114,196 KB maximum RSS |
|
||||
| Incremental migration | Explicit file ownership, preserved module boundaries | Feasible for the stateless leaf; stateful facade/chunk identity still needs a separate graph spike |
|
||||
| Release implications | Commit output; install/startup paths consume existing static assets | Would also need committed or release-produced output plus compiler locking and facade/chunk manifest policy |
|
||||
|
||||
The timing rows measure different tool paths, including `npx` overhead. The
|
||||
compiler's single-leaf success does not establish stateful graph or lazy chunk
|
||||
compatibility. The 134-byte bundle reduction does not justify that additional
|
||||
contract in F1. A future compiler adoption requires a new ADR and graph-level
|
||||
evidence. React, Vue, TypeScript, a global state library, hashed chunks and a
|
||||
development server have no demonstrated necessity here.
|
||||
|
||||
Acorn 8.19.0 is added only as an exact, lockfile-pinned development dependency for
|
||||
architecture checks. A real JavaScript parser is needed to enforce boundaries
|
||||
across multiline imports, re-exports and lazy thunks without treating comments
|
||||
and string literals as executable imports. It is MIT licensed, about 568 KB
|
||||
unpacked, and contributes no deployed JavaScript or runtime dependency.
|
||||
|
||||
## Distribution contract
|
||||
|
||||
1. Source and generated output are reviewed and committed together. CI checks
|
||||
byte parity; it does not silently rebuild and accept drift.
|
||||
2. Native Python startup continues to use `STATIC_DIR` and `/static`, without
|
||||
invoking the publisher. Native installers already obtain tracked static files.
|
||||
3. Docker's current `COPY . .` includes the output. `.dockerignore` excludes
|
||||
`node_modules/`. The Docker runtime and entrypoint require no frontend build.
|
||||
4. `Odysseus.spec` and `build-windows-portable.ps1` already include `static/`.
|
||||
The macOS wrapper uses the Python checkout and its static assets. Neither
|
||||
needs the source tree or frontend tooling to serve the proof.
|
||||
5. A release producer runs the checks before packaging and distributes the
|
||||
entire matching static graph. The publisher is not a transactional deployment
|
||||
mechanism. Partial copying of a new graph into a live deployment is unsupported.
|
||||
6. New output paths must declare LF checkout behavior in `.gitattributes`, as
|
||||
this proof does, so Windows checkout conversion cannot create false byte drift.
|
||||
Before checking parity or writing output, the publisher uses the manifest to
|
||||
require effective `text: set` and `eol: lf` on every source/output path. It asks
|
||||
Git in a temporary repository containing only the applicable repository-owned
|
||||
`.gitattributes` files, including nested rules. Global/local Git overrides
|
||||
cannot supply missing declarations. No blanket legacy-JS rule is introduced.
|
||||
Git is a development/publication prerequisite, not a startup dependency.
|
||||
|
||||
An isolated distribution probe executes the real FastAPI lifespan, shell and
|
||||
static requests after omitting `frontend/` and `node_modules/` and removing Node
|
||||
from the child process search path. It verifies bytes, MIME, CSP, revalidation,
|
||||
source-tree 404 behavior and static path confinement. The same probe runs in an existing Docker image
|
||||
with networking disabled. This is not a claim that a fresh full Docker image or
|
||||
Windows/macOS executable was built; see [validation](validation.md).
|
||||
|
||||
## Imports, identity and offline operation
|
||||
|
||||
New maintained modules use one unversioned deployment URL. Existing legacy URLs,
|
||||
including their version queries, stay unchanged. A copy mapping must preserve
|
||||
the resolution of every literal relative import; the architecture checker rejects
|
||||
a mapping that changes that resolution. Canonical absolute `/static/js/...`
|
||||
imports are also supported. No bare packages or browser imports of `frontend/src`
|
||||
are supported. Existing graph and identity guards continue to cover the runtime.
|
||||
|
||||
Each registered output joins the exact `PRECACHE` or `PANEL_PRECACHE` array. The
|
||||
new checker parses those arrays, rather than accepting a URL mentioned in a
|
||||
comment. Installation fetches do not evaluate lazy modules. Import-time DOM work,
|
||||
timers, state creation and network I/O must be characterized before changing an
|
||||
entry point's load timing. New source dependency cycles are rejected; existing
|
||||
legacy cycles are inventoried and remain outside the new rules.
|
||||
|
||||
The proof already has its exact precache entry, so no SW cache-name or HTML edit
|
||||
is needed. Its first evaluation in a fresh offline browser page passes, and
|
||||
importing the panel registry does not evaluate the editor. The current worker's
|
||||
`/static/` scope cannot control the SPA root. Open PR #6227 addresses that existing
|
||||
limitation; F1 does not fix or mask it. Full root offline reload is not certified.
|
||||
|
||||
## Security and compatibility
|
||||
|
||||
Keep backend authorization and owner scope authoritative. No agent, database,
|
||||
auth, CSP, static mount or API implementation changes belong to this decision.
|
||||
The helper remains same-origin ESM without eval, compiler runtime or external
|
||||
resources. No new source maps or vendored assets are emitted; existing SRI/CSP
|
||||
policy and distribution notices remain unchanged. The publisher validates path
|
||||
containment, symlinks, duplicate ownership and unmapped source before writing.
|
||||
It never deletes unowned static assets. Deleting or retiring outputs is an
|
||||
explicit reviewed migration operation, not automatic cleanup.
|
||||
|
||||
## Rollout, rollback and limits
|
||||
|
||||
Adopt F1 for controlled, reviewed slices after maintainer review. Leave Notes,
|
||||
bootstrap, HTML and service worker unchanged here. Register new files explicitly;
|
||||
update offline manifests only when new runtime dependencies are introduced.
|
||||
F1 rollback can remove the source manifest, proof tooling, gates and documentation
|
||||
without changing runtime bytes. Later rollback restores the complete prior
|
||||
source/output/facade/precache set through an authorized follow-up change.
|
||||
|
||||
Boundary gates cannot prove semantic purity, owner isolation or correct teardown.
|
||||
Domain platform/clock checks are intentionally conservative syntax guards;
|
||||
behavior tests still establish those contracts. Worker URLs, styles and vendored
|
||||
libraries are outside the maintained-JS import gate and need explicit review if
|
||||
a future lane changes them. Legacy exceptions require an exact specifier and a
|
||||
module-scoped reason; there are none in F1. New source is not permission to
|
||||
grandfather new dependency cycles or to replicate broadly shared mutable state.
|
||||
@@ -0,0 +1,127 @@
|
||||
# F1 runtime and architecture assessment
|
||||
|
||||
Assessment date: 2026-10-13. Repository: `odysseus-dev/odysseus`.
|
||||
Worktree: `worktrees/architecture/frontend-foundation`.
|
||||
Branch: `refactor/frontend-architecture-foundation`.
|
||||
HEAD, local `upstream/dev`, and the live GitHub official `dev` commit were all
|
||||
`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The initial worktree was clean.
|
||||
That commit is the merged PR #6495, selected Notes/Todos agent context.
|
||||
`origin` points to the maintainer fork; integration targets official `upstream/dev`.
|
||||
|
||||
Inputs read completely: the 2026-10-13 frontend architecture RFC and coding brief
|
||||
from Downloads, `specs/frontend.md`, repository instructions and existing tests.
|
||||
The current user brief supersedes the coding brief's `origin/dev` wording and
|
||||
defines the Notes state/workflow slice before reminders. The design is self-contained
|
||||
in this directory and does not require access to Downloads.
|
||||
|
||||
Input provenance:
|
||||
|
||||
- RFC SHA-256: `11dedcbb9971f4d787f2721b9c27aa43299baba39ca91066d01507d2d69ac4f8`.
|
||||
- Coding brief SHA-256: `e4882867617b9c732ce4f445e3bc2da0b4ec1315119f8226c1fd8c1acbacf29d`.
|
||||
|
||||
## Module graph and bootstrap
|
||||
|
||||
`node scripts/check_frontend.mjs --inventory` reproduces the parser-based inventory.
|
||||
It excludes vendored `static/lib/` and committed editor build output. At this base
|
||||
it scans 230 JS files, finds 707 static import/re-export edges and 108 literal
|
||||
dynamic import edges, and finds no missing targets. No computed dynamic imports
|
||||
were found in the original parser audit. The earlier existing-regex scan counted
|
||||
814 edges; executable syntax parsing counts 815. Use parser evidence for design
|
||||
and retain the existing regression guard unchanged.
|
||||
|
||||
| Area | Current boundary and implication |
|
||||
| --- | --- |
|
||||
| HTML shell | `static/index.html`: nonce-bearing boot scripts, CSS/preloads, direct ESM script tags, then app/init/a11y. Multiple entry roots mean module identity matters. |
|
||||
| App composition | `static/app.js`: feature imports, globals, route/sidebar composition, session hydration and auth-fetch wiring. It eagerly imports Notes, Calendar, Chat, Documents and Settings. |
|
||||
| Startup | `static/js/startupShell.js`, `sessions.js`, `init.js`: first-paint scheduling, deferred hydration, user-switch storage cleanup and cosmetic permission gates. Preserve boot ordering. |
|
||||
| Lazy loading | `static/js/panels.js`: only `editor` is registered. A memoized import thunk shares successful/in-flight promises and retries failed imports. Do not label all panels lazy. |
|
||||
| Optional libraries | `markdown.js` lazily loads vendored KaTeX/Mermaid. KaTeX and fonts are precached; Mermaid becomes cached after first use. Pyodide has a CDN dependency. |
|
||||
| Feature packages | Settings registry/navigation/finder/lifecycle, Calendar utilities/reminders, Email wrapper/package and editor tools already demonstrate partial decomposition. |
|
||||
|
||||
Highest import fan-in: `ui.js` 77, `spinner.js` 55, `editor/state.js` 39,
|
||||
`escMenuStack.js` 23, and storage/modal/z-order helpers 17 each. The measured
|
||||
static strongly connected components are:
|
||||
|
||||
- Theme and UI.
|
||||
- Email Inbox, compatibility wrapper, package index, AI reply, attachments,
|
||||
body rendering, menus, reader, settings page and unsubscribe.
|
||||
- Cookbook, diagnosis, HW Fit, download, running and serve modules.
|
||||
|
||||
These are existing compatibility constraints, not approved patterns for new
|
||||
source. Dynamic edges connect more features but do not themselves establish
|
||||
initialization cycles. No global legacy dependency rewrite is attempted in F1.
|
||||
|
||||
## State, side effects and ownership
|
||||
|
||||
| Owner | Evidence and architectural treatment |
|
||||
| --- | --- |
|
||||
| Shared configuration | `appConfig.js` owns invalidatable settings/tool request promises and consumes a sessionStorage prefetch once. Retain single identity and writer invalidation. |
|
||||
| Browser persistence | `storage.js` centralizes keys and safe access; feature files also use direct localStorage. `init.js` cleans storage on user changes. Preferences are not an authorization store. |
|
||||
| Feature state | Notes has module variables, selection sets, undo/drafts and reminder state. Email exports a broadly mutable package state object; editor state is imported by 39 modules. Keep ownership explicit; do not expand these into a global store. |
|
||||
| Import-time effects | Notes reads storage/window and schedules reminders. Email state reads localStorage. Theme/UI initialize from DOM readiness; modal manager installs global listeners. Chip/mobile helpers bind at startup. Moving an import can change behavior before any exported function is called. |
|
||||
| Compatibility globals | App exposes controllers on `window`; Notes exposes `window.notesModule`. They are compatibility bridges, not the new inter-feature API convention. |
|
||||
| Async ownership | Session hydration, config invalidation, draft serialized saves and request-generation guards already prevent stale completion. New slices must preserve these rather than only move declarations. |
|
||||
|
||||
Good patterns to retain: narrow zero-state leaves, explicit wrapper export lists,
|
||||
public API tests, retryable memoized lazy imports, shared config invalidation,
|
||||
request-generation checks and source/package-aware regression helpers.
|
||||
|
||||
Patterns to avoid: blanket barrels exposing internals, feature imports from
|
||||
generic helpers, cycles through coordinators, direct writes to another feature's
|
||||
state or DOM, anonymous global listener ownership, import-triggered network/timer
|
||||
initialization in new APIs, and helpers grouped by convenience instead of purpose.
|
||||
|
||||
## Runtime, assets and distribution
|
||||
|
||||
| Contract | Verified source |
|
||||
| --- | --- |
|
||||
| Static URL and revalidation | `app.py`: `register_static_mime_types`, `_RevalidatingStatic`, `/static` mount. JS MIME is `text/javascript`; JS/CSS/HTML have `Cache-Control: no-cache`. |
|
||||
| App paths | `src/constants.py` and `src/runtime_paths.py`: checkout root in native mode, PyInstaller `_MEIPASS` in frozen mode; static assets follow app root, persisted data follows data-directory policy. |
|
||||
| CSP | `core/middleware.py`: same-origin scripts, nonce and jsDelivr allowance; `src/app_helpers.py` injects HTML nonces. No CSP relaxation is needed for the proof. |
|
||||
| Integrity | No `integrity=` attributes were found in the shell. Proof output is byte-identical and emits no source maps; no existing SRI or vendored artifacts change. |
|
||||
| Runtime-generated assets | `/api/generated-image/...` uses a confined data path and owner handling; report/render endpoints, Blob manifests/icons and uploads are runtime concerns outside static source publication. |
|
||||
| Docker | `Dockerfile` copies app code and existing static output; `.dockerignore` excludes node_modules. Node/npm in the image support Browser MCP, not frontend startup. |
|
||||
| Native installers | `setup.py`, `start-macos.sh`, `launch-windows.ps1`, systemd service: Python dependencies/startup consume the checkout. No frontend build is invoked. |
|
||||
| Windows desktop | `Odysseus.spec` and `build-windows-portable.ps1` include the whole static directory. Maintain output here rather than require a new bundle path or compiler. |
|
||||
| macOS | `build-macos-app.sh` creates a launcher around the Python checkout. Static serving follows the existing checkout path. Installer execution was not performed on Linux. |
|
||||
|
||||
## PWA limitations and test coverage
|
||||
|
||||
`static/sw.js` matches exact URLs, including queries. Install fetches each
|
||||
precache entry separately and tolerates failed fetches. It does not discover
|
||||
transitive dependencies. HTML root and JS/CSS handlers are network-first;
|
||||
other static assets are cache-first with background refresh. API and non-GET
|
||||
requests bypass caching. Cache updates need explicit version/manifest review.
|
||||
The older frontend spec's root stale-while-revalidate description is stale.
|
||||
|
||||
Registration in `static/index.html` currently requests no wider scope, so the
|
||||
worker controls `/static/`, not `/`. Root offline reload is therefore a known
|
||||
pre-existing gap. PR #6227 proposes the scope/header correction. F1 tests actual
|
||||
offline ESM loading under the current scope and leaves that separate fix alone.
|
||||
Offline APIs, first-use Mermaid and optional CDN runtimes are not certified.
|
||||
|
||||
Existing coverage includes runtime graph resolution and version parity; editor
|
||||
offline graph coverage and lazy-loader retries; Email API/export/evaluation
|
||||
coverage; Notes autosave, XSS, selection/listener and agent-context regressions;
|
||||
MIME, nonce and runtime paths. Tests use pytest, Node's built-in runner and existing
|
||||
Playwright Chromium. `tests/TESTING_STANDARD.md` requires behavior-first, isolated
|
||||
tests. F1 adds real publication failures, architecture violations and an offline
|
||||
browser proof. It does not replace existing source-sensitive tests.
|
||||
|
||||
## Open PR overlap, checked live on 2026-10-13
|
||||
|
||||
The open-PR listing was fetched through all pages; selected PR file lists were
|
||||
inspected. Refresh this inventory before any later slice.
|
||||
|
||||
| PR | Overlap and disposition |
|
||||
| --- | --- |
|
||||
| [#6470](https://github.com/odysseus-dev/odysseus/pull/6470) | Theme/sidebar redesign touches HTML, SW, shared UI and CSS. F1 changes none of those assets. Future manifest/UI slices need collision review. |
|
||||
| [#6695](https://github.com/odysseus-dev/odysseus/pull/6695) | OXS touches bootstrap, HTML, platform, middleware and other backend boundaries. F1 does not establish an extension API or modify that platform. |
|
||||
| [#6227](https://github.com/odysseus-dev/odysseus/pull/6227) | Root SW scope/header fix. Independent follow-up dependency for broader offline certification. |
|
||||
| [#6394](https://github.com/odysseus-dev/odysseus/pull/6394) | Older CSS extraction touches legacy style/SW assets; requires separate supersession review. |
|
||||
| [#6627](https://github.com/odysseus-dev/odysseus/pull/6627) | npm development-dependency update overlaps package/lock files; reconcile the exact Acorn development entry on integration. |
|
||||
| [#6585](https://github.com/odysseus-dev/odysseus/pull/6585), [#6647](https://github.com/odysseus-dev/odysseus/pull/6647), [#6652](https://github.com/odysseus-dev/odysseus/pull/6652) | Notes optional-field persistence and agent routing/dispatch. Future characterization must reflect their merged state; F1 changes no Notes or backend behavior. |
|
||||
| [#6594](https://github.com/odysseus-dev/odysseus/pull/6594), [#6152](https://github.com/odysseus-dev/odysseus/pull/6152) | Desktop launcher/build and frozen-runtime packaging work. Existing paths stay stable; packaging still needs platform-owner validation. |
|
||||
|
||||
PR titles alone do not establish compatibility. This report records the checked
|
||||
file overlap and relevant dependencies, not approval of those implementations.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Odysseus frontend architecture handbook
|
||||
|
||||
Status: F1 foundation and continuation guide, pending review. The final Notes
|
||||
reference case study belongs to the comprehensive Notes draft PR; the recipient
|
||||
handoff follows its integration. This document must evolve with reviewed internal
|
||||
checkpoints and distinguish executed work from proposals.
|
||||
|
||||
Read the [ADR](ADR-001-source-and-deployment.md), [assessment](assessment.md),
|
||||
[validation report](validation.md) and [Notes roadmap](notes-roadmap.md).
|
||||
The frontend program ends with the Notes reference decomposition and handoff.
|
||||
Calendar, Documents, Email, Chat, Skills, Settings and other later decompositions
|
||||
belong to the designated frontend maintainer; do not start them automatically.
|
||||
|
||||
## Source, ownership and public APIs
|
||||
|
||||
`frontend/src/app/` composes public feature APIs and platform ports. Each
|
||||
`features/<feature>/index.js` is an intentional API, with explicit named exports
|
||||
and a compatibility default only when existing consumers require one. Internals
|
||||
live under `domain/`, `application/`, `infrastructure/` or `ui/` as needed. Do not
|
||||
create empty directories, boilerplate controllers or a framework by convention.
|
||||
Feature names `app` and `shared` are reserved.
|
||||
|
||||
Keep pure business rules in domain; transitions and workflows in application;
|
||||
HTTP/storage/browser services in infrastructure; DOM rendering and lifecycle in
|
||||
UI. The index composes adapters, state and views. Application receives adapters
|
||||
as explicit arguments or constructor ports, rather than importing concrete
|
||||
infrastructure. Views call application operations; infrastructure does not own
|
||||
business decisions. A public API returns data/operations, not mutable stores or
|
||||
private DOM nodes. Importing an API should define capabilities; explicit lifecycle
|
||||
methods activate them. Legacy side effects remain characterized until a reviewed
|
||||
slice safely changes their placement.
|
||||
|
||||
Shared code has a named, proven cross-feature responsibility. `shared/domain/`
|
||||
holds pure primitives, `shared/platform/` holds narrowly scoped platform adapters,
|
||||
and `shared/ui/` holds common DOM capabilities. A named stateless root leaf such
|
||||
as `shared/actionMenuOrder.js` is also allowed and receives domain restrictions.
|
||||
Do not create a central `utils.js` collection or move feature business rules into
|
||||
shared simply to satisfy an import rule.
|
||||
|
||||
## Mechanically checked dependency contract
|
||||
|
||||
| Importer | Permitted maintained-source targets |
|
||||
| --- | --- |
|
||||
| App composition | Other app code, shared code, feature `index.js` APIs |
|
||||
| Feature index | Its own responsibility modules and shared code; other feature public indexes |
|
||||
| Feature domain | Its own domain and shared domain/stateless leaves |
|
||||
| Feature application | Its own application/domain, shared domain, other feature public indexes |
|
||||
| Feature infrastructure | Its own infrastructure/domain, shared domain/platform |
|
||||
| Feature UI | Its own UI/application/domain, shared domain/platform/UI |
|
||||
| Shared domain leaf | Shared domain leaves only |
|
||||
| Shared platform | Shared platform/domain only |
|
||||
| Shared UI | Shared UI/platform/domain only |
|
||||
|
||||
No feature imports app composition. No shared code imports a feature. No external
|
||||
feature imports private responsibility modules. UI and infrastructure communicate
|
||||
with another feature through a supplied application operation/port. Maintained
|
||||
source dependency cycles, including literal lazy imports, fail the architecture
|
||||
gate. If features need bidirectional coordination, app composition injects a
|
||||
callback or adapter instead of creating mutual imports. Prefer this even when a
|
||||
one-way public index import would pass the gate.
|
||||
|
||||
Cross-feature events must document event name, payload, acknowledgement, owner,
|
||||
subscription disposal and delivery timing. Keep them local and specific; F1 adds
|
||||
no global event bus. Preserve `odysseus:notes-context-selected` and its synchronous
|
||||
`accepted` acknowledgement in future Notes work. The server remains authoritative
|
||||
for owner filtering and untrusted selected IDs. Frontend APIs cannot grant access.
|
||||
|
||||
The checker parses executable ESM, forbids computed imports in new source,
|
||||
requires exact precache entries, rejects unresolved/remapped relative imports,
|
||||
and permits only canonical static JS URLs with one unversioned identity for
|
||||
maintained modules. Domain code cannot mention known browser/HTTP/timer globals
|
||||
or an ambient clock; use `now`/clock inputs. This is a conservative vocabulary
|
||||
check, including shadowed names, not a semantic purity proof. Randomness, hidden
|
||||
side effects, authorization and lifecycle behavior require behavioral tests.
|
||||
|
||||
Legacy imports from new source default to forbidden. If an incremental bridge is
|
||||
necessary, declare `legacyImports` on that source's manifest entry as a list of
|
||||
`{ "specifier": "/static/js/example.js?v=existing", "reason": "contract, owner and removal slice" }`.
|
||||
Only feature composition, infrastructure or UI boundaries may use it. Exact
|
||||
specifier/reason and target existence are required; unused exceptions fail.
|
||||
Keep the existing query identity. New domain/application modules receive no
|
||||
legacy exception. F1 has zero exceptions. Do not use exceptions to invent new
|
||||
shared platform dependencies on feature internals.
|
||||
|
||||
## State and lifecycle
|
||||
|
||||
Each feature has one authoritative mutable application-state owner. A transition
|
||||
operation owns writes; UI receives a view/snapshot or subscription. Preserve
|
||||
existing objects and initialization timing when extracting a stateful module.
|
||||
Do not instantiate a new store in both the legacy facade and the feature API.
|
||||
Compatibility facades only forward the same live implementation.
|
||||
|
||||
Document lifecycle scopes: page, authenticated user, session, mounted panel and
|
||||
editor/draft. Name owners of subscriptions, DOM listeners, timers, observers,
|
||||
pending requests and caches. Mounting must be idempotent; disposal releases the
|
||||
resources it owns. Background reminders may outlive an open panel. Closing the
|
||||
panel must not silently stop those reminders during extraction.
|
||||
|
||||
Keep request-generation and selected-session checks around asynchronous
|
||||
completions. Serialize writes where the current contract requires it. Dispose
|
||||
subscriptions explicitly and prevent late updates to a replaced view. Feature
|
||||
state changes on user/session switch must follow current cleanup policy. Browser
|
||||
storage is for preferences/drafts/cache state, never a substitute for backend
|
||||
authorization, credential storage or owner filtering. Define every storage key's
|
||||
owner, retention/reset behavior and compatibility format before moving it.
|
||||
|
||||
## Creating a module or introducing a feature
|
||||
|
||||
1. Identify responsibility, owner, public consumers, side effects and behavioral
|
||||
evidence before editing. Start with a pure leaf when introducing a feature.
|
||||
2. Place only the required source file(s), for example
|
||||
`features/example/domain/calculate.js`. Add an index only when there is a
|
||||
real supported feature API to compose.
|
||||
3. Register each source in `frontend/modules.json`. A typical new mapping is
|
||||
`features/example/domain/calculate.js` to
|
||||
`js/features/example/domain/calculate.js`. Preserve the source/deployment
|
||||
relative graph. The proof's legacy URL mapping is an explicit special case.
|
||||
4. For an extracted leaf, the legacy feature coordinator may import its output
|
||||
as a temporary composition bridge. New maintained features use public indexes;
|
||||
the legacy tree is not globally forced into the new boundary rules in F1.
|
||||
5. Add the exact output URL to the appropriate explicit SW array, retain lazy
|
||||
evaluation timing and bump the cache name when the graph changes. Declare
|
||||
`text eol=lf` for both mapped paths in repository-owned `.gitattributes`
|
||||
files; source-tree or scoped output patterns are allowed. The publisher checks
|
||||
effective Git rules, including nested overrides, and rejects a missing LF rule
|
||||
even if output bytes already match. Do not broaden unrelated legacy checkout
|
||||
rules or automatically rewrite all existing query identities.
|
||||
6. Run the publisher, inspect the source/output diff, then run architecture,
|
||||
identity, graph, offline and focused behavior checks. Include both sides of
|
||||
the mapping in review. Retiring output requires a separate reviewed deletion;
|
||||
the publisher does not clean the static tree.
|
||||
|
||||
```sh
|
||||
python scripts/build_frontend.py
|
||||
python scripts/build_frontend.py --check
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
node scripts/check_frontend.mjs
|
||||
node scripts/check_frontend.mjs --inventory
|
||||
node --test tests/frontendArchitecture.test.mjs
|
||||
./venv/bin/python -m pytest -q -rs tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py tests/test_panel_loader_js.py
|
||||
./venv/bin/python scripts/verify_frontend_runtime.py
|
||||
```
|
||||
|
||||
Use your actual repository virtualenv path; the F1 environment is recorded in
|
||||
validation. Node 20+ follows current CI. Existing Playwright/browser prerequisites
|
||||
are documented in `tests/README.md` and `tests/KNOWN_FAILURES.md`. Missing Node can
|
||||
skip Python browser wrappers, which is a coverage gap, not a pass. The standalone
|
||||
Node gates require locked development dependencies and fail when missing.
|
||||
|
||||
The publisher validates all inputs before writing and only overwrites owned
|
||||
outputs whose bytes differ. It is idempotent and working-directory independent.
|
||||
Git must be available for publication and `--check`; neither runs at application
|
||||
startup. Attribute evaluation uses a private temporary Git repository and ignores
|
||||
global/local overrides, so checkout consistency is reproducible in CI. It adds
|
||||
no runtime configuration or backend imports. CI checks LF attributes, parity and source
|
||||
syntax in existing jobs; the Node job also runs architecture self-tests. Existing
|
||||
runtime identity tests remain authoritative across legacy imports, HTML and SW.
|
||||
The isolated runtime probe is included in the new pytest publication tests.
|
||||
|
||||
## Review, release and rollback
|
||||
|
||||
Review the source/output mapping and exact exported API, then the owner/dependency
|
||||
contract and behavior tests. Check manifests, URL identities, MIME/CSP, load timing,
|
||||
storage compatibility and disposal. Reject unexplained feature behavior changes.
|
||||
Do not repair unrelated defects under an extraction title.
|
||||
|
||||
Release from a validated revision with committed output. Native startup and
|
||||
packagers consume static files without a compiler. Run parity before producing a
|
||||
Docker or desktop artifact. Test the actual packaged application on its platform;
|
||||
Linux contract probes cannot certify macOS/Windows executables. Deploy matching
|
||||
shell, facades, graph and manifests as a unit. Keep an authorized path to the last
|
||||
complete artifact. Rollback restores the whole matching set, not just an entry
|
||||
file or cache version. Do not delete a facade until its public consumers have
|
||||
migrated and compatibility tests make that decision reviewable.
|
||||
|
||||
## Proposal and PR templates
|
||||
|
||||
For a decomposition proposal, record: baseline; responsibility map; current public
|
||||
API and consumers; state owner; I/O and lifecycle boundaries; planned slice;
|
||||
allowed dependencies/temporary exceptions; preserved behavior; deterministic
|
||||
characterization tests; deployment/offline changes; overlapping PRs; rollback;
|
||||
explicit non-goals and remaining uncertainties.
|
||||
|
||||
For each migration PR, lead with the responsibility moved and resulting boundary.
|
||||
Record source/output paths, API compatibility, state/lifecycle ownership,
|
||||
characterization results, exact validation commands, platform gaps, exception
|
||||
removal plan, upstream dependencies and rollback. Attach a concrete before/after
|
||||
example when needed to establish behavioral equivalence. Preserve the repository
|
||||
PR template and never claim unexecuted checks.
|
||||
|
||||
F1 is an entirely separate foundation-only PR. Deliver the complete Notes
|
||||
decomposition, regression tests, compatibility validation and final reference
|
||||
case study through one draft PR targeting official `dev`, using one Notes branch
|
||||
and worktree after F1 integration. N0 through N6 and the N5 substages are internal
|
||||
engineering checkpoints, not independent PRs or merges. Each checkpoint records
|
||||
focused tests, review evidence, its validated revision or reproducible snapshot,
|
||||
and rollback to the prior matching source/output/facade/precache set. Keep the
|
||||
draft open while those checkpoints progress; mark it ready only after the entire
|
||||
decomposition passes the [roadmap's acceptance gates](notes-roadmap.md).
|
||||
|
||||
Structure the Notes PR description around the final integrated design and link
|
||||
each checkpoint's evidence. The receiving maintainer may use smaller independent
|
||||
PRs for future frontend features at their discretion; the one-PR Notes decision
|
||||
does not impose that delivery size on later work.
|
||||
|
||||
## Program backlog and final handoff
|
||||
|
||||
Priority order: review/integrate F1; resolve root offline certification with the
|
||||
owner of PR #6227; complete the Notes checkpoints and final reference case study
|
||||
within one draft PR following the [roadmap](notes-roadmap.md); integrate the
|
||||
complete Notes PR after its acceptance gates; finalize the recipient handoff.
|
||||
Only then does the receiving maintainer prioritize Calendar, Documents, Email,
|
||||
Chat, Skills and Settings against current behavior and risk. Existing partial
|
||||
packages and high fan-in helpers need assessment, not automatic replacement.
|
||||
|
||||
Final H acceptance requires a designated maintainer, the integrated Notes PR
|
||||
revision and its internal checkpoint receipts, the actual public API/state/lifecycle
|
||||
map and final reference case study included in that PR, validated deployment/offline
|
||||
commands, known exceptions/debt,
|
||||
outstanding PR dependencies, rollback evidence and a reproducible independent
|
||||
setup by the recipient. F1 provides the handbook structure and contracts; it does
|
||||
not claim Notes or the completed handoff have occurred. The initiating architect
|
||||
then returns to separately authorized non-frontend architecture work.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Notes reference decomposition roadmap
|
||||
|
||||
Status: planning only. F1 changes no Notes implementation. Begin after the
|
||||
foundation has been reviewed and integrated into official `upstream/dev`.
|
||||
The reference program ends after Notes integration and the completed H handoff.
|
||||
F1 remains an entirely separate foundation-only PR. The complete Notes
|
||||
decomposition, regression tests, compatibility validation and final reference
|
||||
case study belong to one comprehensive draft PR targeting official `dev`.
|
||||
|
||||
## Verified current responsibility and compatibility map
|
||||
|
||||
Baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`.
|
||||
The module is `static/js/notes.js`, about 5,470 lines, with browser identity
|
||||
`/static/js/notes.js?v=20260911notesselectioncancel1`. Preserve that identity across
|
||||
the app import, HTML, SW and lazy consumers until a separately reviewed migration
|
||||
changes the contract everywhere.
|
||||
|
||||
| Responsibility | Current evidence | Intended owner after its reviewed slice |
|
||||
| --- | --- | --- |
|
||||
| State and CRUD | `_notes`, selection/filter/editing/loading variables, `_undoStack`, `_fetchNotes`, `_saveNote`, `_patchNote`, `_archiveNoteById` | One application state owner and workflow operations; no second reminder/UI copy |
|
||||
| Dates and recurrence | `_normalizeRepeat`, `_advanceRecurring`, `_nthWeekdayOfMonth`, `_lastWeekdayOfMonth`, `_toLocalDatetimeStr`, repeat formatting helpers | Pure domain calculations with explicit clock inputs |
|
||||
| Network and persistence | Notes CRUD/reorder, upload, reminder firing, draft and reminder localStorage keys | Infrastructure adapters preserving request payloads, credentials and error contracts |
|
||||
| Reminders | `_checkReminders`, `_startReminderLoop`, `_initReminders`, notification permission/display and due/fired badge calculations | Application reminder workflow plus injected clock/storage/notification/API ports |
|
||||
| Draft autosave | `_draftKey`, `_collectFormDraft`, `_wireDraftAutosave`, `_applyDraftToNote`, `_commitOpenInPlaceEditor` | Draft workflow with editor view/persistence ports and serialized saves |
|
||||
| UI | `openPanel`, `closePanel`, `_renderNotes`, forms/cards/checklists, masonry, color/photo/drawing, mobile swipe/reorder | Scoped UI modules with explicit mount/dispose and application operations |
|
||||
| Agent context | `#notes-send-agent` handler, selected IDs, `odysseus:notes-context-selected` | Preserve event payload and synchronous acknowledgement; Chat owns attachment/session state |
|
||||
| Agent solve action | `_agentSolveNote`: default-chat lookup, session creation and chat stream | Separate existing workflow; do not conflate with selected-note attachment |
|
||||
| Compatibility coordinator | Default controller, named aliases, `window.notesModule`, delayed reminder startup | Stable legacy facade/composition bridge until consumers safely migrate |
|
||||
|
||||
The default API currently includes `openPanel`, `closePanel`, `togglePanel`,
|
||||
`isPanelOpen`, `openNote`, `newNote`, `openNotes`, `closeNotes`, `isNotesOpen` and
|
||||
`refreshDueBadge`. Named exports also include `dismissFiredReminderDot` and the
|
||||
panel/open aliases. Characterization must pin the complete actual namespace and
|
||||
default object separately, including identity of aliases and the window bridge.
|
||||
|
||||
Consumers include `static/app.js` sidebar/routes/badge polling,
|
||||
`chatStream.js` note tool updates, `chatRenderer.js` note links and
|
||||
`document.js` coordination. `static/index.html` and `static/sw.js` are entry/cache
|
||||
contracts. The shared UI, confetti, modal, color picker, drag, tiling, docking,
|
||||
z-order and Escape helpers are direct dependencies. Those legacy dependencies
|
||||
remain in the coordinator until their ownership is reviewed, rather than moving
|
||||
all UI coupling into domain modules.
|
||||
|
||||
Import-time behavior includes window origin/storage reads and
|
||||
`setTimeout(_initReminders, 3000)`. The successful bootstrap loads Notes and starts
|
||||
a 30-second reminder interval. App composition separately refreshes the badge
|
||||
every five minutes. Panel/editing listeners, draft timers, highlight animations,
|
||||
ResizeObserver and drawing/touch handlers have shorter scopes. Record them before
|
||||
extraction; do not stop background reminders merely because a panel closes.
|
||||
Listener cleanup concerns require characterization, not an unrelated fix hidden
|
||||
inside an extraction.
|
||||
|
||||
Persistence includes `odysseus-notes-view`, label collapse and hint preferences,
|
||||
fired/glowed/pending/active reminder markers, dismissed timestamp, and
|
||||
`odysseus-note-draft-<id>` including `__new__`. Current direct APIs include Notes
|
||||
CRUD/reorder/fire-reminder and upload, plus default-chat/session/chat-stream for
|
||||
the existing solve action. Backend owner isolation and permission handling stay
|
||||
authoritative and are outside this frontend decomposition's implementation scope.
|
||||
|
||||
## PR #6495 contract that every relevant slice preserves
|
||||
|
||||
Notes sends `odysseus:notes-context-selected` with `{ noteIds, accepted: false }`.
|
||||
Chat's synchronous listener accepts only a valid active or pending chat, updates
|
||||
session/pending selection, sets `accepted`, and updates the context indicator.
|
||||
Notes clears select mode only after acceptance. Session lifecycle events
|
||||
`odysseus:session-changed` carry session/materialization information. Chat appends
|
||||
selected IDs as `selected_note_ids` when sending; server parsing validates IDs,
|
||||
filters by owner and treats the resulting prompt context as untrusted.
|
||||
|
||||
Keep selection transfer to a materialized chat, reset on a new chat, rejection
|
||||
when no chat/model can accept it, aliases and indicator behavior. Do not broaden
|
||||
frontend selections into permissions or bypass the backend. See
|
||||
`tests/selectedNotesContext.test.mjs`, `tests/test_selected_notes_context.py`,
|
||||
`static/js/chat.js` selected-context listeners and send payload, and
|
||||
`static/js/sessions.js` lifecycle event producers.
|
||||
|
||||
## One Notes branch, worktree and draft PR
|
||||
|
||||
After F1 integration and authorization to begin Notes, create one worktree and
|
||||
branch, suggested name `refactor/notes-reference-decomposition`, from the latest
|
||||
official `upstream/dev`. N0 through N6 are sequential internal engineering
|
||||
checkpoints on that same branch. No independent GitHub PR is required for a
|
||||
checkpoint or a UI substage. Maintain one draft PR targeting official `dev`, with
|
||||
checkpoint evidence linked from its description. Keep it a draft until the
|
||||
complete decomposition passes all acceptance gates below.
|
||||
|
||||
| Checkpoint | Responsibility | Deliverable and exit evidence |
|
||||
| --- | --- | --- |
|
||||
| N0 | Characterization | Public API/consumer, responsibility, state, lifecycle and network map; deterministic behavior tests; PR-overlap refresh; no extraction |
|
||||
| N1 | Recurrence domain | Only pure date/recurrence rules to `features/notes/domain/recurrence.js`; preserve old wrapper call contracts, time-read position and returned strings; new offline dependency registered |
|
||||
| N2 | Infrastructure | HTTP and storage adapters with exact request/error/key contracts; retain state ownership and backend authorization |
|
||||
| N3 | Application | Single state owner and CRUD/optimistic/undo workflows; UI/reminders call operations; no competing arrays or stores |
|
||||
| N4 | Reminders | Timer/notification/reminder orchestration through explicit ports; characterize startup, catch-up, deduplication, badge and background behavior |
|
||||
| N5 | UI substages | Bounded card/panel, editor/draft, checklist, drawing and mobile reviews within the same PR; explicit listener/observer disposal and the same application operations |
|
||||
| N6 | Compatibility and reference case study | Reduce coordinator; full API/consumer/lifecycle/offline comparison; retain legacy facade unless removal is proven safe; complete the final reference case study in the same PR |
|
||||
|
||||
Do not create the Notes worktree, branch or PR during F1. N5 remains several
|
||||
bounded internal reviews rather than a second monolith move. Every checkpoint
|
||||
and UI substage must have its own focused tests, review evidence, lessons,
|
||||
exceptions and a rollback plan. Record the validated revision or reproducible
|
||||
patch/snapshot, prior known-good checkpoint, exact test commands/results and
|
||||
how to restore its matching source/output/facade/precache set while preserving
|
||||
unrelated work. Checkpoint commits require the applicable maintainer authority;
|
||||
no checkpoint or commit is created by this planning document.
|
||||
|
||||
Advance only after the current checkpoint's required checks and review findings
|
||||
are resolved. A failed checkpoint returns to its prior validated state or receives
|
||||
a bounded correction on the same branch; it does not require a separate PR.
|
||||
If upstream changes require updating the baseline, update the same Notes branch,
|
||||
record that revision and rerun affected checkpoint and compatibility gates.
|
||||
|
||||
## Complete Notes PR acceptance gates
|
||||
|
||||
Before marking the single draft PR ready for review:
|
||||
|
||||
- Complete N0 through N6, including all N5 substages, with their tests, review
|
||||
receipts and reproducible checkpoint/rollback evidence.
|
||||
- Pass the required full Notes regression and compatibility checks on the final
|
||||
combined revision, including PR #6495 selection/context behavior, public APIs,
|
||||
module identities, single state ownership, storage formats, timers and teardown.
|
||||
- Pass source/output parity, LF attribute, architecture, import graph and exact
|
||||
offline manifest gates; verify current bootstrap/lazy behavior and relevant
|
||||
native/packaging contracts. Record platform limits and distinguish preserved
|
||||
offline module behavior from separately certified root offline reload.
|
||||
- Resolve required gate failures and review findings; document unrelated existing
|
||||
failures or coverage gaps explicitly rather than bypassing required checks.
|
||||
- Include the final reference case study in this PR: actual before/after APIs,
|
||||
dependency/state/lifecycle maps, stage decisions and lessons, validation recipes,
|
||||
temporary exceptions, known debt and tested rollback/compatibility procedures.
|
||||
|
||||
These are readiness gates for the complete PR, not separate stage merges.
|
||||
The receiving maintainer may choose smaller independent PRs for future frontend
|
||||
features; this one-PR delivery decision applies to the Notes reference program.
|
||||
|
||||
## N0 and N1 characterization requirements
|
||||
|
||||
Pin recurrence normalization for legacy bare repeat values and explicit values;
|
||||
none/unknown/malformed input; daily/weekly/monthly/yearly advancement; month ends,
|
||||
leap years, nth/last weekdays including absent fifth occurrences; overdue catch-up
|
||||
and the 5,000-step guard; future anchors; local serialization and preserved hours;
|
||||
DST gaps/overlaps under explicit time zones. `_advanceRecurring` currently reads
|
||||
`Date.now()` after its first step. Inject a clock without changing that ordering.
|
||||
Record the existing behavior of invalid dates rather than inventing improvements.
|
||||
|
||||
Characterize request error handling, optimistic rollback/undo, archive/delete,
|
||||
selection/filter/reset, ordering, uploads and notification permission errors.
|
||||
For drafts, preserve synchronous local backup before the 500 ms persistence
|
||||
timer, intentional empty text, checklist type fallback, serialized writes,
|
||||
failure recovery and refusal to clear a newer draft when an older save finishes.
|
||||
|
||||
Use stub clocks/ports and temporary storage for unit behavior; browser tests for
|
||||
public namespace evaluation, DOM lifecycle, selected context and actual offline
|
||||
loading. Run real modules through their output paths. Do not rewrite source-range
|
||||
tests wholesale; migrate each only with its responsible slice and equivalent
|
||||
behavioral assertions. Existing source-sensitive Notes autosave tests currently
|
||||
read `notes.js`, so extraction has an explicit test-maintenance dependency.
|
||||
|
||||
Before a slice, refresh #6585/#6647/#6652 backend behavior and #6470/#6695 shared
|
||||
UI/platform overlaps from the [assessment](assessment.md). Existing fixes may
|
||||
change characterization inputs, but this program does not merge or silently
|
||||
reimplement those PRs. The independent root-SW fix #6227 is required before a
|
||||
claim of full SPA offline reload readiness.
|
||||
|
||||
## H completion and stopping rule
|
||||
|
||||
The final reference case study is completed inside the Notes PR before it becomes
|
||||
ready for review. After that PR is integrated, finalize H with the integrated
|
||||
revision and checkpoint-to-integration receipts, outstanding dependencies and
|
||||
the recipient's independent reproduction. Have the receiving maintainer
|
||||
reproduce setup and validation independently and record that receipt. Transfer
|
||||
future feature ownership explicitly. Do not automatically begin Calendar,
|
||||
Documents, Email, Chat, Skills or Settings; the initiating architect continues
|
||||
with separately approved non-frontend monolith work.
|
||||
@@ -0,0 +1,453 @@
|
||||
# F1 implementation and review report
|
||||
|
||||
Date: 2026-10-13. Recommendation: ready for controlled incremental adoption after
|
||||
maintainer review, with the platform/offline limits below. The foundation proof
|
||||
is implemented and tested; Notes decomposition and the completed H handoff are
|
||||
planned only. No commit, push, merge, PR or issue operation was performed.
|
||||
|
||||
## Notes delivery documentation correction
|
||||
|
||||
The maintainer's final delivery decision is one comprehensive Notes draft PR
|
||||
targeting official `dev`, after the separate foundation-only F1 PR is integrated.
|
||||
N0 through N6 are internal engineering checkpoints in one Notes worktree and
|
||||
branch. The complete decomposition, regression tests, compatibility validation
|
||||
and final reference case study belong to that single PR; it stays a draft until
|
||||
the complete acceptance gates pass. Each stage retains its own test results,
|
||||
review receipts and reproducible rollback/checkpoint strategy, without requiring
|
||||
an independent GitHub PR. The receiving maintainer may choose smaller independent
|
||||
PRs for future frontend features.
|
||||
|
||||
This correction updates only `notes-roadmap.md`, `handbook.md` and this report.
|
||||
Implementation and test files retain their pre-correction bytes. All five
|
||||
foundation documents passed link, fence, whitespace, no-em-dash and delivery
|
||||
wording checks. `git diff --check` and the preserved-runtime diff check passed.
|
||||
A before/after snapshot confirmed exactly these three changed documents and
|
||||
15 unchanged existing F1 files. The implementation test results below remain
|
||||
the previous executed evidence; implementation tests were not rerun in this
|
||||
documentation-only pass.
|
||||
|
||||
## Independent review correction
|
||||
|
||||
The maintainer reports that the independent F1 review found no critical
|
||||
architectural defects. This pass preserves the reviewed native ESM architecture
|
||||
and scope. Its correction is to turn the documented LF checkout convention into
|
||||
a manifest-driven publisher/CI requirement. The full independent review text
|
||||
was not supplied; this report records the supplied finding and executed evidence.
|
||||
|
||||
The correction changes only `scripts/build_frontend.py`,
|
||||
`tests/test_frontend_build.py`, the ADR, the handbook and this report. The
|
||||
`.gitattributes` rules, architecture checker, CI workflow, manifest, dependencies
|
||||
and stateless source remain as reviewed. The full F1 inventory below still has
|
||||
18 files, including `.gitattributes`.
|
||||
|
||||
The publisher now evaluates both paths of every manifest entry through Git's
|
||||
actual attribute engine before parity evaluation or any deployment write. It
|
||||
requires `text: set` and `eol: lf` from applicable repository-owned attribute files,
|
||||
including nested overrides. A private temporary repository and isolated Git
|
||||
configuration prevent global attributes, local `.git/info/attributes`, templates
|
||||
or alternate Git directory settings from masking an omitted declaration. Paths
|
||||
are sent through NUL-delimited stdin, preserving spaces and using no shell/path
|
||||
quoting assumptions. The working repository's attributes, index and output are
|
||||
not changed by this check. Git is a publication/development prerequisite; the
|
||||
application does not invoke it to build or check frontend assets at startup.
|
||||
|
||||
The production integration diff is the new `check_lf_attributes(root, paths)`
|
||||
helper, four standard-library imports, this call, and updated CLI help:
|
||||
|
||||
```diff
|
||||
@@ synchronize
|
||||
if unlisted:
|
||||
raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}")
|
||||
+ check_lf_attributes(root, sources | outputs)
|
||||
stale = []
|
||||
```
|
||||
|
||||
The tests add fixture LF declarations and 14 cases. Ten parameterized cases
|
||||
reject missing LF, CRLF, auto-text and binary-text rules for source/output paths
|
||||
in both publish and check modes, even when bytes already match. The other four
|
||||
cover a future manifest pair failing before any output is published and passing
|
||||
after its LF declaration; nested rules and a spaced path; global/local settings
|
||||
failing to mask an omitted repository rule; and clear failure without development
|
||||
Git. Existing publication, ownership, confinement, parity and runtime tests remain.
|
||||
|
||||
## Exact attributes diff and checkout evidence
|
||||
|
||||
`git diff -- .gitattributes` is unchanged from the reviewed F1 diff:
|
||||
|
||||
```diff
|
||||
diff --git a/.gitattributes b/.gitattributes
|
||||
index 8681aee1..dd49c4a0 100644
|
||||
--- a/.gitattributes
|
||||
+++ b/.gitattributes
|
||||
@@ -10,6 +10,10 @@
|
||||
entrypoint.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
|
||||
+# Maintained frontend modules and this lane's byte-identical deployment output.
|
||||
+frontend/src/** text eol=lf
|
||||
+static/js/actionMenuOrder.js text eol=lf
|
||||
+
|
||||
# Windows-native scripts stay CRLF.
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
```
|
||||
|
||||
Exact command and results:
|
||||
|
||||
```text
|
||||
$ git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js
|
||||
frontend/src/shared/actionMenuOrder.js: text: set
|
||||
frontend/src/shared/actionMenuOrder.js: eol: lf
|
||||
static/js/actionMenuOrder.js: text: set
|
||||
static/js/actionMenuOrder.js: eol: lf
|
||||
```
|
||||
|
||||
The sampled legacy contracts were also inspected:
|
||||
|
||||
| Path | text | eol |
|
||||
| --- | --- | --- |
|
||||
| `static/js/notes.js` | auto | unspecified |
|
||||
| `static/index.html` | auto | unspecified |
|
||||
| `launch-windows.ps1` | set | crlf |
|
||||
| `start-macos.sh` | set | lf |
|
||||
|
||||
Beyond samples, all 2,479 unrelated tracked paths were compared against HEAD for
|
||||
both attributes and matched. The current Git 2.53.0 supports the diagnostic
|
||||
`--source=HEAD` option; the publisher uses only the longstanding plain
|
||||
`check-attr --stdin` interface, so it does not depend on that newer option.
|
||||
The read-only whole-tree comparison command was:
|
||||
|
||||
```sh
|
||||
python3 - <<'PY'
|
||||
import subprocess
|
||||
paths = subprocess.check_output(['git', 'ls-files', '-z'])
|
||||
def attributes(source):
|
||||
result = subprocess.run(
|
||||
['git', 'check-attr', *source, '-z', '--stdin', 'text', 'eol'],
|
||||
input=paths, capture_output=True, check=True,
|
||||
)
|
||||
fields = result.stdout.decode().split('\0')[:-1]
|
||||
return {(fields[i], fields[i + 1]): fields[i + 2]
|
||||
for i in range(0, len(fields), 3)}
|
||||
before, after = attributes(['--source=HEAD']), attributes([])
|
||||
owned = lambda p: p.startswith('frontend/src/') or p == 'static/js/actionMenuOrder.js'
|
||||
changes = [(p, a, before[(p, a)], value) for (p, a), value in after.items()
|
||||
if not owned(p) and before[(p, a)] != value]
|
||||
assert not changes, changes
|
||||
print(len({p for p, a in after if not owned(p)}), 'unrelated tracked paths unchanged')
|
||||
PY
|
||||
```
|
||||
|
||||
## Ownership enforcement after correction
|
||||
|
||||
| Contract | Enforcement and CI receipt |
|
||||
| --- | --- |
|
||||
| Canonical, contained source/output paths and unique ownership | Publisher validates the whole manifest before writing; `python-syntax` runs its `--check` |
|
||||
| Every maintained JS source is registered | Publisher rejects unlisted source; duplicate sources/outputs also fail |
|
||||
| Reproducible LF checkout of both mapped paths | Publisher queries isolated repository-owned attributes, even for already matching bytes; the same CI `--check` now enforces it |
|
||||
| Committed output equals maintained source | Publisher checks byte parity without rebuilding in CI; normal publication writes only stale owned outputs |
|
||||
| Feature API/layer direction, no new cycles, stable relative resolution and maintained identities | Existing architecture checker and Node self-tests in `node-syntax`; no checker changes needed |
|
||||
| Explicit exact-URL offline coverage | Architecture checker parses the existing SW arrays; real offline browser proof remains green |
|
||||
| Legacy runtime graph/URL identity and behavior | Existing focused pytest guards and the full CI pytest jobs remain in place |
|
||||
|
||||
These gates enforce the documented maintained-JS contract together. They do not
|
||||
retroactively impose source ownership on all legacy static files, clean retired
|
||||
outputs or prove semantic state/lifecycle correctness. Those existing limits and
|
||||
the independent packaging/offline limitations below remain unchanged.
|
||||
|
||||
## Final correction validation
|
||||
|
||||
The full focused pytest command printed below was rerun with the same interpreter
|
||||
and all listed files: **193 passed, 1 skipped, 1 existing SQLAlchemy warning in
|
||||
12.33 s**. It includes all 27 publisher tests and the isolated native startup,
|
||||
static traversal and no-Node distribution probe. The new 14 tests account for
|
||||
the increase from the initial F1 run's 179 passes. No test failed in this pass.
|
||||
|
||||
| Correction check | Actual result |
|
||||
| --- | --- |
|
||||
| Combined focused pytest command below | 193 passed, 1 unavailable-image skip |
|
||||
| `node --test tests/frontendArchitecture.test.mjs` | 10 passed |
|
||||
| Notes oracle/autosave/selected-context command below | 11 passed |
|
||||
| `python3 scripts/build_frontend.py` | Passed, zero output changes, attributes enforced |
|
||||
| `python3 scripts/build_frontend.py --check` | Passed, attributes and byte parity enforced |
|
||||
| `node scripts/check_frontend.mjs` | Passed, one maintained module |
|
||||
| Helper SHA-256 and `git check-attr` | Identical source/output hash; both text=set, eol=lf |
|
||||
| All unrelated tracked attributes versus HEAD | 2,479 paths unchanged |
|
||||
| `git diff --check` and preserved-runtime command below | Passed, no runtime/backend/packaging diff |
|
||||
| `python -m compileall -q scripts/build_frontend.py tests/test_frontend_build.py` | Passed using the recorded virtualenv interpreter |
|
||||
| Isolated Docker runtime command below | Passed, networking disabled; no source tree, node_modules or Node on PATH |
|
||||
| Complete inventory, whitespace and documentation links | All 18 changed files matched the inventory; checks passed |
|
||||
|
||||
The one skip remains the existing test requiring `odysseus-odysseus:latest`.
|
||||
No new skip or waiver was introduced. Windows/macOS executable builds, a fresh
|
||||
full Dockerfile build, the full pytest suite, Firefox/WebKit and CI's exact
|
||||
Python 3.11/Node 20 versions remain unexecuted locally. Native Git semantics,
|
||||
nested overrides, paths with spaces and ignored `core.autocrlf`/global attribute
|
||||
settings are covered by executed Linux tests; actual Windows execution remains a
|
||||
platform-owner check. The existing `/static/` worker scope still prevents a claim
|
||||
of full root offline reload readiness. No service-worker scope change is included.
|
||||
|
||||
The earlier local ChromaDB probe incident remains recorded under failures below.
|
||||
All runtime checks in this correction pass used the isolated distribution probe
|
||||
with reserved non-listening Chroma port and disabled warmups; Docker additionally
|
||||
had networking disabled. This pass performed no new unisolated ChromaDB probe and
|
||||
no attempt to restore or alter the existing local service's tool index.
|
||||
|
||||
## Reviewer-ready PR summary
|
||||
|
||||
Suggested title: `Establish frontend source ownership and native ESM deployment contracts`.
|
||||
|
||||
Maintain a stateless helper under `frontend/src/` while publishing identical bytes
|
||||
at its existing static URL. Add manifest-owned publication, repository-owned LF
|
||||
attribute and byte-parity checks, parsed dependency/API/identity/offline gates,
|
||||
and a self-contained architecture handbook with the Notes decomposition roadmap.
|
||||
Startup and existing feature behavior retain their current runtime contracts.
|
||||
|
||||
Validation: 193 focused Python tests passed with one existing Docker-image skip;
|
||||
10 architecture and 11 Notes compatibility tests passed; source/output parity,
|
||||
LF gates, preserved-runtime diff and isolated native/Docker runtime proofs passed.
|
||||
Desktop executable builds and full root offline reload remain explicit limits.
|
||||
All 18 files are inventoried below. The working diff is ready for review and is
|
||||
uncommitted; commit, push and PR creation require separate authorization.
|
||||
|
||||
## Verified repository state
|
||||
|
||||
Repository: `odysseus-dev/odysseus`. Branch:
|
||||
`refactor/frontend-architecture-foundation`. Worktree:
|
||||
`/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation`.
|
||||
Initial worktree was clean. HEAD and local official `upstream/dev` are
|
||||
`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The live official GitHub `dev` SHA
|
||||
matched at both initial and final verification. This is PR #6495's merge commit.
|
||||
The `origin` remote is the maintainer fork, not the integration target.
|
||||
|
||||
Final changes are uncommitted F1 files only. Runtime JavaScript, Notes, HTML,
|
||||
SW, backend, Dockerfile and desktop startup/packaging implementations have no
|
||||
diff. The stateless source and existing deployed helper are both 1,688 bytes,
|
||||
SHA-256 `e17baec5d6ac381ce6234ab6f028a8208487280dd07d6950a3469f8488809684`.
|
||||
The final publisher run reported zero changed output modules.
|
||||
|
||||
## Decisions and scope
|
||||
|
||||
The [ADR](ADR-001-source-and-deployment.md) compares two executed approaches:
|
||||
controlled native ESM copying and esbuild 0.25.12 ESM compilation/bundling.
|
||||
Native byte copying wins on existing URL/module/cache compatibility and absence
|
||||
of runtime tooling. Commit explicit output beside maintained source, publish
|
||||
only manifest-owned modules and enforce parity in CI. Python startup never builds.
|
||||
|
||||
The [handbook](handbook.md) defines feature public indexes, responsibility layers,
|
||||
state/lifecycle ownership, cross-feature ports/events, shared-code limits, legacy
|
||||
coexistence, module creation, validation, rollback and handoff contracts.
|
||||
The [assessment](assessment.md) records graph/bootstrap/state/storage/security,
|
||||
packaging assumptions and current open-PR overlaps. The
|
||||
[Notes roadmap](notes-roadmap.md) defines N0 through N6 as internal checkpoints
|
||||
for one comprehensive Notes draft PR, with the final reference case study in
|
||||
that PR and H recipient handoff after integration. None is implemented in F1.
|
||||
There is one maintained stateless leaf and zero legacy-import
|
||||
exceptions, with no empty feature scaffolding.
|
||||
|
||||
Acorn 8.19.0 is the sole new, exact-pinned development dependency, used to parse
|
||||
architecture edges accurately. It is not loaded or distributed as runtime JS.
|
||||
esbuild was a pinned one-off spike and is not a project dependency. Existing
|
||||
frameworks, application entry points and backend contracts are unchanged.
|
||||
|
||||
## Exact changed files
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `.gitattributes` | LF checkout for maintained source and the registered deployed proof |
|
||||
| `.github/workflows/ci.yml` | Parity check; locked parser install; source syntax and architecture self-tests |
|
||||
| `package.json` | Exact Acorn development dependency |
|
||||
| `package-lock.json` | Acorn lock/integrity entry |
|
||||
| `frontend/modules.json` | Explicit source/deployment ownership mapping |
|
||||
| `frontend/src/package.json` | Native ESM interpretation for source tests, no dependencies |
|
||||
| `frontend/src/shared/actionMenuOrder.js` | Authoritative stateless source, copied verbatim from the existing helper |
|
||||
| `scripts/build_frontend.py` | Validated source publisher with manifest-driven, repository-owned LF enforcement and non-mutating parity check |
|
||||
| `scripts/check_frontend.mjs` | Parsed import/layer/API/cycle/identity/precache guards and graph inventory |
|
||||
| `scripts/verify_frontend_runtime.py` | Isolated current-code distribution/runtime compatibility probe |
|
||||
| `tests/frontendArchitecture.test.mjs` | Behavioral API equivalence and positive/negative architecture gates |
|
||||
| `tests/test_frontend_build.py` | Publication/ownership/drift/runtime coverage plus positive and negative Git attribute enforcement tests |
|
||||
| `tests/test_frontend_foundation_offline_js.py` | Real SW precache, offline first evaluation, canonical namespace identity and lazy registry proof |
|
||||
| `docs/frontend-architecture/ADR-001-source-and-deployment.md` | Decision, alternatives and distribution/rollback contracts |
|
||||
| `docs/frontend-architecture/assessment.md` | Evidence-backed architecture and PR-overlap inventory |
|
||||
| `docs/frontend-architecture/handbook.md` | Permanent foundation and maintainer continuation methodology |
|
||||
| `docs/frontend-architecture/notes-roadmap.md` | Reference decomposition and final handoff plan |
|
||||
| `docs/frontend-architecture/validation.md` | This implementation/review/validation report |
|
||||
|
||||
## Initial F1 validation before the review correction
|
||||
|
||||
Environment: Linux; native Python 3.14.4; pytest 9.1.1; FastAPI 0.143.0;
|
||||
Starlette 1.8.0; Node 22.23.1; Playwright 1.62.1 with installed Chromium.
|
||||
CI remains Python 3.11 and Node 20; that exact interpreter pair was not executed
|
||||
locally. Use the repository virtualenv, not the system Python missing dependencies.
|
||||
The interpreter used here was
|
||||
`/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python`.
|
||||
|
||||
The historical counts below are coordinator-executed checks in this worktree, excluding
|
||||
delegated claims and earlier repeated subsets. Delegation was discontinued on
|
||||
user instruction; no delegated implementation was integrated.
|
||||
|
||||
| Check | Actual result |
|
||||
| --- | --- |
|
||||
| Read-only baseline: graph, identity, helper, loader, MIME, nonce, runtime paths | 54 passed, one existing SQLAlchemy deprecation warning |
|
||||
| Final combined focused pytest command below | 179 passed, 1 skipped, one existing SQLAlchemy warning, 12.62 s |
|
||||
| Final `pytest -q -rs tests/test_frontend_build.py` review rerun | 13 passed, including native startup and encoded static traversal rejection |
|
||||
| `node --test tests/frontendArchitecture.test.mjs` | 10 passed, no skips or failures; rerun after final gate review |
|
||||
| Notes oracle/autosave/selected-context Node command below | 11 passed, no skips or failures |
|
||||
| `python3 scripts/build_frontend.py` and `--check` | Zero output changes; byte parity passed |
|
||||
| `node scripts/check_frontend.mjs` | Passed, one maintained module |
|
||||
| `node scripts/check_frontend.mjs --inventory` | 230 files, 707 static edges, 108 lazy literal edges, no missing targets, three existing static cyclic components |
|
||||
| `npm ci --ignore-scripts --no-audit --no-fund` | Locked development dependency installation succeeded |
|
||||
| Native `scripts/verify_frontend_runtime.py` | Real FastAPI lifespan and shell/static assertions passed without maintained source, node_modules or Node on PATH |
|
||||
| Docker runtime probe below | Same current-code assertions passed with networking disabled and source mount read-only |
|
||||
| esbuild spike | Two identical 1,554-byte bundle outputs; executed exports/icon/ordering equivalence passed |
|
||||
| JS syntax checks and Python compileall below | Passed |
|
||||
| `git diff --check`, preserved-scope diff check, hash/LF checks | Passed; no runtime/backend/packaging source diff |
|
||||
| Foundation documentation links, trailing whitespace and em-dash checks | All five files passed |
|
||||
|
||||
The skipped test is
|
||||
`tests/test_docker_devops_hardening.py:187`, which requires the unavailable
|
||||
`odysseus-odysseus:latest` image. It was not relabeled or bypassed. F1's separate
|
||||
Docker runtime probe used the available `odysseus-maintainer-preview-odysseus:latest`,
|
||||
image ID `sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`,
|
||||
with Python 3.14.7, FastAPI 0.141.1 and Starlette 1.6.0. This proves compatibility
|
||||
of current candidate code/assets with that image's runtime dependencies, not a
|
||||
fresh build of the full repository Dockerfile or a deployment.
|
||||
|
||||
Reproduce the final focused command with the actual tested interpreter:
|
||||
|
||||
```sh
|
||||
/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m pytest -q -rs \
|
||||
tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \
|
||||
tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \
|
||||
tests/test_action_menu_order.py tests/test_panel_loader_js.py \
|
||||
tests/test_app_static_mime.py tests/test_serve_html_with_nonce.py \
|
||||
tests/test_runtime_paths.py tests/test_ci_authoritative_validation.py \
|
||||
tests/test_selected_notes_context.py tests/test_email_library_module_graph_js.py \
|
||||
tests/test_settings_module_import_paths.py tests/test_app_config_shared_fetch_js.py \
|
||||
tests/test_markdown_lazy_lib_loading_js.py tests/test_startup_shell_js.py \
|
||||
tests/test_static_stylesheet_manifest.py tests/test_notes_dom_xss_helpers.py \
|
||||
tests/test_notes_reminder_temporal_redos.py tests/test_notes_sidebar_new_note.py \
|
||||
tests/test_notes_mobile_checklist_toggle_static.py \
|
||||
tests/test_notes_document_mutual_minimize.py tests/test_docker_devops_hardening.py
|
||||
|
||||
node --test tests/frontendArchitecture.test.mjs
|
||||
ENDPOINT_URL=http://127.0.0.1:1 node --test \
|
||||
tests/noteTestOracle.test.mjs tests/notesDraftAutosave.test.mjs \
|
||||
tests/selectedNotesContext.test.mjs
|
||||
|
||||
python3 scripts/build_frontend.py --check
|
||||
node scripts/check_frontend.mjs
|
||||
/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python scripts/verify_frontend_runtime.py
|
||||
|
||||
docker run --rm --network none \
|
||||
--mount type=bind,src=/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation,dst=/candidate,readonly \
|
||||
--workdir /candidate --entrypoint python \
|
||||
odysseus-maintainer-preview-odysseus:latest scripts/verify_frontend_runtime.py
|
||||
```
|
||||
|
||||
`ENDPOINT_URL` satisfies the existing oracle's import-time configuration requirement.
|
||||
Its unit-test imports exercise pure exported functions; its network-driving main
|
||||
is guarded by the executable-module check and does not run. The closed loopback
|
||||
address is not a live model endpoint. No oracle implementation was changed.
|
||||
|
||||
Syntax commands executed:
|
||||
|
||||
```sh
|
||||
bash -c 'shopt -s globstar nullglob; for f in static/app.js static/js/**/*.js frontend/src/**/*.js scripts/check_frontend.mjs tests/frontendArchitecture.test.mjs; do node --check "$f" || exit; done'
|
||||
/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m compileall -q app.py core routes src services scripts tests
|
||||
git diff --check
|
||||
git diff --exit-code -- static app.py Dockerfile .dockerignore Odysseus.spec build-macos-app.sh build-windows-portable.ps1 launcher.py core routes src services
|
||||
git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js
|
||||
sha256sum frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js
|
||||
```
|
||||
|
||||
Spike compiler invocation, executed twice with equality and Node import assertions:
|
||||
|
||||
```sh
|
||||
npx --yes esbuild@0.25.12 frontend/src/shared/actionMenuOrder.js \
|
||||
--bundle --format=esm --target=es2022 --platform=browser --log-level=error
|
||||
```
|
||||
|
||||
The clean publication proof uses temporary roots in `test_frontend_build.py`,
|
||||
including an initially absent output tree. Real-checkout publication is idempotent.
|
||||
No emitted JS bytes, URLs, boot scripts or cache manifests changed in this lane.
|
||||
|
||||
## Failures encountered and their resolution
|
||||
|
||||
- An early architecture self-test expected an identity diagnostic but got an
|
||||
unresolved source path for a query-bearing relative import. The checker now
|
||||
rejects alternate identity before resolving that source path. All final tests
|
||||
pass; no test requirement was weakened.
|
||||
- The initial Notes oracle command failed during import because `ENDPOINT_URL`
|
||||
was missing. Inspection established that the unit-test path never runs its
|
||||
endpoint-driving main. The explicit closed-address configuration above passed
|
||||
all 11 tests. This prerequisite is recorded for N0 rather than hidden.
|
||||
- The first isolated distribution fixture omitted the existing `companion/`
|
||||
package, so both probes failed with `ModuleNotFoundError`. The fixture now
|
||||
includes that required runtime directory; neither application nor packaging
|
||||
implementation was modified to make the tests pass.
|
||||
- The first exploratory native startup check connected to the existing local
|
||||
ChromaDB and refreshed the built-in tool index. That was unintended. Subsequent
|
||||
probes use a private distribution/data directory, a reserved non-listening
|
||||
Chroma port, disabled existing warmup options and no Node on PATH. Docker also
|
||||
disables networking. No attempt was made to overwrite or restore that service's
|
||||
data; the earlier unisolated probe is not the reproducible validation recipe.
|
||||
|
||||
## Remaining risks and limits
|
||||
|
||||
Root offline reload is not certified: existing SW scope is `/static/`, and open
|
||||
[#6227](https://github.com/odysseus-dev/odysseus/pull/6227) proposes the independent
|
||||
scope/header correction. F1 preserves and tests actual offline module behavior
|
||||
at the current scope. No first-use offline guarantee is claimed for optional
|
||||
CDN libraries, Mermaid or server APIs.
|
||||
|
||||
Windows/macOS packaged executables, a fresh full Dockerfile build, the full pytest
|
||||
suite, Firefox/WebKit offline behavior and CI's exact Python 3.11/Node 20 versions
|
||||
were not executed here. Existing packaging paths are inspected and unchanged;
|
||||
platform owners should run release validation before broader certification.
|
||||
The unavailable-image test remains a coverage gap. Legacy source still has
|
||||
cross-feature coupling, global side effects and static cycles. New boundaries
|
||||
are enforced on maintained source, not retrospectively on the entire legacy tree.
|
||||
|
||||
The publisher intentionally does not clean retired outputs or provide atomic
|
||||
multi-file deployment. Review full source/output/facade/manifest changes and
|
||||
ship a coherent artifact. Domain syntax checks cannot prove all semantic purity
|
||||
or disposal; behavioral characterization remains required. Reconcile the package
|
||||
lock with #6627 and refresh all PR overlaps before integration.
|
||||
|
||||
## Reviewer checklist
|
||||
|
||||
- Verify the base contains #6495 and the official integration target is dev.
|
||||
- Confirm the deployed helper hash and runtime-scope diff are unchanged.
|
||||
- Review the layer matrix, public API rules, single-state ownership and zero
|
||||
current legacy exceptions; verify negative tests actually reject violations.
|
||||
- Review the manifest mapping, byte-parity/LF policy, exact offline precache and
|
||||
source/output relative-resolution guard; verify missing or overridden LF
|
||||
declarations fail publication and CI even when output bytes already match.
|
||||
- Reproduce the focused tests and inspect the skipped-image prerequisite.
|
||||
- Confirm no frontend compiler/runtime dependency is required at startup and
|
||||
inspect the isolated Python/Docker probe's limits.
|
||||
- Review overlap with #6470/#6695/#6227/#6627 and the Notes backend follow-ups.
|
||||
- Treat the ADR as awaiting adoption review, and the Notes/H documents as plans.
|
||||
- Authorize any commit/push/PR action separately. Do not begin Notes on this branch.
|
||||
|
||||
## Required next reviews and continuation
|
||||
|
||||
1. Review this F1 working diff. On explicit authorization, commit and submit only
|
||||
the foundation-only F1 PR to official dev using the normal maintainer workflow.
|
||||
2. Coordinate the existing #6227 offline fix and platform release validation as
|
||||
separate work. Do not bundle that behavioral change into F1.
|
||||
3. After F1 integration and Notes implementation authorization, create one Notes
|
||||
worktree and branch from the latest official dev, preserving any subsequently
|
||||
merged Notes changes. Use one comprehensive draft PR targeting official dev.
|
||||
4. Complete N0 through N6 from the [Notes roadmap](notes-roadmap.md) on that same
|
||||
branch, recording tests, review and rollback evidence at each checkpoint. Include
|
||||
the final reference case study in the same PR and keep it a draft until the full
|
||||
decomposition and regression/compatibility acceptance gates pass. Recurrence
|
||||
extraction follows characterization as an internal stage, not another PR.
|
||||
5. Complete H only after Notes integration and recipient reproduction. Transfer
|
||||
future frontend work to the designated maintainer and stop this program.
|
||||
|
||||
Another maintainer can start at the handbook, run the documented source/parity,
|
||||
architecture, runtime and behavior checks, inspect this report and select the
|
||||
next authorized slice without relying on unwritten architecture decisions.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"modules": [
|
||||
{
|
||||
"source": "shared/actionMenuOrder.js",
|
||||
"deployment": "js/actionMenuOrder.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Stable semantic ordering for item-level action menus. */
|
||||
const COMMON_ACTION_ORDER = [
|
||||
{ rank: 200, pattern: /^(rename|change name)\b/i },
|
||||
{ rank: 650, pattern: /^select\b/i },
|
||||
{ rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i },
|
||||
{ rank: 500, pattern: /^(copy|clone|duplicate)\b/i },
|
||||
{ rank: 550, pattern: /^(export|download)\b/i },
|
||||
{ rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i },
|
||||
{ rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i },
|
||||
{ rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i },
|
||||
{ rank: 1000, pattern: /^cancel\b/i },
|
||||
];
|
||||
|
||||
// Canonical Select glyph for item-level menus. Toolbar toggles may use the
|
||||
// smaller version, but dropdown rows should all use this exact SVG.
|
||||
export const SELECT_MENU_ICON = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
export function actionMenuRank(item) {
|
||||
if (Number.isFinite(item?.menuOrder)) return item.menuOrder;
|
||||
const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim();
|
||||
return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100;
|
||||
}
|
||||
|
||||
export function orderActionMenuItems(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, index, rank: actionMenuRank(item) }))
|
||||
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
.map(entry => entry.item);
|
||||
}
|
||||
Generated
+25
-29
@@ -6,14 +6,15 @@
|
||||
"": {
|
||||
"name": "odysseus",
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
"@playwright/test": "^1.62.1"
|
||||
"@antithesishq/bombadil": "^0.7.8",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"acorn": "8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antithesishq/bombadil": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.0.tgz",
|
||||
"integrity": "sha512-alJmnphJ/iUoL5mCsnV3DwtajGy/sEQ3NJJCiMhgjqXshSq2BUtAs0vqdXEiiSkB8HbsOX5CLrAcaogYdwfAJg==",
|
||||
"version": "0.7.8",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.8.tgz",
|
||||
"integrity": "sha512-B2o5l4tUauliN8SNQOcQPXsfU0YSyZUP+3HWtHmKIvTPD4fqzEGl+7v7pEx3G5oJf8BYBckxPO0Pxves7yZfyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -21,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
|
||||
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -36,44 +37,39 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"node_modules/acorn": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+3
-2
@@ -12,7 +12,8 @@
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
"@playwright/test": "^1.62.1"
|
||||
"@antithesishq/bombadil": "^0.7.8",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"acorn": "8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +551,7 @@ async def auto_name_session(session_manager, sess):
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
# Find first user message
|
||||
first_msg = ""
|
||||
@@ -603,6 +604,7 @@ async def auto_name_session(session_manager, sess):
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
|
||||
title = title.strip().strip('"\'').strip()
|
||||
@@ -1010,6 +1012,7 @@ async def build_chat_context(
|
||||
chat_processor,
|
||||
message: str,
|
||||
session_id: str,
|
||||
selected_notes_context: str | None = None,
|
||||
preset_id=None,
|
||||
att_ids: list = None,
|
||||
use_web=None,
|
||||
@@ -1182,6 +1185,23 @@ async def build_chat_context(
|
||||
if search_context and allow_tool_preprocessing and not casual_low_signal:
|
||||
preface.append(untrusted_context_message("prefetched search context", search_context))
|
||||
|
||||
# Inject selected notes as context
|
||||
if selected_notes_context:
|
||||
selected_notes_context = (
|
||||
"The following selected notes and todos are reference context explicitly chosen "
|
||||
"by the user. Use them directly when relevant to the user's request. Do not "
|
||||
"ask for permission to use this context, and do not call note-management "
|
||||
"tools just to retrieve the same notes; use those tools only for a requested "
|
||||
"change or when this context is insufficient.\n\n"
|
||||
+ selected_notes_context
|
||||
)
|
||||
preface.append(
|
||||
untrusted_context_message(
|
||||
"Selected notes and todos",
|
||||
selected_notes_context,
|
||||
)
|
||||
)
|
||||
|
||||
# YouTube transcripts
|
||||
for transcript in preprocessed.youtube_transcripts:
|
||||
preface.append(untrusted_context_message("youtube transcript", transcript))
|
||||
|
||||
+82
-1
@@ -58,7 +58,7 @@ from src.auth_helpers import (
|
||||
from routes.session_routes import _verify_session_owner
|
||||
from routes.document_helpers import _owner_session_filter
|
||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note
|
||||
from core.database import Document as DBDocument, ModelEndpoint
|
||||
from core.log_safety import redact_url
|
||||
from routes.research_routes import _resolve_research_endpoint
|
||||
@@ -2153,6 +2153,68 @@ def _set_user_time_from_request(request: Request) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_selected_note_ids(raw) -> list[str]:
|
||||
"""Validate user-selected Note IDs before passing them to SQLAlchemy."""
|
||||
if raw is None or raw == "":
|
||||
return []
|
||||
if not isinstance(raw, str) or len(raw) > 8192:
|
||||
raise HTTPException(400, "Invalid selected_note_ids payload")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(400, "Invalid selected_note_ids JSON") from exc
|
||||
if (
|
||||
not isinstance(parsed, list)
|
||||
or len(parsed) > 50
|
||||
or any(
|
||||
not isinstance(note_id, str)
|
||||
or not note_id.strip()
|
||||
or len(note_id) > 128
|
||||
for note_id in parsed
|
||||
)
|
||||
):
|
||||
raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs")
|
||||
return list(dict.fromkeys(parsed))
|
||||
|
||||
|
||||
def _format_selected_notes_context(notes) -> str:
|
||||
"""Format selected Notes/Todos as reference context for the model."""
|
||||
sections = []
|
||||
|
||||
for index, note in enumerate(notes, start=1):
|
||||
lines = [
|
||||
f"--- Selected Note {index} ---",
|
||||
f"Title: {note.title or '(untitled)'}",
|
||||
f"Type: {note.note_type or 'note'}",
|
||||
]
|
||||
|
||||
if note.content:
|
||||
lines.extend([
|
||||
"Content:",
|
||||
str(note.content),
|
||||
])
|
||||
|
||||
if note.items:
|
||||
try:
|
||||
items = json.loads(note.items)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
items = []
|
||||
|
||||
if isinstance(items, list):
|
||||
lines.append("Items:")
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
status = "done" if item.get("done") else "pending"
|
||||
text = str(item.get("text") or "").strip()
|
||||
|
||||
if text:
|
||||
lines.append(f"- [{status}] {text}")
|
||||
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager):
|
||||
"""Use an explicit request override, then fall back to the active preset."""
|
||||
@@ -2423,6 +2485,8 @@ def setup_chat_routes(
|
||||
form_data = await request.form()
|
||||
message = form_data.get("message")
|
||||
session = form_data.get("session")
|
||||
selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids"))
|
||||
|
||||
attachments = form_data.get("attachments")
|
||||
use_web = form_data.get("use_web")
|
||||
use_research = form_data.get("use_research")
|
||||
@@ -2730,6 +2794,22 @@ def setup_chat_routes(
|
||||
current_rejected=workspace_rejected,
|
||||
)
|
||||
owner = effective_user(request)
|
||||
selected_notes = []
|
||||
if selected_note_ids:
|
||||
notes_db = SessionLocal()
|
||||
try:
|
||||
selected_notes = (
|
||||
notes_db.query(Note)
|
||||
.filter(
|
||||
Note.id.in_(selected_note_ids),
|
||||
Note.owner == owner,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
notes_db.close()
|
||||
selected_notes_context = _format_selected_notes_context(selected_notes)
|
||||
|
||||
if tool_approval_id:
|
||||
_reject_delegated_tool_approval(request)
|
||||
from src.agent_runtime.authority import require_user_approval_request
|
||||
@@ -3001,6 +3081,7 @@ def setup_chat_routes(
|
||||
sess, request, chat_handler, chat_processor,
|
||||
message=message,
|
||||
session_id=session,
|
||||
selected_notes_context=selected_notes_context,
|
||||
preset_id=preset_id,
|
||||
att_ids=att_ids,
|
||||
use_web=use_web,
|
||||
|
||||
@@ -443,9 +443,11 @@ async def _generate_email_summary(
|
||||
headers: dict | None = None,
|
||||
max_tokens: int = 8192,
|
||||
timeout: int = 180,
|
||||
owner: str | None = None,
|
||||
) -> str:
|
||||
"""Generate an interactive email summary through the shared LLM adapter."""
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
raw = await llm_call_async(
|
||||
url=url,
|
||||
@@ -456,6 +458,7 @@ async def _generate_email_summary(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
workload="foreground",
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
return _normalize_email_summary(raw)
|
||||
|
||||
|
||||
@@ -1031,6 +1031,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=account_owner or None,
|
||||
temperature=0.7, max_tokens=1024, timeout=90,
|
||||
reasoning_effort="",
|
||||
)
|
||||
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
||||
if reply:
|
||||
|
||||
@@ -5920,7 +5920,12 @@ def setup_email_routes():
|
||||
},
|
||||
]
|
||||
|
||||
style = await llm_call_async(url, model, messages, headers=headers, max_tokens=2048)
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
style = await llm_call_async(
|
||||
url, model, messages, headers=headers, max_tokens=2048,
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
style = _strip_think(style or "")
|
||||
if not style:
|
||||
return {"success": False, "error": "LLM failed to generate style description"}
|
||||
@@ -6004,6 +6009,7 @@ def setup_email_routes():
|
||||
headers=req_headers,
|
||||
max_tokens=8192,
|
||||
timeout=180,
|
||||
owner=owner,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -6125,6 +6131,8 @@ def setup_email_routes():
|
||||
if not candidates:
|
||||
return {"success": False, "error": "No LLM endpoint configured"}
|
||||
|
||||
from src.utility_effort import candidate_effort_factory
|
||||
|
||||
content = await llm_call_async_with_fallback(
|
||||
candidates,
|
||||
messages=[
|
||||
@@ -6152,6 +6160,7 @@ def setup_email_routes():
|
||||
temperature=0.2,
|
||||
max_tokens=8192,
|
||||
timeout=180,
|
||||
candidate_request_factory=candidate_effort_factory(owner),
|
||||
)
|
||||
model = candidates[0][1] if candidates else ""
|
||||
content = (content or "").strip()
|
||||
|
||||
@@ -237,6 +237,18 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int |
|
||||
return None
|
||||
|
||||
|
||||
def _effort_mode(model: str, raw_effort: Any) -> str:
|
||||
"""Session ``thinking_mode`` for a picked effort: ``effort:<level>`` or ``off``.
|
||||
|
||||
Validated like the chat routes do, so llama-swap advertised efforts persist
|
||||
as well as ChatGPT subscription levels.
|
||||
"""
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
effort = validate_reasoning_effort(model, raw_effort)
|
||||
return f"effort:{effort}" if effort else "off"
|
||||
|
||||
|
||||
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
router = APIRouter(
|
||||
tags=["history"],
|
||||
@@ -391,53 +403,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),
|
||||
@@ -1058,26 +1043,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
mode = getattr(session, "thinking_mode", "off") or "off"
|
||||
raw_effort = body.get("reasoning_effort")
|
||||
if raw_effort is not None:
|
||||
clean_effort = str(raw_effort).strip().lower()
|
||||
if clean_effort in {"", "default"}:
|
||||
mode = "off"
|
||||
else:
|
||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
||||
meta = get_chatgpt_model_metadata(session.model)
|
||||
if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]:
|
||||
mode = f"effort:{clean_effort}"
|
||||
else:
|
||||
mode = "off"
|
||||
mode = _effort_mode(session.model, raw_effort)
|
||||
elif "thinking_mode" in body:
|
||||
raw_mode = str(body.get("thinking_mode") or "").strip().lower()
|
||||
if raw_mode.startswith("effort:"):
|
||||
clean_effort = raw_mode[7:].strip()
|
||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
||||
meta = get_chatgpt_model_metadata(session.model)
|
||||
if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]:
|
||||
mode = f"effort:{clean_effort}"
|
||||
else:
|
||||
mode = "off"
|
||||
mode = _effort_mode(session.model, raw_mode[7:])
|
||||
elif raw_mode in {"", "on", "off"}:
|
||||
mode = raw_mode
|
||||
from src.model_profiles import supports_user_thinking_toggle
|
||||
@@ -1156,7 +1126,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
)
|
||||
|
||||
# Use utility model if available
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||
from src.utility_effort import effort_for_call
|
||||
util_url, util_model, util_headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=getattr(session, "endpoint_url", None),
|
||||
fallback_model=getattr(session, "model", None),
|
||||
fallback_headers=getattr(session, "headers", None),
|
||||
owner=owner or None,
|
||||
)
|
||||
compact_url = util_url or session.endpoint_url
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
@@ -1172,6 +1149,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
],
|
||||
temperature=0.2, max_tokens=1024,
|
||||
headers=compact_headers, timeout=30,
|
||||
reasoning_effort=effort_for_call(compact_url, compact_model, owner, session),
|
||||
)
|
||||
summary = normalize_compaction_summary(summary)
|
||||
|
||||
|
||||
@@ -261,6 +261,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||
)
|
||||
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
try:
|
||||
suggestion_text = await llm_call_async(
|
||||
t_url,
|
||||
@@ -269,6 +271,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
temperature=0.2,
|
||||
max_tokens=500,
|
||||
headers=t_headers,
|
||||
reasoning_effort=effort_for_call(t_url, t_model, _owner(request), sess),
|
||||
)
|
||||
try:
|
||||
suggestions = json.loads(suggestion_text)
|
||||
@@ -349,6 +352,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
endpoint_url = None
|
||||
model = None
|
||||
headers = {}
|
||||
sess = None
|
||||
|
||||
user = _owner(request)
|
||||
|
||||
@@ -453,6 +457,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
"Return ONLY valid JSON, no markdown fences."
|
||||
)
|
||||
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
try:
|
||||
raw = await llm_call_async(
|
||||
endpoint_url,
|
||||
@@ -464,6 +470,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
temperature=0.2,
|
||||
max_tokens=2000,
|
||||
headers=headers,
|
||||
reasoning_effort=effort_for_call(endpoint_url, model, user, sess),
|
||||
)
|
||||
|
||||
# Parse JSON
|
||||
|
||||
@@ -509,6 +509,7 @@ def setup_session_routes(
|
||||
"has_documents": s.id in doc_session_ids,
|
||||
"has_images": s.id in img_session_ids,
|
||||
"mode": s.mode,
|
||||
"thinking_mode": s.thinking_mode or "off",
|
||||
"message_count": s.message_count or 0,
|
||||
})
|
||||
finally:
|
||||
@@ -1042,6 +1043,7 @@ def setup_session_routes(
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"model": s.model,
|
||||
"thinking_mode": s.thinking_mode or "off",
|
||||
"message_count": s.message_count or 0,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
@@ -1238,9 +1240,16 @@ def setup_session_routes(
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
owner = getattr(session, "owner", None) or effective_user(request)
|
||||
url, model, headers = resolve_endpoint("utility", owner=owner)
|
||||
url, model, headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=getattr(session, "endpoint_url", None),
|
||||
fallback_model=getattr(session, "model", None),
|
||||
fallback_headers=getattr(session, "headers", None),
|
||||
owner=owner,
|
||||
)
|
||||
if not url or not model:
|
||||
url, model, headers = session.endpoint_url, session.model, session.headers
|
||||
if not url or not model:
|
||||
@@ -1268,6 +1277,7 @@ def setup_session_routes(
|
||||
max_tokens=1024,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
reasoning_effort=effort_for_call(url, model, owner, session),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Manual compaction failed: %s", e)
|
||||
@@ -1311,6 +1321,7 @@ def setup_session_routes(
|
||||
users can clean junk without spending tokens.
|
||||
"""
|
||||
from src.llm_core import llm_call
|
||||
from src.utility_effort import effort_for_call
|
||||
user = effective_user(request)
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
@@ -1483,7 +1494,8 @@ def setup_session_routes(
|
||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
|
||||
reasoning_effort=effort_for_call(url, model, user))
|
||||
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||
# Extract JSON from response — handle markdown fences, leading text,
|
||||
# reasoning-model <think> blocks, and trailing commas.
|
||||
|
||||
+125
-105
@@ -9,6 +9,7 @@ The on-disk format is SKILL.md (frontmatter + structured body) under
|
||||
|
||||
import logging
|
||||
import re
|
||||
from contextlib import aclosing
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
@@ -20,6 +21,7 @@ from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.text_helpers import strip_closed_think_blocks
|
||||
from src.utility_effort import effort_for_call
|
||||
from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -169,7 +171,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
baseline_transcript: str = "",
|
||||
skill_stats: Optional[dict] = None,
|
||||
baseline_stats: Optional[dict] = None,
|
||||
workload: str = "foreground") -> dict:
|
||||
workload: str = "foreground", owner: Optional[str] = None) -> dict:
|
||||
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
|
||||
|
||||
Robust against local reasoning models (strips <think>, lenient JSON,
|
||||
@@ -348,6 +350,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
url, model, msgs,
|
||||
temperature=0.1, max_tokens=32768, headers=headers, timeout=180,
|
||||
workload=workload,
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't give up on a transient first-attempt error — let the second
|
||||
@@ -367,7 +370,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
|
||||
async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: str,
|
||||
headers: Optional[dict],
|
||||
workload: str = "foreground") -> Optional[dict]:
|
||||
workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]:
|
||||
"""Advisory judge: is this skill worth keeping, or is it redundant / trivially
|
||||
unnecessary? Sees the OTHER skills' names+descriptions so it can spot
|
||||
duplicates. Returns {necessary, redundant_with, reason} or None. Never acts —
|
||||
@@ -400,6 +403,7 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
|
||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||
temperature=0.1, max_tokens=8192, headers=headers, timeout=120,
|
||||
workload=workload,
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Necessity check failed: {e}")
|
||||
@@ -452,7 +456,7 @@ def _should_check_retrieval_precision(skill: dict) -> bool:
|
||||
async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
||||
url: str, model: str,
|
||||
headers: Optional[dict],
|
||||
workload: str = "foreground") -> Optional[dict]:
|
||||
workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]:
|
||||
"""Advisory judge: would this skill's metadata make retrieval over-select it?
|
||||
|
||||
This is distinct from "does the procedure work?". It asks whether tags,
|
||||
@@ -490,6 +494,7 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||
temperature=0.1, max_tokens=4096, headers=headers, timeout=90,
|
||||
workload=workload,
|
||||
reasoning_effort=effort_for_call(url, model, owner),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Retrieval precision check failed: {e}")
|
||||
@@ -558,70 +563,74 @@ async def _run_skill_test_job(
|
||||
|
||||
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
|
||||
try:
|
||||
async for chunk in stream_agent_loop(
|
||||
# aclosing: pausing for an approval leaves this loop early, and the
|
||||
# stream's ContextVar cleanup must run in this task, not in asyncio's
|
||||
# finalizer (#6635).
|
||||
async with aclosing(stream_agent_loop(
|
||||
url, model, messages, headers=headers,
|
||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
||||
exact_approval=exact_approval,
|
||||
request_authority=(request_authority or (
|
||||
exact_approval.pending.request_authority if exact_approval is not None else None
|
||||
) or RequestAuthority.empty(owner=owner)),
|
||||
):
|
||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||
continue
|
||||
try:
|
||||
d = _json.loads(chunk[6:])
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("delta"):
|
||||
say_buf.append(d["delta"]); transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
_flush_say()
|
||||
skill_stats["tool_calls"] += 1
|
||||
cmd = str(d.get("command") or d.get("args") or "")[:300]
|
||||
log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd})
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
_flush_say()
|
||||
out = str(d.get("output") or "")[:600]
|
||||
tool_log = {"type": "tool_output", "output": out}
|
||||
approval = d.get("ask_user")
|
||||
if isinstance(approval, dict):
|
||||
tool_log["ask_user"] = approval
|
||||
log.append(tool_log)
|
||||
transcript.append(f"[output] {out}\n")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
and approval.get("approval_id")
|
||||
):
|
||||
# Manual skill tests have their own polling UI instead of a
|
||||
# chat session. Pause the run and retain only server-side
|
||||
# continuation state until the same owner approves/denies
|
||||
# this exact sealed action.
|
||||
job["status"] = "awaiting_approval"
|
||||
job["approval"] = approval
|
||||
job["_transcript"] = transcript
|
||||
return
|
||||
elif d.get("type") == "agent_step":
|
||||
_flush_say()
|
||||
)) as stream:
|
||||
async for chunk in stream:
|
||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||
continue
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
log.append({"type": "agent_step", "round": d.get("round")})
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if len(log) > 600:
|
||||
del log[0:len(log) - 600]
|
||||
d = _json.loads(chunk[6:])
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("delta"):
|
||||
say_buf.append(d["delta"]); transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
_flush_say()
|
||||
skill_stats["tool_calls"] += 1
|
||||
cmd = str(d.get("command") or d.get("args") or "")[:300]
|
||||
log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd})
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
_flush_say()
|
||||
out = str(d.get("output") or "")[:600]
|
||||
tool_log = {"type": "tool_output", "output": out}
|
||||
approval = d.get("ask_user")
|
||||
if isinstance(approval, dict):
|
||||
tool_log["ask_user"] = approval
|
||||
log.append(tool_log)
|
||||
transcript.append(f"[output] {out}\n")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
and approval.get("approval_id")
|
||||
):
|
||||
# Manual skill tests have their own polling UI instead of a
|
||||
# chat session. Pause the run and retain only server-side
|
||||
# continuation state until the same owner approves/denies
|
||||
# this exact sealed action.
|
||||
job["status"] = "awaiting_approval"
|
||||
job["approval"] = approval
|
||||
job["_transcript"] = transcript
|
||||
return
|
||||
elif d.get("type") == "agent_step":
|
||||
_flush_say()
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
log.append({"type": "agent_step", "round": d.get("round")})
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if len(log) > 600:
|
||||
del log[0:len(log) - 600]
|
||||
_flush_say()
|
||||
except Exception as e:
|
||||
_flush_say()
|
||||
@@ -662,6 +671,7 @@ async def _run_skill_test_job(
|
||||
baseline_transcript=baseline_transcript,
|
||||
skill_stats=skill_stats,
|
||||
baseline_stats=baseline_stats,
|
||||
owner=owner,
|
||||
)
|
||||
except Exception as e:
|
||||
job["verdict"] = {"verdict": "unknown", "confidence": 0, "summary": f"Eval failed: {e}", "issues": []}
|
||||
@@ -1054,53 +1064,55 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
|
||||
# OpenAI-compat) generate an empty completion, which manifested as
|
||||
# the skill test returning nothing while chat (which carries its
|
||||
# preset's max_tokens) worked. 4096 matches the chat default.
|
||||
async for chunk in stream_agent_loop(
|
||||
# aclosing: stopping for an approval leaves this loop early (#6635).
|
||||
async with aclosing(stream_agent_loop(
|
||||
url, model, messages, headers=headers,
|
||||
temperature=0.3, max_tokens=4096, max_rounds=8,
|
||||
owner=owner, workload=workload, suppress_skills=True,
|
||||
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
|
||||
):
|
||||
# Streams can include an SSE event line before the data line,
|
||||
# notably `event: error`. Do not silently discard those failures.
|
||||
payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None)
|
||||
if payload is None or payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
d = _json.loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("error") or d.get("type") == "error":
|
||||
raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed"))
|
||||
if d.get("delta"):
|
||||
transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
stats["tool_calls"] += 1
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||
approval = d.get("ask_user")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
):
|
||||
approval_required = approval
|
||||
break
|
||||
elif d.get("type") == "agent_step":
|
||||
)) as stream:
|
||||
async for chunk in stream:
|
||||
# Streams can include an SSE event line before the data line,
|
||||
# notably `event: error`. Do not silently discard those failures.
|
||||
payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None)
|
||||
if payload is None or payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
d = _json.loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
if d.get("error") or d.get("type") == "error":
|
||||
raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed"))
|
||||
if d.get("delta"):
|
||||
transcript.append(d["delta"])
|
||||
elif d.get("type") == "tool_start":
|
||||
stats["tool_calls"] += 1
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||
approval = d.get("ask_user")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
):
|
||||
approval_required = approval
|
||||
break
|
||||
elif d.get("type") == "agent_step":
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(d.get("round") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
elif d.get("type") == "metrics":
|
||||
data = d.get("data") or {}
|
||||
try:
|
||||
stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except SkillAuditUnavailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1150,12 +1162,13 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner,
|
||||
headers,
|
||||
skill_stats=stats,
|
||||
workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
return text, verdict
|
||||
|
||||
|
||||
async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url, model, headers,
|
||||
workload: str = "foreground"):
|
||||
workload: str = "foreground", owner: Optional[str] = None):
|
||||
"""Have a model rewrite SKILL.md to fix the reviewer's issues. Returns the
|
||||
corrected markdown, or None if it couldn't produce a usable change."""
|
||||
import re as _re
|
||||
@@ -1185,7 +1198,8 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
|
||||
[{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": user_msg}],
|
||||
temperature=0.2, max_tokens=16384, headers=headers, timeout=180,
|
||||
workload=workload)
|
||||
workload=workload,
|
||||
reasoning_effort=effort_for_call(url, model, owner))
|
||||
except Exception as e:
|
||||
logger.warning(f"Audit: improve call failed: {e}")
|
||||
return None
|
||||
@@ -1255,6 +1269,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
]
|
||||
nec = await _eval_skill_necessity(
|
||||
md, others, url, model, headers, workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
if nec is not None:
|
||||
skills_manager.set_necessity(name, nec.get("necessary", True),
|
||||
@@ -1271,6 +1286,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
if _should_check_retrieval_precision(skill):
|
||||
rp = await _eval_skill_retrieval_precision(
|
||||
md, others, url, model, headers, workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
if rp and not rp.get("ok"):
|
||||
issues = rp.get("issues") or ["metadata: retrieval: narrow tags and when_to_use to the intended trigger"]
|
||||
@@ -1281,7 +1297,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
"summary": rp.get("summary") or "Retrieval metadata is too broad.",
|
||||
"issues": issues,
|
||||
}, "Retrieval audit only: the procedure may work, but matching metadata is too broad.",
|
||||
url, model, headers, workload=workload)
|
||||
url, model, headers, workload=workload, owner=owner)
|
||||
if fixed and fixed.strip() != md.strip() and _apply_skill_md(skills_manager, name, fixed, owner):
|
||||
md = fixed
|
||||
refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None)
|
||||
@@ -1357,6 +1373,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
skill_stats=skill_stats,
|
||||
baseline_stats=baseline_stats,
|
||||
workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
v = verdict.get("verdict")
|
||||
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
|
||||
@@ -1390,6 +1407,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
log(f"{name}: pass, but fixing {len(meta_issues)} metadata issue(s)…")
|
||||
fixed = await _improve_skill_md(
|
||||
md, verdict, transcript, url, model, headers, workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
if fixed and fixed.strip() != md.strip():
|
||||
_apply_skill_md(skills_manager, name, fixed, owner)
|
||||
@@ -1423,6 +1441,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
log(f"{name}: self-editing to fix issues…")
|
||||
new_md = await _improve_skill_md(
|
||||
md, verdict, transcript, url, model, headers, workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
if new_md and new_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, new_md, owner):
|
||||
md = new_md
|
||||
@@ -1457,6 +1476,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
log(f"{name}: teacher {t_model} rewriting the skill…")
|
||||
t_md = await _improve_skill_md(
|
||||
md, verdict, transcript, t_url, t_model, t_headers, workload=workload,
|
||||
owner=owner,
|
||||
)
|
||||
if t_md and t_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, t_md, owner):
|
||||
md = t_md
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish explicitly owned native ES modules; never run at application startup."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def module_path(base: Path, relative: str) -> Path:
|
||||
"""Accept canonical JS paths only, contained within the declared tree."""
|
||||
if not isinstance(relative, str):
|
||||
raise ValueError("module paths must be strings")
|
||||
parts = PurePosixPath(relative)
|
||||
if (
|
||||
parts.is_absolute()
|
||||
or parts.as_posix() != relative
|
||||
or ".." in parts.parts
|
||||
or "\\" in relative
|
||||
or "?" in relative
|
||||
or "#" in relative
|
||||
or parts.suffix != ".js"
|
||||
):
|
||||
raise ValueError(f"invalid module path: {relative!r}")
|
||||
path = base / relative
|
||||
if not path.resolve().is_relative_to(base.resolve()):
|
||||
raise ValueError(f"module path escapes its tree: {relative!r}")
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise ValueError(f"symlink in module path: {relative!r}")
|
||||
return path
|
||||
|
||||
|
||||
def check_lf_attributes(root: Path, paths: set[Path]) -> None:
|
||||
"""Require portable, repository-owned LF attributes for every mapped path.
|
||||
|
||||
Ask Git in an isolated temporary repository so global attributes, local
|
||||
info/attributes and alternate Git directories cannot mask missing rules.
|
||||
Copy only attribute files at ancestors of the manifest's source/output paths.
|
||||
"""
|
||||
git = shutil.which("git")
|
||||
if git is None:
|
||||
raise ValueError("Git is required for frontend publication attribute checks")
|
||||
attributes = {root / ".gitattributes"}
|
||||
for path in paths:
|
||||
for parent in path.parents:
|
||||
if parent == root:
|
||||
break
|
||||
attributes.add(parent / ".gitattributes")
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
||||
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
|
||||
relative = sorted(path.relative_to(root).as_posix() for path in paths)
|
||||
with tempfile.TemporaryDirectory(prefix="ody-frontend-attributes-") as directory:
|
||||
target = Path(directory)
|
||||
for attribute in attributes:
|
||||
if attribute.is_symlink():
|
||||
raise ValueError(f"symlink attribute file: {attribute.relative_to(root)}")
|
||||
if attribute.is_file():
|
||||
copy = target / attribute.relative_to(root)
|
||||
copy.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy.write_bytes(attribute.read_bytes())
|
||||
command = [git, "-c", f"core.attributesFile={os.devnull}", "-C", str(target)]
|
||||
try:
|
||||
subprocess.run(command + ["init", "--quiet", "--template="], env=env, check=True, capture_output=True, timeout=15)
|
||||
result = subprocess.run(
|
||||
command + ["check-attr", "-z", "--stdin", "text", "eol"],
|
||||
input=("\0".join(relative) + "\0").encode("utf-8"),
|
||||
env=env, check=True, capture_output=True, timeout=15,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||
raise ValueError("Git frontend attribute check failed") from error
|
||||
fields = result.stdout.decode("utf-8").split("\0")[:-1]
|
||||
effective = {name: {} for name in relative}
|
||||
for index in range(0, len(fields), 3):
|
||||
name, attribute, value = fields[index:index + 3]
|
||||
effective[name][attribute] = value
|
||||
invalid = [
|
||||
f"{name} (text={values.get('text')}, eol={values.get('eol')})"
|
||||
for name, values in effective.items()
|
||||
if values != {"text": "set", "eol": "lf"}
|
||||
]
|
||||
if invalid:
|
||||
raise ValueError("manifest paths require repository-owned 'text eol=lf' Git attributes: " + ", ".join(invalid))
|
||||
|
||||
|
||||
def synchronize(root: Path = ROOT, *, check: bool = False) -> list[str]:
|
||||
"""Validate the entire plan before writing. Return stale deployment paths."""
|
||||
root = root.resolve()
|
||||
source_root = root / "frontend" / "src"
|
||||
static_root = root / "static"
|
||||
manifest = json.loads((root / "frontend" / "modules.json").read_text(encoding="utf-8"))
|
||||
if not isinstance(manifest, dict) or manifest.get("version") != 1 or not isinstance(manifest.get("modules"), list):
|
||||
raise ValueError("expected frontend module manifest version 1")
|
||||
if not manifest["modules"]:
|
||||
raise ValueError("module manifest must not be empty")
|
||||
sources, outputs, plan = set(), set(), []
|
||||
for entry in manifest["modules"]:
|
||||
source = module_path(source_root, entry["source"])
|
||||
output = module_path(static_root, entry["deployment"])
|
||||
if not entry["deployment"].startswith("js/"):
|
||||
raise ValueError("deployment modules must be under static/js/")
|
||||
if source in sources or output in outputs:
|
||||
raise ValueError("each source and deployment path must be unique")
|
||||
sources.add(source)
|
||||
outputs.add(output)
|
||||
content = source.read_bytes()
|
||||
if output.exists() and not output.is_file():
|
||||
raise ValueError(f"deployment path is not a file: {output}")
|
||||
plan.append((output, content))
|
||||
unlisted = set(source_root.rglob("*.js")) - sources
|
||||
if unlisted:
|
||||
raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}")
|
||||
check_lf_attributes(root, sources | outputs)
|
||||
stale = []
|
||||
for output, content in plan:
|
||||
if not output.is_file() or output.read_bytes() != content:
|
||||
stale.append(output.relative_to(root).as_posix())
|
||||
if not check:
|
||||
for output, content in plan:
|
||||
if output.relative_to(root).as_posix() in stale:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(content)
|
||||
return stale
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail on invalid attributes or stale output; write no deployment files")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
stale = synchronize(check=args.check)
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
parser.exit(2, f"frontend: {error}\n")
|
||||
if args.check and stale:
|
||||
print("Stale frontend deployment modules: " + ", ".join(stale))
|
||||
print("Run python scripts/build_frontend.py and include the source and output changes together.")
|
||||
return 1
|
||||
print(f"Frontend {'check passed' if args.check else 'published'} ({len(stale)} changed modules).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
/** Architecture gates for maintained source. Legacy static modules stay under existing guards. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from 'acorn';
|
||||
|
||||
const layers = ['domain', 'application', 'infrastructure', 'ui'];
|
||||
const platformIdentifiers = new Set(['window', 'document', 'globalThis', 'navigator', 'localStorage', 'sessionStorage', 'fetch', 'Notification', 'indexedDB', 'WebSocket', 'XMLHttpRequest', 'Worker', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'requestAnimationFrame', 'cancelAnimationFrame', 'performance', 'eval', 'Function']);
|
||||
|
||||
function ownership(source) {
|
||||
const parts = source.split('/');
|
||||
if (parts[0] === 'app' && parts.length >= 2) return { feature: 'app', layer: 'index' };
|
||||
if (parts[0] === 'shared') {
|
||||
if (parts.length === 2) return { feature: 'shared', layer: 'domain' };
|
||||
const layer = { domain: 'domain', platform: 'infrastructure', ui: 'ui' }[parts[1]];
|
||||
if (layer) return { feature: 'shared', layer };
|
||||
}
|
||||
if (parts[0] === 'features' && parts.length >= 3) {
|
||||
if (['app', 'shared'].includes(parts[1])) throw new Error(`reserved feature name: ${parts[1]}`);
|
||||
if (parts.length === 3 && parts[2] === 'index.js') return { feature: parts[1], layer: 'index' };
|
||||
if (layers.includes(parts[2]) && parts.length >= 4) return { feature: parts[1], layer: parts[2] };
|
||||
}
|
||||
throw new Error(`unsupported source ownership: ${source}`);
|
||||
}
|
||||
|
||||
export function allowsDependency(from, to) {
|
||||
const a = ownership(from), b = ownership(to);
|
||||
if (b.feature === 'app') return a.feature === 'app';
|
||||
if (a.feature === 'app') return b.feature === 'shared' || b.layer === 'index';
|
||||
if (b.feature === 'shared') {
|
||||
const shared = { domain: ['domain'], application: ['domain'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'infrastructure', 'ui'], index: layers };
|
||||
return shared[a.layer].includes(b.layer);
|
||||
}
|
||||
if (a.feature === 'shared') return false;
|
||||
if (a.feature !== b.feature) return ['application', 'index'].includes(a.layer) && b.layer === 'index';
|
||||
const internal = { domain: ['domain'], application: ['domain', 'application'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'application', 'ui'], index: layers };
|
||||
return internal[a.layer].includes(b.layer);
|
||||
}
|
||||
|
||||
export function imports(source, { domain = false } = {}) {
|
||||
const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
|
||||
const found = [];
|
||||
function visit(node, parent, field) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
// Domain modules use explicit values/ports, including an injected clock.
|
||||
// Keep platform vocabulary out even when a local binding shadows a global.
|
||||
const propertyName = parent && !parent.computed && (
|
||||
(parent.type === 'MemberExpression' && field === 'property') ||
|
||||
(['Property', 'MethodDefinition'].includes(parent.type) && field === 'key' && !parent.shorthand)
|
||||
);
|
||||
if (domain && !propertyName && node.type === 'Identifier' && platformIdentifiers.has(node.name)) {
|
||||
throw new Error(`platform identifier in domain at line ${node.loc.start.line}: ${node.name}`);
|
||||
}
|
||||
if (domain && ((node.type === 'MemberExpression' && node.object.name === 'Date' && (node.property.name === 'now' || node.property.value === 'now')) || (['CallExpression', 'NewExpression'].includes(node.type) && node.callee.name === 'Date' && !node.arguments.length))) {
|
||||
throw new Error(`ambient clock in domain at line ${node.loc.start.line}; inject time`);
|
||||
}
|
||||
if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(node.type) && node.source) {
|
||||
if (node.source.type !== 'Literal' || typeof node.source.value !== 'string') {
|
||||
throw new Error(`computed import at line ${node.loc.start.line}; use explicit import thunks`);
|
||||
}
|
||||
found.push({ specifier: node.source.value, line: node.loc.start.line, dynamic: node.type === 'ImportExpression' });
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (Array.isArray(value)) value.forEach(item => visit(item, node, key));
|
||||
else if (value && typeof value === 'object' && value.type) visit(value, node, key);
|
||||
}
|
||||
}
|
||||
visit(ast);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function checkArchitecture(root) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json'), 'utf8'));
|
||||
const bySource = new Map(manifest.modules.map(m => [m.source, m]));
|
||||
const byOutput = new Map(manifest.modules.map(m => [`/static/${m.deployment}`, m.source]));
|
||||
const graph = new Map();
|
||||
const errors = [];
|
||||
const sw = fs.readFileSync(path.join(root, 'static/sw.js'), 'utf8');
|
||||
// Parse the actual manifest arrays, not comments or unrelated strings.
|
||||
const swAst = parse(sw, { ecmaVersion: 'latest', sourceType: 'script' });
|
||||
const precached = new Set();
|
||||
for (const statement of swAst.body) {
|
||||
for (const declaration of statement.declarations || []) {
|
||||
if (['PRECACHE', 'PANEL_PRECACHE'].includes(declaration.id.name)) {
|
||||
for (const item of declaration.init.elements) {
|
||||
if (item?.type === 'Literal' && typeof item.value === 'string') precached.add(item.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const module of manifest.modules) {
|
||||
try {
|
||||
const owner = ownership(module.source);
|
||||
const deployed = `/static/${module.deployment}`;
|
||||
if (!precached.has(deployed)) throw new Error(`deployment URL absent from explicit precache: ${deployed}`);
|
||||
const dependencies = [];
|
||||
const exceptions = module.legacyImports || [];
|
||||
const used = new Set();
|
||||
for (const { specifier, line } of imports(fs.readFileSync(path.join(root, 'frontend/src', module.source), 'utf8'), { domain: owner.layer === 'domain' })) {
|
||||
const fail = message => { throw new Error(`line ${line}: ${message}: ${specifier}`); };
|
||||
const runtime = new URL(specifier, `https://odysseus.invalid${deployed}`);
|
||||
if (!runtime.pathname.startsWith('/static/js/') || !runtime.pathname.endsWith('.js') || runtime.pathname.includes('%')) fail('runtime imports must name canonical static JS paths');
|
||||
let target;
|
||||
if (specifier.startsWith('.')) {
|
||||
if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity');
|
||||
target = path.posix.normalize(path.posix.join(path.posix.dirname(module.source), specifier));
|
||||
if (!bySource.has(target)) fail('relative import must name maintained source');
|
||||
if (runtime.pathname !== `/static/${bySource.get(target).deployment}`) fail('source and deployment relative paths resolve differently');
|
||||
} else if (specifier.startsWith('/static/')) {
|
||||
target = byOutput.get(runtime.pathname);
|
||||
if (!target) {
|
||||
const exception = exceptions.find(e => e.specifier === specifier && e.reason?.trim());
|
||||
if (!exception || !['index', 'infrastructure', 'ui'].includes(owner.layer) || owner.feature === 'shared') fail('legacy import requires an explicit module-scoped rationale at a composition, infrastructure or UI boundary');
|
||||
if (!fs.statSync(path.join(root, runtime.pathname.slice(1))).isFile()) fail('legacy target does not exist');
|
||||
used.add(exception);
|
||||
continue;
|
||||
}
|
||||
} else fail('bare, external and source-tree runtime imports are unsupported');
|
||||
if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity');
|
||||
if (!allowsDependency(module.source, target)) fail(`forbidden dependency on ${target}`);
|
||||
dependencies.push(target);
|
||||
}
|
||||
if (exceptions.some(e => !used.has(e))) throw new Error('unused legacy import exception');
|
||||
graph.set(module.source, dependencies);
|
||||
} catch (error) { errors.push(`${module.source}: ${error.message}`); }
|
||||
}
|
||||
const visiting = new Set(), visited = new Set();
|
||||
function visit(source, trail = []) {
|
||||
if (visiting.has(source)) throw new Error(`maintained-source dependency cycle: ${[...trail, source].join(' -> ')}`);
|
||||
if (visited.has(source)) return;
|
||||
visiting.add(source);
|
||||
for (const target of graph.get(source) || []) visit(target, [...trail, source]);
|
||||
visiting.delete(source);
|
||||
visited.add(source);
|
||||
}
|
||||
try { for (const source of graph.keys()) visit(source); }
|
||||
catch (error) { errors.push(error.message); }
|
||||
if (errors.length) throw new Error(errors.join('\n'));
|
||||
return graph.size;
|
||||
}
|
||||
|
||||
export function inventory(root) {
|
||||
const staticRoot = path.join(root, 'static');
|
||||
const files = fs.readdirSync(staticRoot, { recursive: true }).map(p => p.split(path.sep).join('/')).filter(p => p.endsWith('.js') && !p.startsWith('lib/') && !p.startsWith('js/editor/build/')).sort();
|
||||
const graph = new Map(), fan = new Map(), missing = [];
|
||||
let staticImports = 0, dynamicLiteralImports = 0;
|
||||
for (const file of files) {
|
||||
const targets = [];
|
||||
for (const { specifier, dynamic } of imports(fs.readFileSync(path.join(staticRoot, file), 'utf8'))) {
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/static/')) continue;
|
||||
const target = new URL(specifier, `https://odysseus.invalid/static/${file}`).pathname.slice('/static/'.length);
|
||||
if (dynamic) dynamicLiteralImports++;
|
||||
else { staticImports++; targets.push(target); }
|
||||
fan.set(target, (fan.get(target) || 0) + 1);
|
||||
if (!fs.existsSync(path.join(staticRoot, target))) missing.push({ file, specifier });
|
||||
}
|
||||
graph.set(file, targets);
|
||||
}
|
||||
const indices = new Map(), low = new Map(), stack = [], active = new Set(), staticCycles = [];
|
||||
function visit(v) {
|
||||
indices.set(v, indices.size); low.set(v, indices.get(v)); stack.push(v); active.add(v);
|
||||
for (const w of graph.get(v) || []) {
|
||||
if (!indices.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
||||
else if (active.has(w)) low.set(v, Math.min(low.get(v), indices.get(w)));
|
||||
}
|
||||
if (low.get(v) === indices.get(v)) {
|
||||
const component = []; let w;
|
||||
do { w = stack.pop(); active.delete(w); component.push(w); } while (w !== v);
|
||||
if (component.length > 1) staticCycles.push(component.sort());
|
||||
}
|
||||
}
|
||||
for (const file of files) if (!indices.has(file)) visit(file);
|
||||
return { files: files.length, staticImports, dynamicLiteralImports, missing, fanIn: [...fan].sort((a, b) => b[1] - a[1]).slice(0, 8), staticCycles };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
try { console.log(process.argv.includes('--inventory') ? JSON.stringify(inventory(root), null, 2) : `Frontend architecture passed (${checkArchitecture(root)} maintained modules).`); }
|
||||
catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the current Python runtime from an isolated distribution without frontend tooling."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
PROBE = """
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from starlette.testclient import TestClient
|
||||
from app import app
|
||||
assert not Path('frontend').exists()
|
||||
assert not Path('node_modules').exists()
|
||||
assert shutil.which('node') is None
|
||||
with TestClient(app) as client:
|
||||
response = client.get('/static/js/actionMenuOrder.js')
|
||||
assert response.status_code == 200, response.status_code
|
||||
assert response.content == Path('static/js/actionMenuOrder.js').read_bytes()
|
||||
assert response.headers['content-type'].startswith('text/javascript'), response.headers
|
||||
assert response.headers['cache-control'] == 'no-cache', response.headers
|
||||
assert "script-src 'self' 'nonce-" in response.headers['content-security-policy'], response.headers
|
||||
assert client.get('/frontend/src/shared/actionMenuOrder.js').status_code == 404
|
||||
assert client.get('/static/%2e%2e/app.py').status_code == 404
|
||||
shell = client.get('/')
|
||||
assert shell.status_code == 200, shell.status_code
|
||||
assert '{{CSP_NONCE}}' not in shell.text
|
||||
assert '/static/app.js' in shell.text
|
||||
print('PASS: isolated Python lifespan, shell, deployed bytes, MIME, revalidation, CSP and source-tree 404')
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="ody-f1-distribution-") as directory, socket.socket() as chroma_guard:
|
||||
target = Path(directory)
|
||||
for name in ("app.py", "HARNESS_VERSION"):
|
||||
shutil.copy2(ROOT / name, target / name)
|
||||
for name in ("core", "routes", "src", "services", "resources", "mcp_servers", "companion", "config", "static", "scripts"):
|
||||
shutil.copytree(ROOT / name, target / name, ignore=shutil.ignore_patterns("__pycache__", "node_modules", ".env", "secrets.env*", "*.db", "*.sqlite*"))
|
||||
# Reserve a non-listening port so initialization cannot reach a real store.
|
||||
chroma_guard.bind(("127.0.0.1", 0))
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
ODYSSEUS_DATA_DIR=str(target / "data"),
|
||||
DATABASE_URL="sqlite:///:memory:",
|
||||
AUTH_ENABLED="false",
|
||||
ODYSSEUS_STARTUP_WARMUPS="0",
|
||||
ODYSSEUS_TOOL_INDEX_PREWARM="0",
|
||||
CHROMADB_HOST="127.0.0.1",
|
||||
CHROMADB_PORT=str(chroma_guard.getsockname()[1]),
|
||||
FASTEMBED_CACHE_PATH=str(target / "fastembed"),
|
||||
PATH=str(target / "no-development-tools"),
|
||||
)
|
||||
# A caller's Python search path must not import the original checkout.
|
||||
env.pop("PYTHONPATH", None)
|
||||
result = subprocess.run([sys.executable, "-c", PROBE], cwd=target, env=env, capture_output=True, text=True, timeout=60)
|
||||
print(result.stdout, end="")
|
||||
if result.returncode:
|
||||
print(result.stderr[-8000:], file=sys.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -331,6 +331,7 @@ async def extract_and_store(
|
||||
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
# Get last N messages from session
|
||||
messages = session.get_context_messages()
|
||||
@@ -404,6 +405,9 @@ async def extract_and_store(
|
||||
# enough once thinking has room.
|
||||
max_tokens=4096,
|
||||
headers=headers,
|
||||
reasoning_effort=effort_for_call(
|
||||
endpoint_url, model, getattr(session, "owner", None), session
|
||||
),
|
||||
)
|
||||
|
||||
# Parse JSON, tolerating reasoning-model noise (<think> blocks, a
|
||||
@@ -559,6 +563,7 @@ async def update_persona_memory(
|
||||
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
from src.text_helpers import strip_think
|
||||
|
||||
custom = {}
|
||||
@@ -607,6 +612,9 @@ async def update_persona_memory(
|
||||
temperature=0.1,
|
||||
max_tokens=1200,
|
||||
headers=headers,
|
||||
reasoning_effort=effort_for_call(
|
||||
endpoint_url, model, getattr(session, "owner", None), session
|
||||
),
|
||||
)
|
||||
|
||||
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
||||
@@ -643,6 +651,7 @@ async def audit_memories(
|
||||
"""
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
existing = memory_manager.load(owner=owner)
|
||||
if not existing:
|
||||
@@ -691,6 +700,7 @@ async def audit_memories(
|
||||
# Bound the call so the Tidy whirlpool can't spin indefinitely on a
|
||||
# slow/large generation.
|
||||
timeout=120,
|
||||
reasoning_effort=effort_for_call(endpoint_url, model, owner),
|
||||
)
|
||||
|
||||
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
|
||||
|
||||
@@ -149,6 +149,7 @@ async def maybe_extract_skill(
|
||||
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from src.utility_effort import effort_for_call
|
||||
|
||||
# Get recent messages
|
||||
history = session.get_context_messages()
|
||||
@@ -204,6 +205,7 @@ async def maybe_extract_skill(
|
||||
],
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
reasoning_effort=effort_for_call(endpoint_url, model, owner, session),
|
||||
)
|
||||
logger.debug(
|
||||
"[skill-extract] LLM returned in %.1fs (len=%d, head=%r)",
|
||||
|
||||
@@ -745,6 +745,7 @@ class SkillsManager:
|
||||
min_confidence: float = 0.0,
|
||||
available_toolsets: Optional[Iterable[str]] = None,
|
||||
platform: Optional[str] = None,
|
||||
published_only: bool = False,
|
||||
) -> List[Dict]:
|
||||
if skills is None:
|
||||
skills = self.load_all()
|
||||
@@ -775,9 +776,15 @@ class SkillsManager:
|
||||
if not skill.get("platforms") or platform in skill.get("platforms", [])
|
||||
]
|
||||
# Prompt injection is fail-closed for user skills. Built-ins are
|
||||
# shipped procedures; every other skill needs a passing audit and a
|
||||
# confidence score at the user's current threshold.
|
||||
if min_confidence > 0:
|
||||
# shipped procedures. With Auto-approve OFF (published_only), the
|
||||
# user's publish is the approval; otherwise a skill needs a passing
|
||||
# audit and a confidence score at the user's current threshold.
|
||||
if published_only:
|
||||
skills = [
|
||||
s for s in skills
|
||||
if s.get("source") == "builtin" or s.get("status") == "published"
|
||||
]
|
||||
elif min_confidence > 0:
|
||||
def _passes(s):
|
||||
if s.get("source") == "builtin":
|
||||
return True
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+11
-5
@@ -6933,6 +6933,7 @@ _AGENT_RULES = """\
|
||||
- If the user asks for multiple specific emails and you call `read_email` more than once, your final answer MUST include every successfully read email, clearly separated and linked by UID. Do not answer with only the last email you read.
|
||||
- Multiple email accounts: if tool output says "Other accounts" or the user asks "my Gmail?", "other inbox?", "work mail?", "custom domain mail?", or names any mailbox/account, DO NOT answer from memory. Call `list_email_accounts` if needed, then call `list_emails`/`read_email`/`bulk_email` with the exact `account` value for that mailbox. Account names are user-defined labels; if the user typo-matches a known account, use the closest listed account instead of claiming it does not exist. NEVER use `app_api` or `/api/email/accounts` to discover email accounts; that route is owner-filtered in tool context and can falsely return empty.
|
||||
- User identity facts/preferences ("my name is <name>", "I live in <place>", "I prefer concise replies", "call me <name>") → use `manage_memory` with action=add. NEVER use `manage_contact` for facts about the user unless the user explicitly says to create/update a contact and provides contact details such as an email or phone.
|
||||
- When the prompt contains "Selected notes and todos", treat that content as already-provided reference context. Use it directly when it answers the request; do not call `manage_notes` to list/search/view the same notes, and do not ask for permission merely to use the selected context. Call `manage_notes` only when the user requests a note change or the selected context is insufficient and a fresh lookup is needed.
|
||||
- "Create/add/write a note" / "notes" / "todos" / "remind me to X at <time>" → use `manage_notes`. Do NOT store notes in `manage_memory`; memory is for persistent facts/preferences about the user, not note content. For reminders, include a `due_date`; for todos, use `note_type=checklist` when appropriate.
|
||||
- "Do X every morning / daily / on a schedule / automatically" (e.g. "summarize my inbox every morning") → this is a request to CREATE A SCHEDULED TASK, not to do X once right now. Call `manage_tasks` with action=create (prompt = what to do, schedule + cron/time). Do NOT just perform the action inline this turn — the user wants it to recur. After creating, return a clickable `[Task name](#task-<id>)` link and tell them it'll run on schedule and show in the Tasks panel. If you also want to show a sample of this run, do that AFTER creating the task, not instead of it.
|
||||
- There is NO generic sleep / auto-wakeup / resume-after-this-turn primitive. Background jobs and subagent-style work should return a job/task id and notify the session automatically when finished; do not sleep or poll for their progress. If the user explicitly asks you to retry or do something later, call `manage_tasks` with action=create, `task_type=llm`, `schedule=once`, and a self-contained prompt. NEVER claim you will wake up, retry later, or handle something later unless a `manage_tasks` create call succeeds.
|
||||
@@ -7041,6 +7042,7 @@ _AGENT_RULES = """\
|
||||
- After a tool fails, retry with a concrete fix or state what is blocking you.
|
||||
- Finish only when the user's concrete request is actually done, or clearly state that you are blocked.
|
||||
- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts.
|
||||
- When the prompt contains "Selected notes and todos", treat that content as already-provided reference context. Use it directly when it answers the request; do not call `manage_notes` to list/search/view the same notes, and do not ask for permission merely to use the selected context. Call `manage_notes` only when the user requests a note change or the selected context is insufficient and a fresh lookup is needed.
|
||||
"""
|
||||
|
||||
_API_AGENT_RULES = """\
|
||||
@@ -7099,7 +7101,7 @@ _DOMAIN_RULES = {
|
||||
- After a successful serve, verify with `list_served_models`; if an external server is running but invisible, use `adopt_served_model`.""",
|
||||
"notes_calendar_tasks": """\
|
||||
## Notes/calendar/tasks rules
|
||||
- Notes/todos/reminders use `manage_notes`, not memory.
|
||||
- Notes/todos/reminders use `manage_notes`, not memory. However, when the prompt contains "Selected notes and todos", those notes are already available as reference context: use them directly and do not list/search/view them again or ask permission to use them. Use `manage_notes` only for a requested change or when a fresh lookup is genuinely needed.
|
||||
- Calendar create/update/delete should call `manage_calendar` with `action=list_calendars` first.
|
||||
- Recurring/automatic/scheduled requests create a `manage_tasks` task; do not just perform the action once.""",
|
||||
"memory": """\
|
||||
@@ -10323,9 +10325,10 @@ def _has_matching_skill_for_turn(
|
||||
))))
|
||||
if max_items == 0:
|
||||
return False
|
||||
published_only = not prefs.get("auto_approve_skills", True)
|
||||
min_confidence = (
|
||||
2.0
|
||||
if not prefs.get("auto_approve_skills", True)
|
||||
0.0
|
||||
if published_only
|
||||
else float(prefs.get(
|
||||
"skill_min_confidence",
|
||||
get_setting("skill_autosave_min_confidence", 0.85),
|
||||
@@ -10342,6 +10345,7 @@ def _has_matching_skill_for_turn(
|
||||
threshold=0.25,
|
||||
max_items=max_items,
|
||||
min_confidence=min_confidence,
|
||||
published_only=published_only,
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.debug("skill preflight failed (non-fatal): %s", exc)
|
||||
@@ -14950,8 +14954,9 @@ def _build_system_prompt(
|
||||
# confidence threshold. Approve OFF → published-only (no draft
|
||||
# passes). Approve ON → drafts at/above the chosen confidence
|
||||
# (0 = "All"). Falls back to the global default setting.
|
||||
if not _prefs.get("auto_approve_skills", True):
|
||||
_skill_min_conf = 2.0 # nothing draft clears it → published only
|
||||
_skill_published_only = not _prefs.get("auto_approve_skills", True)
|
||||
if _skill_published_only:
|
||||
_skill_min_conf = 0.0 # published_only decides eligibility
|
||||
else:
|
||||
try:
|
||||
_skill_min_conf = float(_prefs.get(
|
||||
@@ -14974,6 +14979,7 @@ def _build_system_prompt(
|
||||
max_items=_skill_max_injected,
|
||||
min_confidence=_skill_min_conf,
|
||||
available_toolsets=relevant_tools,
|
||||
published_only=_skill_published_only,
|
||||
) if _skill_max_injected > 0 else []
|
||||
else:
|
||||
# Explicit client activation chooses which eligible skill
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
||||
from src.model_context import estimate_text_tokens, get_context_length, estimate_tokens
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.utility_effort import effort_for_call
|
||||
from src.settings import get_setting
|
||||
from core.models import ChatMessage
|
||||
|
||||
@@ -556,7 +557,13 @@ async def maybe_compact(
|
||||
)
|
||||
|
||||
# Use utility model if configured, otherwise fall back to session model
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner)
|
||||
util_url, util_model, util_headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=endpoint_url,
|
||||
fallback_model=model,
|
||||
fallback_headers=headers,
|
||||
owner=owner,
|
||||
)
|
||||
compact_url = util_url or endpoint_url
|
||||
compact_model = util_model or model
|
||||
compact_headers = util_headers if util_url else headers
|
||||
@@ -580,6 +587,7 @@ async def maybe_compact(
|
||||
max_tokens=SUMMARY_MAX_TOKENS,
|
||||
headers=compact_headers,
|
||||
timeout=30,
|
||||
reasoning_effort=effort_for_call(compact_url, compact_model, owner, session),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Compaction summary failed: {e}")
|
||||
|
||||
+37
-4
@@ -2268,10 +2268,25 @@ def normalize_model_id(
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _apply_explicit_nonstream_reasoning_effort(
|
||||
payload: Dict, reasoning_effort: Optional[str]
|
||||
) -> None:
|
||||
"""Apply explicit effort to an OpenAI-compatible non-streaming request.
|
||||
|
||||
No explicit effort preserves existing provider-specific defaults.
|
||||
Native Ollama, Anthropic and ChatGPT Responses use separate builders.
|
||||
"""
|
||||
effort = str(reasoning_effort or "").strip().lower()
|
||||
if effort and effort != "default":
|
||||
payload["reasoning_effort"] = effort
|
||||
|
||||
|
||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
thinking_mode: Optional[str] = None) -> str:
|
||||
thinking_mode: Optional[str] = None,
|
||||
reasoning_effort: Optional[str] = None) -> str:
|
||||
"""Synchronous LLM call with optional prompt type enhancement."""
|
||||
h = _provider_headers(_detect_provider(url))
|
||||
# Tolerate headers that arrive as a JSON string (some sessions stored them
|
||||
@@ -2303,7 +2318,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
provider = _detect_provider(url)
|
||||
cache_key = _get_cache_key(
|
||||
url, model, messages_copy, temperature, max_tokens, headers=headers,
|
||||
thinking_mode=thinking_mode,
|
||||
thinking_mode=thinking_mode, reasoning_effort=reasoning_effort,
|
||||
)
|
||||
cached_response = _get_cached_response(cache_key)
|
||||
if cached_response:
|
||||
@@ -2343,6 +2358,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
)
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_explicit_nonstream_reasoning_effort(payload, reasoning_effort)
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||
@@ -2465,14 +2481,30 @@ def llm_call_with_fallback(candidates, messages, **kwargs) -> str:
|
||||
|
||||
|
||||
async def llm_call_async_with_fallback(candidates, messages, **kwargs) -> str:
|
||||
"""Async variant of `llm_call_with_fallback` — same semantics."""
|
||||
"""Async variant of `llm_call_with_fallback` — same semantics.
|
||||
|
||||
An optional ``candidate_request_factory(index, url, model, headers)`` may
|
||||
return ``{"messages": ..., "kwargs": {...}}`` to adjust the request per
|
||||
candidate (same contract as `llm_call_async_with_route_fallback`).
|
||||
"""
|
||||
candidate_request_factory = kwargs.pop("candidate_request_factory", None)
|
||||
cands = dedupe_model_candidates(candidates)
|
||||
if not cands:
|
||||
raise HTTPException(503, "No model endpoint configured")
|
||||
last_err = None
|
||||
for i, (url, model, headers) in enumerate(cands):
|
||||
try:
|
||||
return await llm_call_async(url, model, messages, headers=headers, **kwargs)
|
||||
candidate_messages = messages
|
||||
candidate_kwargs = kwargs
|
||||
if candidate_request_factory is not None:
|
||||
request = candidate_request_factory(i, url, model, headers) or {}
|
||||
if hasattr(request, "__await__"):
|
||||
request = await request
|
||||
candidate_messages = request.get("messages", messages)
|
||||
candidate_kwargs = {**kwargs, **(request.get("kwargs") or {})}
|
||||
return await llm_call_async(
|
||||
url, model, candidate_messages, headers=headers, **candidate_kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
tag = "primary" if i == 0 else "candidate"
|
||||
@@ -2740,6 +2772,7 @@ async def llm_call_async(
|
||||
_apply_deepseek_v4_reasoning_defaults(
|
||||
payload, target_url, model, thinking_mode
|
||||
)
|
||||
_apply_explicit_nonstream_reasoning_effort(payload, reasoning_effort)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
|
||||
@@ -160,6 +160,10 @@ DEFAULT_SETTINGS = {
|
||||
# Ordered fallback chain for the Utility model (summarization, naming,
|
||||
# tidy actions, etc.).
|
||||
"utility_model_fallbacks": [],
|
||||
# Opt-in reasoning effort for utility/background LLM calls. Empty keeps the
|
||||
# provider default; a value is sent only to utility-chain candidates whose
|
||||
# model advertises that level.
|
||||
"utility_reasoning_effort": "",
|
||||
"teacher_model": "",
|
||||
"teacher_enabled": False,
|
||||
"teacher_tier2_enabled": False,
|
||||
@@ -290,6 +294,7 @@ _PER_USER_KEYS = {
|
||||
# got injected into the chat composer on first open.
|
||||
"default_endpoint_id", "default_model",
|
||||
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||
"utility_reasoning_effort",
|
||||
"research_endpoint_id", "research_model",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,4 +106,11 @@ async def task_llm_call_async(
|
||||
raise RuntimeError("No LLM endpoint available for background task")
|
||||
await wait_for_interactive_quiet("background task LLM")
|
||||
kwargs.setdefault("workload", "background")
|
||||
if kwargs.get("reasoning_effort") is None:
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
from src.utility_effort import candidate_effort_factory
|
||||
|
||||
factory = candidate_effort_factory(owner)
|
||||
if factory is not None:
|
||||
kwargs.setdefault("candidate_request_factory", factory)
|
||||
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
||||
|
||||
+17
-2
@@ -415,9 +415,24 @@ class ToolApprovalStore:
|
||||
backend_operation = BoundBackendOperation(backend,
|
||||
request_authority.request_id if request_authority is not None else "",
|
||||
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
|
||||
from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation
|
||||
from src.agent_runtime.process_resources import (
|
||||
LAUNCH_TOOLS, needs_process_binding, resolve_process_operation, seal_launch_scope)
|
||||
if request_authority is not None and needs_process_binding(operation, backend):
|
||||
process_operation = resolve_process_operation(request_authority, operation, backend)
|
||||
process_authority = request_authority
|
||||
if (not request_authority.inherited and operation.tool in LAUNCH_TOOLS
|
||||
and not any(s.backend == backend for s in request_authority.launch_scopes)):
|
||||
# Launch scopes exist only for granted bash/python, but the
|
||||
# approval exists to admit an ungranted one. Seal its launch
|
||||
# boundary now, in the request's workspace, the same way an
|
||||
# ungranted filesystem action gets its root below (#6635).
|
||||
roots = request_authority.resource_roots
|
||||
if not roots and workspace:
|
||||
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
|
||||
if len(roots) == 1:
|
||||
from dataclasses import replace
|
||||
process_authority = replace(
|
||||
request_authority, launch_scopes=(seal_launch_scope(backend, roots[0]),))
|
||||
process_operation = resolve_process_operation(process_authority, operation, backend)
|
||||
from src.browser_identity import native_browser, resolve_browser_operation
|
||||
if native_browser(operation, backend):
|
||||
if request_authority is None:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Reasoning effort for utility-role LLM calls.
|
||||
|
||||
One owner for the rule. Precedence for a utility call:
|
||||
|
||||
1. an explicit caller effort (never touched here);
|
||||
2. the ``utility_reasoning_effort`` setting, sent only to routes on the
|
||||
utility chain (the Utility endpoint/model plus its fallbacks, matched by
|
||||
``(url, model)``);
|
||||
3. the chat session's own effort (``thinking_mode == "effort:<level>"``), when
|
||||
the call is made for a chat session and its route is that session's route.
|
||||
|
||||
Every level is validated against what the model advertises; a model without
|
||||
evidence gets nothing. Calls with no session only ever use (2).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def utility_routes(owner: Optional[str] = None) -> set:
|
||||
"""The ``(url, model)`` routes that make up the utility chain."""
|
||||
from src.endpoint_resolver import resolve_endpoint, resolve_utility_fallback_candidates
|
||||
|
||||
return {
|
||||
(url, model)
|
||||
for url, model, _headers in [
|
||||
resolve_endpoint("utility", owner=owner),
|
||||
*resolve_utility_fallback_candidates(owner=owner),
|
||||
]
|
||||
if url and model
|
||||
}
|
||||
|
||||
|
||||
def configured_effort(owner: Optional[str] = None) -> str:
|
||||
"""The raw setting, lowercased; empty when unset (the default)."""
|
||||
from src.settings import get_user_setting
|
||||
|
||||
return str(get_user_setting("utility_reasoning_effort", owner or "", "") or "").strip().lower()
|
||||
|
||||
|
||||
def fallback_route_efforts(owner: Optional[str] = None) -> dict[tuple[str, str], str]:
|
||||
"""Configured effort per fallback candidate in ``utility_model_fallbacks``."""
|
||||
from src.endpoint_resolver import resolve_endpoint_by_id
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
chain = get_user_setting(
|
||||
"utility_model_fallbacks",
|
||||
owner or "",
|
||||
settings.get("utility_model_fallbacks") or [],
|
||||
) or []
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
res = {}
|
||||
for entry in chain:
|
||||
if not isinstance(entry, dict) or "reasoning_effort" not in entry:
|
||||
continue
|
||||
effort = str(entry.get("reasoning_effort") or "").strip().lower()
|
||||
ep = resolve_endpoint_by_id(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
)
|
||||
if ep:
|
||||
url, model, _ = ep
|
||||
if url and model:
|
||||
res[(url, model)] = effort
|
||||
return res
|
||||
|
||||
|
||||
def effort_for_route(url: str, model: str, owner: Optional[str] = None) -> Optional[str]:
|
||||
"""Validated effort for ``(url, model)``, or None when it must not be sent."""
|
||||
fb_efforts = fallback_route_efforts(owner)
|
||||
if (url, model) in fb_efforts:
|
||||
fb_effort = fb_efforts[(url, model)]
|
||||
if not fb_effort or fb_effort in {"off", "none", "default"}:
|
||||
return None
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
return validate_reasoning_effort(model, fb_effort)
|
||||
|
||||
effort = configured_effort(owner)
|
||||
if not effort or (url, model) not in utility_routes(owner):
|
||||
return None
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
return validate_reasoning_effort(model, effort)
|
||||
|
||||
|
||||
def session_effort(session) -> Optional[str]:
|
||||
"""The raw effort level the chat session is set to, or None."""
|
||||
mode = str(getattr(session, "thinking_mode", "") or "")
|
||||
if not mode.startswith("effort:"):
|
||||
return None
|
||||
return mode[len("effort:"):].strip().lower() or None
|
||||
|
||||
|
||||
def _is_session_route(url: str, model: str, session) -> bool:
|
||||
from src.endpoint_resolver import same_endpoint_base
|
||||
|
||||
return bool(
|
||||
model
|
||||
and model == getattr(session, "model", None)
|
||||
and same_endpoint_base(url, getattr(session, "endpoint_url", None))
|
||||
)
|
||||
|
||||
|
||||
def effort_for_call(url: str, model: str, owner: Optional[str] = None, session=None) -> Optional[str]:
|
||||
"""Effort an owner-aware utility call should send to ``(url, model)``.
|
||||
|
||||
``session`` is the chat session the call is made for, or None for
|
||||
session-less work (scheduled tasks, pollers, auto-sort). Never raises:
|
||||
a lookup failure means "send nothing".
|
||||
"""
|
||||
try:
|
||||
raw = configured_effort(owner)
|
||||
if raw in {"off", "none", "default"}:
|
||||
return None
|
||||
effort = effort_for_route(url, model, owner)
|
||||
if effort or session is None:
|
||||
return effort
|
||||
level = session_effort(session)
|
||||
if not level or not _is_session_route(url, model, session):
|
||||
return None
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
return validate_reasoning_effort(model, level)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def candidate_effort_factory(owner=None):
|
||||
"""Per-candidate factory for `llm_call_async_with_fallback`.
|
||||
|
||||
Returns None when neither the setting nor any fallback effort is configured.
|
||||
Otherwise a factory sending the effort only to utility-chain candidates
|
||||
(owner-scoped) whose model advertises it.
|
||||
"""
|
||||
raw = configured_effort(owner)
|
||||
has_primary = bool(raw and raw not in {"off", "none", "default"})
|
||||
fb_efforts = fallback_route_efforts(owner)
|
||||
has_fb = any(eff and eff not in {"off", "none", "default"} for eff in fb_efforts.values())
|
||||
if not has_primary and not has_fb:
|
||||
return None
|
||||
|
||||
def factory(_index, url, model, _headers):
|
||||
effort = effort_for_route(url, model, owner)
|
||||
return {"kwargs": {"reasoning_effort": effort}} if effort else {}
|
||||
|
||||
return factory
|
||||
@@ -445,6 +445,7 @@
|
||||
flex-shrink: 0; color: var(--fg); opacity: 0.85;
|
||||
}
|
||||
.adm-provider-logo svg { width: 14px; height: 14px; }
|
||||
.adm-model-logo { margin-right: 8px; }
|
||||
/* Pull the settings-select 4px closer to its logo chip so the row
|
||||
reads as logo+select instead of having an extra gap. */
|
||||
.adm-model-logo + .settings-select { margin-left: -4px; }
|
||||
@@ -454,6 +455,8 @@
|
||||
select shrinks to its content and the trash button rides up
|
||||
tight to it. */
|
||||
.settings-fallback-row > .settings-select { flex: 1 1 0; min-width: 0; }
|
||||
.settings-fallback-row > .settings-select:first-of-type { flex: 0 1 128px; max-width: 34%; }
|
||||
.settings-fallback-row > .settings-select.settings-fallback-effort { flex: 0 1 130px; max-width: 32%; }
|
||||
/* Constrain logo SVGs in settings dropdown chips. Many provider
|
||||
icons in providers.js declare only viewBox (no width/height),
|
||||
so without this rule the browser falls back to 300x150 and
|
||||
|
||||
+12
-2
@@ -989,7 +989,6 @@ a.chat-link[href^="#research-"] {
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-col {
|
||||
display: flex;
|
||||
@@ -998,7 +997,13 @@ a.chat-link[href^="#research-"] {
|
||||
}
|
||||
.settings-label {
|
||||
font-size: 12px;
|
||||
min-width: 70px;
|
||||
min-width: 85px;
|
||||
}
|
||||
#set-utilityEffortLabel { flex: 0 0 85px; line-height: 1.2; }
|
||||
.settings-label-sub {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.settings-select {
|
||||
flex: 1;
|
||||
@@ -1117,6 +1122,11 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
|
||||
width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.settings-fallback-row .settings-select.settings-fallback-effort {
|
||||
flex: 0 1 130px;
|
||||
width: 130px;
|
||||
max-width: 32%;
|
||||
}
|
||||
details.hwfit-serve-advanced > .hwfit-serve-row,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-checks,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-cmd-wrap,
|
||||
|
||||
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(){
|
||||
@@ -1177,6 +1178,21 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Selected Notes/Todos indicator -->
|
||||
<button
|
||||
type="button"
|
||||
class="input-icon-btn tool-indicator"
|
||||
title="Selected Notes/Todos context — click to clear"
|
||||
id="selected-notes-context-btn"
|
||||
aria-label="Clear selected Notes/Todos context"
|
||||
style="display:none;"
|
||||
>
|
||||
<span id="selected-notes-context-count" style="font-size:11px;margin-left:2px;"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg class="workspace-indicator-icon" 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 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
@@ -1596,6 +1612,12 @@
|
||||
<span class="adm-model-logo" id="set-utilityModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-utilityModelSelect" class="settings-select"><option value="">—</option></select>
|
||||
</div>
|
||||
<div class="settings-row" id="set-utilityEffortRow" style="display:none;">
|
||||
<label class="settings-label" id="set-utilityEffortLabel">Effort<span class="settings-label-sub" id="set-utilityEffortQualifier">(when supported)</span></label>
|
||||
<span class="adm-model-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-utilityEffortSelect" class="settings-select"><option value="">Provider default</option></select>
|
||||
</div>
|
||||
<div class="admin-toggle-sub" id="set-utilityEffortHelp" style="display:none;margin-top:-2px;">Applied to utility and background calls, not chat. Use “Same as chat” to match active chat effort and potentially ensure cache hits to speed up responses. Reasoning tokens count against each call's output limit, so higher efforts can truncate or empty short outputs such as auto-generated titles.</div>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
|
||||
@@ -65,6 +65,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _contextHeaderAnchorEl = null;
|
||||
let _contextHeaderPopupCleanup = null;
|
||||
let _pendingToolApproval = null;
|
||||
const _selectedNoteIdsBySession = new Map();
|
||||
let _pendingSelectedNoteIds = [];
|
||||
let _lastPrivateBrowserUrl = '';
|
||||
function _isPrivateBrowserTool(tool) {
|
||||
const name = String(tool || '').toLowerCase();
|
||||
@@ -240,6 +242,46 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
||||
});
|
||||
|
||||
window.addEventListener('odysseus:notes-context-selected', (event) => {
|
||||
const noteIds = event.detail?.noteIds;
|
||||
if (!Array.isArray(noteIds) || noteIds.length === 0) return;
|
||||
|
||||
const sessionId = sessionModule.getCurrentSessionId?.();
|
||||
if (!sessionId && !sessionModule.hasPendingChat?.()) {
|
||||
console.warn('Cannot attach Notes context: no active or pending chat');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
_selectedNoteIdsBySession.set(sessionId, [...noteIds]);
|
||||
} else {
|
||||
_pendingSelectedNoteIds = [...noteIds];
|
||||
}
|
||||
if (event.detail) event.detail.accepted = true;
|
||||
_renderSelectedNotesIndicator(noteIds);
|
||||
});
|
||||
|
||||
window.addEventListener('odysseus:session-changed', (event) => {
|
||||
const sessionId = event.detail?.sessionId;
|
||||
if (sessionId && event.detail?.materialized && _pendingSelectedNoteIds.length) {
|
||||
_selectedNoteIdsBySession.set(sessionId, _pendingSelectedNoteIds);
|
||||
}
|
||||
_pendingSelectedNoteIds = [];
|
||||
_renderSelectedNotesIndicator(
|
||||
sessionId ? (_selectedNoteIdsBySession.get(sessionId) || []) : []
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('selected-notes-context-btn')?.addEventListener('click', () => {
|
||||
const sessionId = sessionModule.getCurrentSessionId?.();
|
||||
if (sessionId) {
|
||||
_selectedNoteIdsBySession.delete(sessionId);
|
||||
} else {
|
||||
_pendingSelectedNoteIds = [];
|
||||
}
|
||||
_renderSelectedNotesIndicator([]);
|
||||
});
|
||||
|
||||
function _fmtContextNumber(n) {
|
||||
const v = Number(n || 0);
|
||||
return v ? v.toLocaleString() : '?';
|
||||
@@ -290,6 +332,26 @@ import { invalidateSettings } from './appConfig.js';
|
||||
return Math.max(50, Math.min(95, Math.round(n)));
|
||||
}
|
||||
|
||||
function _renderSelectedNotesIndicator(noteIds){
|
||||
const button = document.getElementById('selected-notes-context-btn');
|
||||
const count = document.getElementById('selected-notes-context-count');
|
||||
|
||||
if (!button || !count) return;
|
||||
|
||||
const total = Array.isArray(noteIds) ? noteIds.length : 0;
|
||||
if(!total){
|
||||
button.style.display = 'none';
|
||||
count.textContent = '';
|
||||
return;
|
||||
}
|
||||
count.textContent = `${total} note${total === 1 ? '' : 's'} selected`;
|
||||
button.style.display = '';
|
||||
button.title = `${total} selected Note/Todo${total === 1 ? '' : 's'} — click to clear`;
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
`Clear ${total} selected Note/Todo${total === 1 ? '' : 's'} context`
|
||||
);
|
||||
}
|
||||
function _liveSessionModule() {
|
||||
return (window.sessionModule && window.sessionModule.getCurrentSessionId)
|
||||
? window.sessionModule
|
||||
@@ -2497,6 +2559,12 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const fd = new FormData();
|
||||
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
||||
fd.append('session', streamSessionId);
|
||||
|
||||
const selectedNoteIds = _selectedNoteIdsBySession.get(streamSessionId) || [];
|
||||
if(selectedNoteIds.length){
|
||||
fd.append('selected_note_ids', JSON.stringify(selectedNoteIds))
|
||||
}
|
||||
|
||||
if (approvalForSend) {
|
||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||
fd.append('tool_approval_decision', approvalForSend.decision);
|
||||
|
||||
@@ -1230,6 +1230,7 @@ export function openPanel() {
|
||||
<label class="memory-bulk-check-all"><input type="checkbox" id="notes-select-all" /> All</label>
|
||||
<span id="notes-selected-count">0 Selected</span>
|
||||
<span style="flex:1"></span>
|
||||
<button id="notes-send-agent" class="memory-toolbar-btn" disabled><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>Send to Agent</button>
|
||||
<button id="notes-bulk-archive" class="memory-toolbar-btn" disabled>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>Archive
|
||||
</button>
|
||||
@@ -1379,6 +1380,17 @@ export function openPanel() {
|
||||
_renderNotes();
|
||||
_updateBulkBar();
|
||||
});
|
||||
document.getElementById('notes-send-agent').addEventListener('click', async () => {
|
||||
const ids = [..._selectedIds];
|
||||
if (!ids.length) return;
|
||||
const detail = { noteIds: ids, accepted: false };
|
||||
window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail }));
|
||||
if (!detail.accepted) {
|
||||
uiModule.showToast('Choose a chat or model before attaching notes');
|
||||
return;
|
||||
}
|
||||
_exitSelectMode();
|
||||
})
|
||||
document.getElementById('notes-bulk-archive').addEventListener('click', async () => {
|
||||
const ids = [..._selectedIds];
|
||||
if (!ids.length) return;
|
||||
@@ -1511,10 +1523,12 @@ function _exitSelectMode() {
|
||||
function _updateBulkBar() {
|
||||
const count = _selectedIds.size;
|
||||
const countEl = document.getElementById('notes-selected-count');
|
||||
const sendAgentBtn = document.getElementById('notes-send-agent');
|
||||
const archiveBtn = document.getElementById('notes-bulk-archive');
|
||||
const deleteBtn = document.getElementById('notes-bulk-delete');
|
||||
const allEl = document.getElementById('notes-select-all');
|
||||
if (countEl) countEl.textContent = `${count} Selected`;
|
||||
if (sendAgentBtn) sendAgentBtn.disabled = count === 0;
|
||||
if (archiveBtn) archiveBtn.disabled = count === 0;
|
||||
if (deleteBtn) deleteBtn.disabled = count === 0;
|
||||
if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id));
|
||||
|
||||
@@ -351,6 +351,9 @@ let _sessionListFocused = false;
|
||||
function _deselectCurrentSession(sid) {
|
||||
if (currentSessionId !== sid) return;
|
||||
currentSessionId = null;
|
||||
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||
detail: { sessionId: null },
|
||||
}));
|
||||
uiModule.el('chat-history').innerHTML = '';
|
||||
uiModule.el('current-meta').textContent = 'Odysseus Chat';
|
||||
Storage.remove('lastSessionId');
|
||||
@@ -2065,6 +2068,9 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
try { window.documentModule.clearSelection(); } catch {}
|
||||
}
|
||||
currentSessionId = id;
|
||||
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||
detail: { sessionId: id ? id : null },
|
||||
}));
|
||||
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
|
||||
// Identify Assistant / task-output sessions so we don't "trap" the user
|
||||
// there on return. Skipped from both `lastSessionId` persistence and the
|
||||
@@ -2445,6 +2451,9 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
|
||||
_skipAutoSelect = true;
|
||||
_suppressNextSessionLoading = true;
|
||||
currentSessionId = null;
|
||||
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||
detail: { sessionId: null },
|
||||
}));
|
||||
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
@@ -2555,6 +2564,9 @@ export async function materializePendingSession() {
|
||||
}
|
||||
_pendingChat = null;
|
||||
currentSessionId = payload.id;
|
||||
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||
detail: { sessionId: payload.id, materialized: true },
|
||||
}));
|
||||
if (!isIncognito) {
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
history.replaceState(null, '', '#' + payload.id);
|
||||
@@ -2626,6 +2638,9 @@ export function getCurrentEndpointUrl() {
|
||||
export function setCurrentSessionId(id) {
|
||||
_sessionNavToken++;
|
||||
currentSessionId = id;
|
||||
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||
detail: { sessionId: id ? id : null },
|
||||
}));
|
||||
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
||||
if (!id) {
|
||||
_suppressNextSessionLoading = true;
|
||||
|
||||
+165
-10
@@ -212,9 +212,11 @@ function _bindFallbackWidget(opts) {
|
||||
var endpointsRef = opts.endpoints; // mutable list reference
|
||||
var modelsFilter = opts.modelsFilter || function() { return true; };
|
||||
var settingKey = opts.settingKey;
|
||||
var current = opts.initial || []; // [{endpoint_id, model}]
|
||||
var current = opts.initial || []; // [{endpoint_id, model, reasoning_effort}]
|
||||
var supportsEffort = Boolean(opts.supportsEffort);
|
||||
var getEffortLevels = opts.getEffortLevels;
|
||||
|
||||
if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} };
|
||||
if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {}, refresh: function() {} };
|
||||
|
||||
function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); }
|
||||
|
||||
@@ -233,8 +235,36 @@ function _bindFallbackWidget(opts) {
|
||||
if (selected) selectEl.value = selected;
|
||||
}
|
||||
|
||||
function fillEffort(selectEl, epId, modelId, selected) {
|
||||
if (!selectEl) return;
|
||||
while (selectEl.options.length) selectEl.remove(0);
|
||||
var levels = getEffortLevels ? getEffortLevels(epId, modelId) : [];
|
||||
var base = document.createElement('option');
|
||||
base.value = '';
|
||||
base.textContent = 'Provider default';
|
||||
selectEl.appendChild(base);
|
||||
levels.forEach(function(lvl) {
|
||||
var val = typeof lvl === 'string' ? lvl : (lvl && lvl.effort);
|
||||
if (val) {
|
||||
var o = document.createElement('option');
|
||||
o.value = val.toLowerCase();
|
||||
o.textContent = val;
|
||||
selectEl.appendChild(o);
|
||||
}
|
||||
});
|
||||
var validValues = [''].concat(levels.map(function(l) { return (typeof l === 'string' ? l : l.effort).toLowerCase(); }));
|
||||
selectEl.value = validValues.indexOf((selected || '').toLowerCase()) >= 0 ? (selected || '').toLowerCase() : '';
|
||||
selectEl.style.display = levels.length > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var clean = current.filter(function(f) { return f.endpoint_id && f.model; });
|
||||
var clean = current.filter(function(f) { return f.endpoint_id && f.model; }).map(function(f) {
|
||||
var item = { endpoint_id: f.endpoint_id, model: f.model };
|
||||
if (supportsEffort && typeof f.reasoning_effort !== 'undefined') {
|
||||
item.reasoning_effort = f.reasoning_effort || '';
|
||||
}
|
||||
return item;
|
||||
});
|
||||
var body = {};
|
||||
body[settingKey] = clean;
|
||||
try {
|
||||
@@ -270,13 +300,36 @@ function _bindFallbackWidget(opts) {
|
||||
fb.endpoint_id = epS.value;
|
||||
fb.model = mS.value;
|
||||
|
||||
var effS = null;
|
||||
if (supportsEffort) {
|
||||
effS = document.createElement('select');
|
||||
effS.className = 'settings-select settings-fallback-effort';
|
||||
fillEffort(effS, epS.value, mS.value, fb.reasoning_effort);
|
||||
fb.reasoning_effort = effS.value;
|
||||
effS.addEventListener('change', function() {
|
||||
fb.reasoning_effort = effS.value;
|
||||
save();
|
||||
});
|
||||
}
|
||||
|
||||
epS.addEventListener('change', function() {
|
||||
fb.endpoint_id = epS.value;
|
||||
fillModels(mS, epS.value, '');
|
||||
fb.model = mS.value;
|
||||
if (effS) {
|
||||
fillEffort(effS, epS.value, mS.value, '');
|
||||
fb.reasoning_effort = effS.value;
|
||||
}
|
||||
save();
|
||||
});
|
||||
mS.addEventListener('change', function() {
|
||||
fb.model = mS.value;
|
||||
if (effS) {
|
||||
fillEffort(effS, epS.value, mS.value, fb.reasoning_effort);
|
||||
fb.reasoning_effort = effS.value;
|
||||
}
|
||||
save();
|
||||
});
|
||||
mS.addEventListener('change', function() { fb.model = mS.value; save(); });
|
||||
|
||||
var rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
@@ -292,6 +345,7 @@ function _bindFallbackWidget(opts) {
|
||||
row.appendChild(num);
|
||||
row.appendChild(epS);
|
||||
row.appendChild(mS);
|
||||
if (effS) row.appendChild(effS);
|
||||
row.appendChild(rm);
|
||||
fbContainer.appendChild(row);
|
||||
});
|
||||
@@ -299,7 +353,7 @@ function _bindFallbackWidget(opts) {
|
||||
|
||||
addBtn.addEventListener('click', function() {
|
||||
var first = enabledEps()[0];
|
||||
current.push({ endpoint_id: first ? first.id : '', model: '' });
|
||||
current.push({ endpoint_id: first ? first.id : '', model: '', reasoning_effort: '' });
|
||||
render();
|
||||
save();
|
||||
});
|
||||
@@ -371,9 +425,14 @@ async function initDefaultChat() {
|
||||
async function initUtilityModel() {
|
||||
var epSel = el('set-utilityEpSelect');
|
||||
var modelSel = el('set-utilityModelSelect');
|
||||
var effortSel = el('set-utilityEffortSelect');
|
||||
var effortRow = el('set-utilityEffortRow');
|
||||
var effortHelp = el('set-utilityEffortHelp');
|
||||
var effortQualifier = el('set-utilityEffortQualifier');
|
||||
var msg = el('set-utilityChatMsg');
|
||||
var _endpoints = [];
|
||||
var fallbackWidget = null;
|
||||
var _levelsByEndpoint = {};
|
||||
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
|
||||
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
|
||||
|
||||
@@ -382,24 +441,110 @@ async function initUtilityModel() {
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
|
||||
|
||||
// Effort levels come from /api/models `models_metadata` (same evidence the
|
||||
// chat composer uses); /api/model-endpoints does not carry them.
|
||||
async function loadEffortLevels() {
|
||||
try {
|
||||
var r = await fetch('/api/models', { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
var data = await r.json();
|
||||
var items = Array.isArray(data) ? data : (data.models || data.items || []);
|
||||
_levelsByEndpoint = {};
|
||||
items.forEach(function(item) {
|
||||
if (item && item.endpoint_id) _levelsByEndpoint[item.endpoint_id] = item.models_metadata || {};
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load reasoning levels for utility model', e); }
|
||||
}
|
||||
|
||||
function refreshEffort(selected) {
|
||||
if (!effortSel) return;
|
||||
var targetEp = epSel.value;
|
||||
var targetModel = modelSel.value;
|
||||
if (!targetEp) {
|
||||
var defEp = el('set-defaultEpSelect');
|
||||
var defModel = el('set-defaultModelSelect');
|
||||
if (defEp) targetEp = defEp.value;
|
||||
if (defModel) targetModel = defModel.value;
|
||||
}
|
||||
var meta = (_levelsByEndpoint[targetEp] || {})[targetModel] || {};
|
||||
var levels = Array.isArray(meta.supported_reasoning_levels) ? meta.supported_reasoning_levels.slice() : [];
|
||||
var isInherited = !epSel.value;
|
||||
if (isInherited && levels.length === 0) {
|
||||
var allLevels = {};
|
||||
Object.keys(_levelsByEndpoint).forEach(function(epId) {
|
||||
var models = _levelsByEndpoint[epId] || {};
|
||||
Object.keys(models).forEach(function(mId) {
|
||||
var mLevels = (models[mId] || {}).supported_reasoning_levels;
|
||||
if (Array.isArray(mLevels)) {
|
||||
mLevels.forEach(function(lvl) {
|
||||
var val = typeof lvl === 'string' ? lvl : (lvl && lvl.effort);
|
||||
if (val) allLevels[val.toLowerCase()] = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
levels = Object.keys(allLevels);
|
||||
if (levels.length === 0) {
|
||||
levels = ['low', 'medium', 'high'];
|
||||
}
|
||||
}
|
||||
while (effortSel.options.length) effortSel.remove(0);
|
||||
var base = document.createElement('option');
|
||||
base.value = '';
|
||||
base.textContent = isInherited ? 'Same as chat' : 'Provider default';
|
||||
if (effortQualifier) effortQualifier.style.display = isInherited ? '' : 'none';
|
||||
effortSel.appendChild(base);
|
||||
if (isInherited) {
|
||||
var offOpt = document.createElement('option');
|
||||
offOpt.value = 'off';
|
||||
offOpt.textContent = 'Provider default';
|
||||
effortSel.appendChild(offOpt);
|
||||
}
|
||||
levels.forEach(function(level) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = level;
|
||||
opt.textContent = level;
|
||||
effortSel.appendChild(opt);
|
||||
});
|
||||
var validValues = [''].concat(isInherited ? ['off'] : []).concat(levels);
|
||||
effortSel.value = validValues.indexOf(selected) >= 0 ? selected : '';
|
||||
var show = isInherited || levels.length > 0;
|
||||
if (effortRow) effortRow.style.display = show ? '' : 'none';
|
||||
if (effortHelp) effortHelp.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function refreshModels(selectedModel) {
|
||||
var epId = epSel.value;
|
||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||
}
|
||||
|
||||
await loadEffortLevels();
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
|
||||
refreshModels(settings.utility_model || '');
|
||||
refreshEffort(settings.utility_reasoning_effort || '');
|
||||
fallbackWidget = _bindFallbackWidget({
|
||||
containerId: 'set-utilityFallbacks',
|
||||
addBtnId: 'set-utilityAddFallback',
|
||||
endpoints: function() { return _endpoints; },
|
||||
settingKey: 'utility_model_fallbacks',
|
||||
supportsEffort: true,
|
||||
getEffortLevels: function(epId, modelId) {
|
||||
var meta = (_levelsByEndpoint[epId] || {})[modelId] || {};
|
||||
return Array.isArray(meta.supported_reasoning_levels) ? meta.supported_reasoning_levels : [];
|
||||
},
|
||||
initial: Array.isArray(settings.utility_model_fallbacks)
|
||||
? settings.utility_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; })
|
||||
? settings.utility_model_fallbacks.map(function(f) {
|
||||
return {
|
||||
endpoint_id: (f && f.endpoint_id) || '',
|
||||
model: (f && f.model) || '',
|
||||
reasoning_effort: (f && f.reasoning_effort) || '',
|
||||
};
|
||||
})
|
||||
: [],
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load utility model settings', e); }
|
||||
@@ -411,20 +556,30 @@ async function initUtilityModel() {
|
||||
try {
|
||||
await _postSettings({
|
||||
utility_endpoint_id: epSel.value || '',
|
||||
utility_model: modelSel.value || ''
|
||||
utility_model: modelSel.value || '',
|
||||
utility_reasoning_effort: effortSel ? (effortSel.value || '') : ''
|
||||
});
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 1500);
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
|
||||
modelSel.addEventListener('change', saveUtility);
|
||||
// A new endpoint/model may not advertise the saved effort; refreshEffort
|
||||
// resets it to the provider default, and saveUtility persists that.
|
||||
epSel.addEventListener('change', function() { refreshModels(''); refreshEffort(''); saveUtility(); });
|
||||
modelSel.addEventListener('change', function() { refreshEffort(effortSel ? effortSel.value : ''); saveUtility(); });
|
||||
if (effortSel) effortSel.addEventListener('change', saveUtility);
|
||||
var defEp = el('set-defaultEpSelect');
|
||||
var defModel = el('set-defaultModelSelect');
|
||||
if (defEp) defEp.addEventListener('change', function() { if (!epSel.value) refreshEffort(effortSel ? effortSel.value : ''); });
|
||||
if (defModel) defModel.addEventListener('change', function() { if (!epSel.value) refreshEffort(effortSel ? effortSel.value : ''); });
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_registerAiEndpointRefresh(async function(endpoints) {
|
||||
_endpoints = endpoints;
|
||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||
refreshModels(modelSel.value);
|
||||
await loadEffortLevels();
|
||||
refreshEffort(effortSel ? effortSel.value : '');
|
||||
if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs';
|
||||
import * as source from '../frontend/src/shared/actionMenuOrder.js';
|
||||
import * as deployed from '../static/js/actionMenuOrder.js';
|
||||
|
||||
test('stateless helper preserves its public API, ordering, references and inputs', () => {
|
||||
for (const module of [source, deployed]) {
|
||||
assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']);
|
||||
const callback = () => {};
|
||||
const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback }));
|
||||
Object.freeze(items);
|
||||
const sorted = module.orderActionMenuItems(items);
|
||||
assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']);
|
||||
assert.ok(sorted.every(i => items.includes(i)));
|
||||
assert.deepEqual(module.orderActionMenuItems([]), []);
|
||||
assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12);
|
||||
assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100);
|
||||
const equalRank = [{ label: 'Open first' }, { label: 'Open second' }];
|
||||
assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank);
|
||||
}
|
||||
assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON);
|
||||
});
|
||||
|
||||
test('dependency direction separates composition, workflow, adapters and views', () => {
|
||||
const n = name => `features/notes/${name}.js`;
|
||||
const allowed = [
|
||||
['app/bootstrap.js', n('index')],
|
||||
[n('index'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('domain/recurrence')],
|
||||
[n('infrastructure/api'), 'shared/platform/http.js'],
|
||||
[n('ui/panel'), n('application/crud')],
|
||||
[n('application/reminders'), 'features/calendar/index.js'],
|
||||
];
|
||||
for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`);
|
||||
const forbidden = [
|
||||
[n('domain/recurrence'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('ui/panel')],
|
||||
[n('application/crud'), n('infrastructure/api')],
|
||||
[n('ui/panel'), 'features/calendar/index.js'],
|
||||
[n('application/crud'), 'features/calendar/application/store.js'],
|
||||
['app/bootstrap.js', n('domain/recurrence')],
|
||||
['shared/platform/http.js', n('index')],
|
||||
[n('domain/recurrence'), 'app/bootstrap.js'],
|
||||
];
|
||||
for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`);
|
||||
});
|
||||
|
||||
test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => {
|
||||
const code = `// import './false.js';
|
||||
const text = "import './false.js'";
|
||||
import { a }\n from './a.js';
|
||||
export { b } from './b.js';
|
||||
export * from './c.js';
|
||||
const load = () => import('./d.js');`;
|
||||
assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']);
|
||||
assert.throws(() => imports('const load = p => import(p);'), /computed import/);
|
||||
assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/);
|
||||
assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/);
|
||||
assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true }));
|
||||
assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true }));
|
||||
});
|
||||
|
||||
function fixture(t, modules, { cache = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const entries = Object.entries(modules).map(([name, code]) => {
|
||||
const file = path.join(root, 'frontend/src', name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, code);
|
||||
return { source: name, deployment: `js/${name}` };
|
||||
});
|
||||
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries }));
|
||||
const urls = cache ? entries.map(m => `/static/${m.deployment}`) : [];
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('complete mapped graph and exact precache pass', t => {
|
||||
const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' });
|
||||
assert.equal(checkArchitecture(root), 2);
|
||||
});
|
||||
|
||||
test('feature-private import fails through the full checker', t => {
|
||||
const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /forbidden dependency/);
|
||||
});
|
||||
|
||||
test('undeclared legacy coupling and alternate identities fail', t => {
|
||||
for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) {
|
||||
const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a copy mapping cannot silently change relative import resolution', t => {
|
||||
const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json')));
|
||||
manifest.modules[1].deployment = 'js/elsewhere/b.js';
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest));
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];");
|
||||
assert.throws(() => checkArchitecture(root), /resolve differently/);
|
||||
});
|
||||
|
||||
test('new modules must join exact offline manifests and cannot introduce cycles', t => {
|
||||
const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false });
|
||||
assert.throws(() => checkArchitecture(missing), /absent from explicit precache/);
|
||||
const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" });
|
||||
assert.throws(() => checkArchitecture(cyclic), /dependency cycle/);
|
||||
});
|
||||
|
||||
test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => {
|
||||
const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;');
|
||||
const file = path.join(root, 'frontend/modules.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(file));
|
||||
manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }];
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.equal(checkArchitecture(root), 1);
|
||||
manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' });
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.throws(() => checkArchitecture(root), /unused legacy import exception/);
|
||||
assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/);
|
||||
});
|
||||
|
||||
test('inventory reports executable static cycles and separates dynamic edges', t => {
|
||||
const root = fixture(t, { 'shared/a.js': 'export const a = 1;' });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');");
|
||||
fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';");
|
||||
fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;');
|
||||
const result = inventory(root);
|
||||
assert.equal(result.staticImports, 2);
|
||||
assert.equal(result.dynamicLiteralImports, 1);
|
||||
assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]);
|
||||
assert.deepEqual(result.missing, []);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const chatSource = readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8');
|
||||
const sessionSource = readFileSync(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
|
||||
const notesSource = readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||
|
||||
function setup() {
|
||||
const start = chatSource.indexOf(" window.addEventListener('odysseus:notes-context-selected'");
|
||||
const end = chatSource.indexOf(' function _fmtContextNumber(n)', start);
|
||||
assert.ok(start !== -1 && end > start, 'production event handlers must exist');
|
||||
const handlers = new Map();
|
||||
const rendered = [];
|
||||
let sessionId = null;
|
||||
let pending = false;
|
||||
let clear;
|
||||
const win = { addEventListener: (name, fn) => handlers.set(name, fn) };
|
||||
const doc = {
|
||||
getElementById: () => ({ addEventListener: (name, fn) => { if (name === 'click') clear = fn; } }),
|
||||
};
|
||||
const sessionModule = {
|
||||
getCurrentSessionId: () => sessionId,
|
||||
hasPendingChat: () => pending,
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`const _selectedNoteIdsBySession = new Map(); let _pendingSelectedNoteIds = [];
|
||||
${chatSource.slice(start, end)}`,
|
||||
{ window: win, document: doc, sessionModule, console, _renderSelectedNotesIndicator: ids => rendered.push(Array.from(ids)) },
|
||||
);
|
||||
return {
|
||||
setSession(id, isPending = false) { sessionId = id; pending = isPending; },
|
||||
emit(name, detail) { handlers.get(name)({ detail }); },
|
||||
selected(ids) { const detail = { noteIds: ids, accepted: false }; handlers.get('odysseus:notes-context-selected')({ detail }); return detail; },
|
||||
clear() { clear(); },
|
||||
lastRendered() { return rendered.at(-1); },
|
||||
};
|
||||
}
|
||||
|
||||
test('pending selections transfer only to the materialized chat', () => {
|
||||
const ctx = setup();
|
||||
ctx.setSession(null, true);
|
||||
assert.equal(ctx.selected(['a', 'b']).accepted, true);
|
||||
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||
ctx.setSession('new-chat');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat', materialized: true });
|
||||
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||
ctx.setSession('other-chat');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'other-chat' });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
ctx.setSession('new-chat');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat' });
|
||||
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||
ctx.clear();
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
});
|
||||
|
||||
test('new chat resets old indicator and invalid selection is rejected', () => {
|
||||
const ctx = setup();
|
||||
assert.equal(ctx.selected(['a']).accepted, false);
|
||||
ctx.setSession('old-chat');
|
||||
assert.equal(ctx.selected(['a']).accepted, true);
|
||||
ctx.setSession(null, true);
|
||||
ctx.emit('odysseus:session-changed', { sessionId: null });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
assert.equal(ctx.selected(['b']).accepted, true);
|
||||
ctx.emit('odysseus:session-changed', { sessionId: null });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
ctx.setSession('fresh');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'fresh', materialized: true });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
});
|
||||
|
||||
test('producer events and rejection acknowledgement are wired in source', () => {
|
||||
const create = sessionSource.slice(sessionSource.indexOf('export function createDirectChat'), sessionSource.indexOf('export async function materializePendingSession'));
|
||||
const materialize = sessionSource.slice(sessionSource.indexOf('export async function materializePendingSession'), sessionSource.indexOf('export function preMaterializePendingSession'));
|
||||
assert.match(create, /currentSessionId = null;[\s\S]{0,180}odysseus:session-changed/);
|
||||
assert.match(materialize, /currentSessionId = payload.id;[\s\S]{0,180}odysseus:session-changed/);
|
||||
assert.match(notesSource, /if \(!detail\.accepted\)/);
|
||||
});
|
||||
@@ -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."""
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""An exact approval must be able to run a bash/python call the request did not grant (#6635).
|
||||
|
||||
`ToolApprovalStore.create()` sealed the process identity only from the request
|
||||
authority's launch scopes, and those exist only for granted bash/python
|
||||
backends. For an ungranted call the sealing failed silently, so the approval
|
||||
card was created with no process identity and the executor refused it:
|
||||
"Approved action has no sealed process/job identity".
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admin_owner(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
|
||||
|
||||
|
||||
def _authority(workspace, *tools):
|
||||
return RequestAuthority("approval-request", "alice", "s", str(workspace),
|
||||
tuple(OperationGrant(t) for t in tools))
|
||||
|
||||
|
||||
def _approve(authority, tool, content, store=None):
|
||||
store = store or ToolApprovalStore()
|
||||
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
|
||||
tool_name=tool, content=content, workspace=authority.workspace, request_authority=authority,
|
||||
external_untrusted_context_seen=True, capabilities=capabilities_for_action(tool, content))
|
||||
return pending, store.consume(pending.approval_id, decision="approve",
|
||||
owner=authority.owner, session_id=authority.session_id)
|
||||
|
||||
|
||||
async def _dispatch(authority, tool, content, exact):
|
||||
from src import tool_execution as execution
|
||||
return await execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
|
||||
session_id=authority.session_id, workspace=authority.workspace,
|
||||
request_authority=authority, exact_approval=exact,
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
|
||||
|
||||
|
||||
def test_approval_for_ungranted_bash_seals_a_launch_in_the_workspace(tmp_path):
|
||||
authority = _authority(tmp_path, "get_workspace")
|
||||
assert authority.launch_scopes == ()
|
||||
|
||||
pending, _ = _approve(authority, "bash", "printf approved-run")
|
||||
|
||||
bound = pending.process_operation
|
||||
assert bound is not None
|
||||
assert bound.launch is not None
|
||||
assert bound.launch.scope.root.path == str(tmp_path.resolve())
|
||||
assert bound.request_id == authority.request_id
|
||||
|
||||
|
||||
async def test_approved_ungranted_bash_runs_once(tmp_path):
|
||||
authority = _authority(tmp_path, "get_workspace")
|
||||
_, exact = _approve(authority, "bash", "printf approved-run")
|
||||
|
||||
description, result = await _dispatch(authority, "bash", "printf approved-run", exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
assert result.get("exit_code") == 0, result
|
||||
assert "approved-run" in str(result.get("output", "")) + description
|
||||
|
||||
_, replay = await _dispatch(authority, "bash", "printf approved-run", exact)
|
||||
assert replay.get("blocked") is True, replay
|
||||
|
||||
|
||||
async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path):
|
||||
# A child (inherited) authority may only narrow its parent, so an approval
|
||||
# it proposes for an ungranted launch must stay unsealed and unrunnable.
|
||||
from dataclasses import replace
|
||||
child = replace(_authority(tmp_path, "get_workspace"), inherited=True)
|
||||
pending, exact = _approve(child, "bash", "printf approved-run")
|
||||
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
|
||||
assert result.get("blocked") is True
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Exercise publication failure modes without changing the real static tree."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
build = load_script("build_frontend.py")
|
||||
|
||||
|
||||
def project(tmp_path, entries=None):
|
||||
source = tmp_path / "frontend/src/shared/helper.js"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_bytes(b"export const value = 1;\n")
|
||||
manifest = {"version": 1, "modules": entries if entries is not None else [
|
||||
{"source": "shared/helper.js", "deployment": "js/helper.js"}
|
||||
]}
|
||||
(tmp_path / "frontend/modules.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
(tmp_path / ".gitattributes").write_text("* text=auto\nfrontend/src/** text eol=lf\nstatic/js/helper.js text eol=lf\n", encoding="utf-8")
|
||||
return source, tmp_path / "static/js/helper.js"
|
||||
|
||||
|
||||
def test_check_detects_missing_and_stale_output_without_writing(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
|
||||
assert not output.exists()
|
||||
assert build.synchronize(tmp_path) == ["static/js/helper.js"]
|
||||
assert output.read_bytes() == source.read_bytes()
|
||||
source.write_bytes(b"export const value = 2;\n")
|
||||
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
|
||||
assert output.read_bytes() == b"export const value = 1;\n"
|
||||
|
||||
|
||||
def test_publication_is_idempotent_and_preserves_unowned_assets(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
unowned = tmp_path / "static/js/notes.js"
|
||||
unowned.parent.mkdir(parents=True)
|
||||
unowned.write_bytes(b"legacy state owner\n")
|
||||
build.synchronize(tmp_path)
|
||||
timestamp = output.stat().st_mtime_ns
|
||||
assert build.synchronize(tmp_path) == []
|
||||
assert output.stat().st_mtime_ns == timestamp
|
||||
assert unowned.read_bytes() == b"legacy state owner\n"
|
||||
assert output.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../outside.js", "/absolute.js", "shared/../helper.js", "shared\\helper.js", "shared/helper.js?v=1", "shared/helper.js#fragment", "shared/helper.ts"])
|
||||
def test_invalid_plan_does_not_partially_publish(tmp_path, bad):
|
||||
project(tmp_path, [
|
||||
{"source": "shared/helper.js", "deployment": "js/helper.js"},
|
||||
{"source": bad, "deployment": "js/other.js"},
|
||||
])
|
||||
with pytest.raises(ValueError, match="invalid module path"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not (tmp_path / "static").exists()
|
||||
|
||||
|
||||
def test_duplicate_outputs_and_unmapped_sources_are_rejected(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
extra = source.with_name("extra.js")
|
||||
extra.write_bytes(b"export const value = 2;\n")
|
||||
with pytest.raises(ValueError, match="missing from manifest"):
|
||||
build.synchronize(tmp_path)
|
||||
manifest_path = tmp_path / "frontend/modules.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
manifest["modules"].append({"source": "shared/extra.js", "deployment": "js/helper.js"})
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
with pytest.raises(ValueError, match="must be unique"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_symlink_output_cannot_overwrite_another_file(tmp_path):
|
||||
_, output = project(tmp_path)
|
||||
outside = tmp_path / "outside.js"
|
||||
outside.write_bytes(b"untouched\n")
|
||||
output.parent.mkdir(parents=True)
|
||||
output.symlink_to(outside)
|
||||
with pytest.raises(ValueError, match="escapes|symlink"):
|
||||
build.synchronize(tmp_path)
|
||||
assert outside.read_bytes() == b"untouched\n"
|
||||
|
||||
|
||||
def test_real_checkout_check_works_from_another_directory(tmp_path):
|
||||
result = subprocess.run([sys.executable, str(ROOT / "scripts/build_frontend.py"), "--check"], cwd=tmp_path, capture_output=True, text=True, timeout=20)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert (ROOT / "frontend/src/shared/actionMenuOrder.js").read_bytes() == (ROOT / "static/js/actionMenuOrder.js").read_bytes()
|
||||
|
||||
|
||||
def test_isolated_python_distribution_runs_without_source_or_node():
|
||||
result = subprocess.run([sys.executable, str(ROOT / "scripts/verify_frontend_runtime.py")], cwd=ROOT, capture_output=True, text=True, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS: isolated Python lifespan" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("check", [False, True])
|
||||
@pytest.mark.parametrize("path,rule", [
|
||||
("frontend/src/shared/helper.js", "text"),
|
||||
("static/js/helper.js", "text"),
|
||||
("frontend/src/shared/helper.js", "text eol=crlf"),
|
||||
("static/js/helper.js", "text=auto eol=lf"),
|
||||
("static/js/helper.js", "-text eol=lf"),
|
||||
])
|
||||
def test_each_manifest_path_requires_lf_even_when_bytes_already_match(tmp_path, check, path, rule):
|
||||
source, output = project(tmp_path)
|
||||
output.parent.mkdir(parents=True)
|
||||
output.write_bytes(source.read_bytes())
|
||||
other = "static/js/helper.js" if path.startswith("frontend/") else "frontend/src/shared/helper.js"
|
||||
(tmp_path / ".gitattributes").write_text(f"* text=auto\n{other} text eol=lf\n{path} {rule}\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="require repository-owned.*" + path):
|
||||
build.synchronize(tmp_path, check=check)
|
||||
assert output.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_future_manifest_pair_cannot_publish_without_output_attributes(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
extra = source.with_name("future.js")
|
||||
extra.write_bytes(b"export const future = true;\n")
|
||||
manifest_file = tmp_path / "frontend/modules.json"
|
||||
manifest = json.loads(manifest_file.read_text())
|
||||
manifest["modules"].append({"source": "shared/future.js", "deployment": "js/future.js"})
|
||||
manifest_file.write_text(json.dumps(manifest))
|
||||
with pytest.raises(ValueError, match="static/js/future.js"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not output.exists()
|
||||
assert not (tmp_path / "static/js/future.js").exists()
|
||||
with (tmp_path / ".gitattributes").open("a", encoding="utf-8") as attributes:
|
||||
attributes.write("static/js/future.js text eol=lf\n")
|
||||
assert build.synchronize(tmp_path) == ["static/js/helper.js", "static/js/future.js"]
|
||||
assert build.synchronize(tmp_path, check=True) == []
|
||||
|
||||
|
||||
def test_nested_git_attribute_rules_and_paths_with_spaces_are_supported(tmp_path):
|
||||
source, _ = project(tmp_path, [{"source": "shared/helper.js", "deployment": "js/feature space/helper.js"}])
|
||||
attributes = tmp_path / "static/js/feature space/.gitattributes"
|
||||
attributes.parent.mkdir(parents=True)
|
||||
attributes.write_text("*.js text eol=lf\n", encoding="utf-8")
|
||||
assert build.synchronize(tmp_path) == ["static/js/feature space/helper.js"]
|
||||
assert (attributes.parent / "helper.js").read_bytes() == source.read_bytes()
|
||||
attributes.write_text("*.js text eol=crlf\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="eol=crlf"):
|
||||
build.synchronize(tmp_path, check=True)
|
||||
|
||||
|
||||
def test_global_and_local_git_settings_cannot_mask_missing_repository_rule(tmp_path, monkeypatch):
|
||||
project(tmp_path)
|
||||
(tmp_path / ".gitattributes").write_text("frontend/src/** text eol=lf\n", encoding="utf-8")
|
||||
global_attributes = tmp_path / "global-attributes"
|
||||
global_attributes.write_text("* text eol=lf\n", encoding="utf-8")
|
||||
config = tmp_path / "global-config"
|
||||
config.write_text(f'[core]\n attributesFile = "{global_attributes.as_posix()}"\n autocrlf = true\n', encoding="utf-8")
|
||||
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True, capture_output=True)
|
||||
(tmp_path / ".git/info/attributes").write_text("* text eol=lf\n", encoding="utf-8")
|
||||
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
|
||||
monkeypatch.setenv("GIT_DIR", str(tmp_path / ".git"))
|
||||
monkeypatch.setenv("GIT_WORK_TREE", str(tmp_path))
|
||||
with pytest.raises(ValueError, match="static/js/helper.js"):
|
||||
build.synchronize(tmp_path, check=True)
|
||||
|
||||
|
||||
def test_attribute_enforcement_fails_clearly_when_development_git_is_missing(tmp_path, monkeypatch):
|
||||
_, output = project(tmp_path)
|
||||
monkeypatch.setattr(build.shutil, "which", lambda name: None)
|
||||
with pytest.raises(ValueError, match="Git is required for frontend publication"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not output.exists()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Real Chromium proof at the existing /static/ service-worker scope.
|
||||
|
||||
This deliberately does not widen SW scope or claim offline SPA root reload.
|
||||
The helper is first evaluated offline, after install-time precaching only.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not on PATH")
|
||||
def test_precached_helper_imports_offline_in_a_fresh_page_and_panels_stay_lazy():
|
||||
script = r'''
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const installed = await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
const response = await caches.match('/static/js/actionMenuOrder.js');
|
||||
return { scope: registration.scope, cached: !!response && response.ok };
|
||||
});
|
||||
assert.equal(installed.scope, `${origin}/static/`);
|
||||
assert.equal(installed.cached, true);
|
||||
// New page gives a fresh ES-module map. No helper import occurred online.
|
||||
await context.setOffline(true);
|
||||
const offline = await context.newPage();
|
||||
await offline.goto(`${origin}/static/js/panels.js`);
|
||||
const requests = [];
|
||||
offline.on('request', request => requests.push(request.url()));
|
||||
const result = await offline.evaluate(async () => {
|
||||
const helper = await import('/static/js/actionMenuOrder.js');
|
||||
const again = await import('./actionMenuOrder.js');
|
||||
const panels = await import('/static/js/panels.js?v=20260909movepicklayer1');
|
||||
return {
|
||||
sameModule: helper === again,
|
||||
exports: Object.keys(helper).sort(),
|
||||
order: helper.orderActionMenuItems([{ label: 'Delete' }, { label: 'Rename' }]).map(i => i.label),
|
||||
panels: panels.panelNames(),
|
||||
};
|
||||
});
|
||||
assert.equal(result.sameModule, true);
|
||||
assert.deepEqual(result.order, ['Rename', 'Delete']);
|
||||
assert.ok(result.panels.includes('editor'));
|
||||
assert.equal(requests.some(url => url.includes('/galleryEditor.js')), false);
|
||||
console.log(JSON.stringify({ ...result, ...installed }));
|
||||
} finally { await browser.close(); }
|
||||
'''
|
||||
result = subprocess.run(["node", "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["exports"] == ["SELECT_MENU_ICON", "actionMenuRank", "orderActionMenuItems"]
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Regression tests for untrusted selected-note ID parsing."""
|
||||
import json
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from routes.chat_routes import _parse_selected_note_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(None, []),
|
||||
("", []),
|
||||
("[]", []),
|
||||
('["one", "two", "one"]', ["one", "two"]),
|
||||
])
|
||||
def test_selected_note_ids_valid(raw, expected):
|
||||
assert _parse_selected_note_ids(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [
|
||||
"42", "true", "null", '{"id":"a"}',
|
||||
'["one", 5]', '["one", null]', '["one", false]',
|
||||
'[""]', '[" "]', '["one", {}]',
|
||||
"not json", json.dumps(["a"] * 51),
|
||||
json.dumps(["a" * 129]), " " * 8193,
|
||||
])
|
||||
def test_selected_note_ids_rejected(raw):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_parse_selected_note_ids(raw)
|
||||
assert exc.value.status_code == 400
|
||||
@@ -133,3 +133,20 @@ def test_unaudited_or_below_threshold_published_skill_is_not_injected(tmp_path):
|
||||
assert not sm.get_relevant_skills(
|
||||
"premature skill", min_confidence=0.80,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_approve_off_injects_published_skills_without_an_audit(tmp_path):
|
||||
# #6634: OFF used min_confidence=2.0, which no 0-1 confidence clears, so
|
||||
# every user skill was rejected. Publishing is the approval when OFF.
|
||||
(tmp_path / "skills").mkdir()
|
||||
for name, status in (("premature-skill", "published"), ("draft-skill", "draft")):
|
||||
path = _write_skill_md(tmp_path / "skills", name)
|
||||
text = path.read_text(encoding="utf-8").replace("audit_verdict: pass\n", "")
|
||||
path.write_text(text.replace("status: published", f"status: {status}"), encoding="utf-8")
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
|
||||
query = "premature skill draft skill"
|
||||
# threshold=0 isolates the eligibility gate from relevance scoring.
|
||||
assert _names(sm.get_relevant_skills(query, threshold=0.0, published_only=True)) == {"premature-skill"}
|
||||
# Auto-approve ON still needs a passing audit, as before.
|
||||
assert not sm.get_relevant_skills(query, threshold=0.0, min_confidence=0.85)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Skill test runs must close the agent stream in their own task (#6635).
|
||||
|
||||
When the agent asks for an exact approval, the skill test pauses by leaving its
|
||||
`async for` over `stream_agent_loop`. The abandoned generator was later closed
|
||||
by asyncio's finalizer in another task, so the ContextVar resets in its cleanup
|
||||
(request_authority, turn_contract, ...) raised "Token ... was created in a
|
||||
different Context". The stream must be closed before the run returns.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from contextvars import ContextVar
|
||||
|
||||
import routes.skills_routes as skills_routes
|
||||
|
||||
_BOUND: ContextVar = ContextVar("skill_test_bound", default=None)
|
||||
|
||||
_APPROVAL = {"kind": "tool_approval", "approval_id": "approval-1", "question": "Allow?"}
|
||||
|
||||
|
||||
def _fake_stream(events, closed):
|
||||
async def stream_agent_loop(*args, **kwargs):
|
||||
token = _BOUND.set("bound")
|
||||
try:
|
||||
for event in events:
|
||||
yield "data: " + json.dumps(event) + "\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
finally:
|
||||
# Raises ValueError when run from another task's context.
|
||||
_BOUND.reset(token)
|
||||
closed.append(asyncio.current_task())
|
||||
return stream_agent_loop
|
||||
|
||||
|
||||
async def test_paused_skill_test_closes_the_agent_stream_in_its_own_task(monkeypatch):
|
||||
closed = []
|
||||
events = [{"type": "tool_output", "output": "blocked", "ask_user": _APPROVAL},
|
||||
{"delta": "never reached"}]
|
||||
monkeypatch.setattr("src.agent_loop.stream_agent_loop", _fake_stream(events, closed))
|
||||
key = ("alice", "demo")
|
||||
skills_routes._skill_test_jobs[key] = {"log": [], "status": "running"}
|
||||
try:
|
||||
await skills_routes._run_skill_test_job(
|
||||
key, "demo", "# skill", "task", "http://model.test", "m", {}, "alice")
|
||||
job = skills_routes._skill_test_jobs[key]
|
||||
assert job["status"] == "awaiting_approval"
|
||||
assert closed == [asyncio.current_task()]
|
||||
finally:
|
||||
skills_routes._skill_test_jobs.pop(key, None)
|
||||
|
||||
|
||||
async def test_audit_arm_closes_the_agent_stream_when_it_stops_for_approval(monkeypatch):
|
||||
closed = []
|
||||
events = [{"type": "tool_output", "output": "blocked", "ask_user": _APPROVAL},
|
||||
{"delta": "never reached"}]
|
||||
monkeypatch.setattr("src.agent_loop.stream_agent_loop", _fake_stream(events, closed))
|
||||
|
||||
_, _, approval = await skills_routes._run_skill_audit_arm(
|
||||
[{"role": "user", "content": "task"}], "http://model.test", "m", {}, "alice")
|
||||
|
||||
assert approval == _APPROVAL
|
||||
assert closed == [asyncio.current_task()]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Opt-in ``utility_reasoning_effort`` for utility/background LLM calls.
|
||||
|
||||
The setting is resolved in ``task_llm_call_async`` and sent only to candidates
|
||||
on the utility chain whose model advertises the level. Explicit caller values
|
||||
win; with the setting empty nothing changes.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm_core, task_endpoint
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
UTIL = ("https://api.openai.example/v1", "gpt-5.5", {})
|
||||
UTIL_FALLBACK = ("https://fallback.example/v1", "gpt-5.5-mini", {})
|
||||
OVERRIDE = ("https://override.example/v1", "gpt-5.5", {})
|
||||
NO_EVIDENCE = ("https://local.example/v1", "tiny-local-model", {})
|
||||
|
||||
|
||||
def _stub_resolvers(monkeypatch, utility):
|
||||
"""Stub the resolvers in both modules that look the utility chain up."""
|
||||
from src import endpoint_resolver
|
||||
|
||||
resolve = lambda prefix, *a, **k: { # noqa: E731
|
||||
"utility": utility, "task": OVERRIDE, "default": NO_EVIDENCE,
|
||||
}[prefix]
|
||||
fallbacks = lambda owner=None: [UTIL_FALLBACK] # noqa: E731
|
||||
for mod in (task_endpoint, endpoint_resolver):
|
||||
monkeypatch.setattr(mod, "resolve_endpoint", resolve)
|
||||
monkeypatch.setattr(mod, "resolve_utility_fallback_candidates", fallbacks)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chain(monkeypatch):
|
||||
"""Stub the resolvers and capture what reaches llm_call_async per model."""
|
||||
_stub_resolvers(monkeypatch, UTIL)
|
||||
|
||||
async def _quiet(*_a, **_k):
|
||||
return None
|
||||
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", _quiet)
|
||||
|
||||
calls = {}
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
calls[url] = kw.get("reasoning_effort")
|
||||
if url != UTIL_FALLBACK[0]:
|
||||
raise RuntimeError("force walk down the chain")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
return calls
|
||||
|
||||
|
||||
def _set_effort(monkeypatch, value):
|
||||
monkeypatch.setattr(
|
||||
"src.settings.get_user_setting",
|
||||
lambda key, owner="", default=None: value if key == "utility_reasoning_effort" else default,
|
||||
)
|
||||
|
||||
|
||||
def _run(**kw):
|
||||
return asyncio.run(task_endpoint.task_llm_call_async(
|
||||
[{"role": "user", "content": "hi"}], **kw,
|
||||
))
|
||||
|
||||
|
||||
def test_setting_applies_only_to_utility_chain(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "high")
|
||||
_run()
|
||||
# The Background Tasks route shares the model id with the utility model but
|
||||
# is a different (url, model) route, so it gets no effort.
|
||||
assert chain[OVERRIDE[0]] is None
|
||||
assert chain[UTIL[0]] == "high"
|
||||
assert chain[UTIL_FALLBACK[0]] == "high"
|
||||
|
||||
|
||||
def test_utility_candidate_gets_validated_effort(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "HIGH")
|
||||
_run()
|
||||
assert chain[UTIL[0]] == "high"
|
||||
|
||||
|
||||
def test_effort_without_evidence_is_dropped(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "high")
|
||||
_stub_resolvers(monkeypatch, ("https://local.example/v1", "tiny-local-model", {}))
|
||||
_run()
|
||||
assert chain["https://local.example/v1"] is None
|
||||
|
||||
|
||||
def test_unsupported_level_is_dropped(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "bogus")
|
||||
_run()
|
||||
assert chain[UTIL[0]] is None
|
||||
|
||||
|
||||
def test_empty_setting_changes_nothing(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "")
|
||||
_run()
|
||||
assert chain and all(effort is None for effort in chain.values())
|
||||
|
||||
|
||||
def test_explicit_caller_effort_wins(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "high")
|
||||
_run(reasoning_effort="low")
|
||||
assert chain and all(effort == "low" for effort in chain.values())
|
||||
|
||||
|
||||
def test_fallback_factory_can_override_messages_and_kwargs(monkeypatch):
|
||||
seen = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
seen.append((model, messages, kw.get("reasoning_effort"), kw.get("temperature")))
|
||||
if len(seen) == 1:
|
||||
raise RuntimeError("first fails")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
|
||||
def factory(index, url, model, headers):
|
||||
return {"kwargs": {"reasoning_effort": f"e{index}"}}
|
||||
|
||||
out = asyncio.run(llm_core.llm_call_async_with_fallback(
|
||||
[("http://a/v1", "m1", {}), ("http://b/v1", "m2", {})],
|
||||
[{"role": "user", "content": "x"}],
|
||||
temperature=0.1,
|
||||
candidate_request_factory=factory,
|
||||
))
|
||||
assert out == "ok"
|
||||
assert [(s[0], s[2], s[3]) for s in seen] == [("m1", "e0", 0.1), ("m2", "e1", 0.1)]
|
||||
|
||||
|
||||
def test_validate_reasoning_effort_needs_evidence():
|
||||
assert validate_reasoning_effort("gpt-5.5", "high") == "high"
|
||||
assert validate_reasoning_effort("tiny-local-model", "high") is None
|
||||
assert validate_reasoning_effort("gpt-5.5", "") is None
|
||||
|
||||
|
||||
def test_chatgpt_wire_carries_effort_and_default_omits_it():
|
||||
sent = llm_core._build_chatgpt_responses_payload(
|
||||
"gpt-5.5", [{"role": "user", "content": "hi"}], 0.3, 64, reasoning_effort="high",
|
||||
)
|
||||
assert sent["reasoning"] == {"effort": "high"}
|
||||
plain = llm_core._build_chatgpt_responses_payload(
|
||||
"gpt-5.5", [{"role": "user", "content": "hi"}], 0.3, 64,
|
||||
)
|
||||
assert "reasoning" not in plain
|
||||
|
||||
|
||||
def test_sync_llm_call_cache_key_includes_effort(monkeypatch):
|
||||
keys = []
|
||||
|
||||
def fake_cached(key):
|
||||
keys.append(key)
|
||||
return "cached"
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", fake_cached)
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
llm_core.llm_call("http://x/v1", "m", msgs)
|
||||
llm_core.llm_call("http://x/v1", "m", msgs, reasoning_effort="high")
|
||||
llm_core.llm_call("http://x/v1", "m", msgs, reasoning_effort="HIGH")
|
||||
assert keys[0] != keys[1]
|
||||
assert keys[1] == keys[2]
|
||||
|
||||
|
||||
def test_setting_is_registered_default_and_per_user():
|
||||
from src.settings import DEFAULT_SETTINGS, _PER_USER_KEYS
|
||||
|
||||
assert DEFAULT_SETTINGS["utility_reasoning_effort"] == ""
|
||||
assert "utility_reasoning_effort" in _PER_USER_KEYS
|
||||
|
||||
|
||||
# --- effort_for_call: setting, then the chat session's own effort ---
|
||||
|
||||
CHAT = ("https://chat.example/v1", "gpt-5.5", {})
|
||||
|
||||
|
||||
class _Sess:
|
||||
def __init__(self, thinking_mode="effort:low", url=CHAT[0], model=CHAT[1], owner="alice"):
|
||||
self.thinking_mode = thinking_mode
|
||||
self.endpoint_url = url
|
||||
self.model = model
|
||||
self.owner = owner
|
||||
self.headers = {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def util(monkeypatch):
|
||||
"""Utility chain = {UTIL}; the setting is controlled via ``settings[owner]``."""
|
||||
from src import utility_effort
|
||||
|
||||
settings = {}
|
||||
monkeypatch.setattr(utility_effort, "utility_routes", lambda owner=None: {(UTIL[0], UTIL[1])})
|
||||
monkeypatch.setattr(
|
||||
"src.settings.get_user_setting",
|
||||
lambda key, owner="", default=None: (
|
||||
settings.get(owner, "") if key == "utility_reasoning_effort" else default
|
||||
),
|
||||
)
|
||||
util_mod = utility_effort
|
||||
util_mod.settings = settings
|
||||
return util_mod
|
||||
|
||||
|
||||
def test_setting_applies_to_utility_route_and_wins_over_chat_effort(util):
|
||||
util.settings["alice"] = "medium"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess("effort:low")) == "medium"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice") == "medium"
|
||||
|
||||
|
||||
def test_setting_is_per_user(util):
|
||||
util.settings["alice"] = "high"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice") == "high"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "bob") is None
|
||||
|
||||
|
||||
def test_session_effort_inherited_on_the_session_route(util):
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) == "low"
|
||||
# Endpoint spelling differences (trailing slash) are still the same route.
|
||||
assert util.effort_for_call(CHAT[0] + "/", CHAT[1], "alice", _Sess("effort:low")) == "low"
|
||||
|
||||
|
||||
def test_session_effort_not_inherited_on_another_route(util):
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess("effort:low")) is None
|
||||
assert util.effort_for_call(CHAT[0], "gpt-5.5-mini", "alice", _Sess("effort:low")) is None
|
||||
|
||||
|
||||
def test_session_effort_off_or_missing_inherits_nothing(util):
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("off")) is None
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("")) is None
|
||||
|
||||
|
||||
def test_no_session_means_no_inheritance(util):
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice") is None
|
||||
|
||||
|
||||
def test_explicit_off_setting_blocks_session_effort_inheritance(util):
|
||||
util.settings["alice"] = "off"
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) is None
|
||||
assert util.candidate_effort_factory("alice") is None
|
||||
|
||||
util.settings["alice"] = "default"
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) is None
|
||||
assert util.candidate_effort_factory("alice") is None
|
||||
|
||||
|
||||
def test_inherited_effort_is_validated_against_the_model(util):
|
||||
assert util.effort_for_call(
|
||||
NO_EVIDENCE[0], NO_EVIDENCE[1], "alice",
|
||||
_Sess("effort:low", url=NO_EVIDENCE[0], model=NO_EVIDENCE[1]),
|
||||
) is None
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:bogus")) is None
|
||||
|
||||
|
||||
def test_effort_for_call_never_raises(util, monkeypatch):
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(util, "effort_for_route", boom)
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess()) is None
|
||||
|
||||
|
||||
# --- no global fill: callers that are not utility call sites never get an effort ---
|
||||
|
||||
def test_llm_call_async_adds_no_default_effort(util, monkeypatch):
|
||||
# The owner-less helpers resolve at global scope; set it there and per user.
|
||||
util.settings[""] = util.settings["alice"] = "high"
|
||||
seen = []
|
||||
|
||||
def fake_key(url, model, messages, temperature, max_tokens, headers=None,
|
||||
thinking_mode=None, reasoning_effort=None):
|
||||
seen.append(reasoning_effort)
|
||||
return "k"
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cache_key", fake_key)
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: "cached")
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
# Same (url, model) as the utility route, but not a utility call site.
|
||||
asyncio.run(llm_core.llm_call_async(UTIL[0], UTIL[1], msgs))
|
||||
llm_core.llm_call(UTIL[0], UTIL[1], msgs)
|
||||
asyncio.run(llm_core.llm_call_async(UTIL[0], UTIL[1], msgs, reasoning_effort="low"))
|
||||
assert seen == [None, None, "low"]
|
||||
|
||||
|
||||
# --- call sites pass the effort explicitly ---
|
||||
|
||||
def _capture_llm(monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
sent.append((url, model, kw.get("reasoning_effort")))
|
||||
return "A title"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
return sent
|
||||
|
||||
|
||||
class _History:
|
||||
role = "user"
|
||||
content = "hello there"
|
||||
|
||||
|
||||
def _title_session(**kw):
|
||||
sess = _Sess(**kw)
|
||||
sess.id = "s1"
|
||||
sess.history = [_History()]
|
||||
return sess
|
||||
|
||||
|
||||
class _Manager:
|
||||
def update_session_name(self, *_a):
|
||||
pass
|
||||
|
||||
|
||||
def test_auto_title_inherits_chat_effort_when_utility_is_blank(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
sent = _capture_llm(monkeypatch)
|
||||
# Blank utility/task resolves to the session's own route.
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (CHAT[0], CHAT[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(CHAT[0], CHAT[1], "low")]
|
||||
|
||||
|
||||
def test_auto_title_setting_beats_chat_effort(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
util.settings["alice"] = "medium"
|
||||
sent = _capture_llm(monkeypatch)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(UTIL[0], UTIL[1], "medium")]
|
||||
|
||||
|
||||
def test_auto_title_on_a_different_utility_model_gets_no_chat_effort(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
sent = _capture_llm(monkeypatch)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(UTIL[0], UTIL[1], None)]
|
||||
|
||||
|
||||
def test_memory_extraction_passes_chat_effort(util, monkeypatch):
|
||||
from services.memory import memory_extractor
|
||||
|
||||
sent = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
sent.append(kw.get("reasoning_effort"))
|
||||
return "[]"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
|
||||
class Sess(_Sess):
|
||||
def get_context_messages(self):
|
||||
return [{"role": "user", "content": "I live in Oslo"},
|
||||
{"role": "assistant", "content": "Noted"}]
|
||||
|
||||
asyncio.run(memory_extractor.extract_and_store(
|
||||
Sess(), object(), object(), CHAT[0], CHAT[1], {},
|
||||
))
|
||||
assert sent and sent[0] == "low"
|
||||
|
||||
|
||||
def test_email_summary_sends_setting_effort_for_owner_only(util, monkeypatch):
|
||||
from routes.email import email_helpers
|
||||
|
||||
util.settings["alice"] = "medium"
|
||||
sent = []
|
||||
|
||||
async def fake_call(**kw):
|
||||
sent.append(kw.get("reasoning_effort"))
|
||||
return "- point"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
for owner in ("alice", "bob", None):
|
||||
asyncio.run(email_helpers._generate_email_summary(
|
||||
UTIL[0], UTIL[1], "a@x", "s", "body", owner=owner,
|
||||
))
|
||||
assert sent == ["medium", None, None]
|
||||
|
||||
|
||||
def test_candidate_effort_factory_scopes_to_utility_route(util):
|
||||
from src.utility_effort import candidate_effort_factory
|
||||
|
||||
assert candidate_effort_factory("alice") is None
|
||||
util.settings["alice"] = "medium"
|
||||
factory = candidate_effort_factory("alice")
|
||||
assert factory(0, UTIL[0], UTIL[1], {}) == {"kwargs": {"reasoning_effort": "medium"}}
|
||||
assert factory(1, NO_EVIDENCE[0], NO_EVIDENCE[1], {}) == {}
|
||||
assert candidate_effort_factory("bob") is None
|
||||
|
||||
|
||||
def test_fallback_route_efforts_and_factory(monkeypatch):
|
||||
from src.utility_effort import candidate_effort_factory, effort_for_route
|
||||
|
||||
def fake_resolve_id(ep_id, model, owner=None):
|
||||
if ep_id == "ep-fallback":
|
||||
return ("https://fallback.example/v1", model, {})
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("src.endpoint_resolver.resolve_endpoint_by_id", fake_resolve_id)
|
||||
monkeypatch.setattr(
|
||||
"src.settings.load_settings",
|
||||
lambda: {"utility_model_fallbacks": [{"endpoint_id": "ep-fallback", "model": "gpt-5.5-mini", "reasoning_effort": "low"}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.settings.get_user_setting",
|
||||
lambda key, owner="", default=None: default,
|
||||
)
|
||||
|
||||
factory = candidate_effort_factory("alice")
|
||||
assert factory is not None
|
||||
assert effort_for_route("https://api.openai.example/v1", "gpt-5.5", "alice") is None
|
||||
assert effort_for_route("https://fallback.example/v1", "gpt-5.5-mini", "alice") == "low"
|
||||
assert factory(0, "https://fallback.example/v1", "gpt-5.5-mini", {}) == {"kwargs": {"reasoning_effort": "low"}}
|
||||
|
||||
|
||||
def test_sync_openai_compatible_wire_transmits_explicit_effort(monkeypatch):
|
||||
import httpx
|
||||
|
||||
sent = []
|
||||
|
||||
def fake_post(url, headers, **kwargs):
|
||||
sent.append(dict(kwargs["json"]))
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": "ok"}}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", lambda *_: None)
|
||||
monkeypatch.setattr(llm_core, "_set_cached_response", lambda *a, **k: None)
|
||||
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a: None)
|
||||
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware", fake_post)
|
||||
|
||||
url = "https://example.test/v1"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
llm_core.llm_call(
|
||||
url, "gpt-5.5", messages, max_tokens=64,
|
||||
reasoning_effort="HIGH",
|
||||
)
|
||||
llm_core.llm_call(url, "gpt-5.5", messages, max_tokens=64)
|
||||
|
||||
assert sent[0]["reasoning_effort"] == "high"
|
||||
assert "reasoning_effort" not in sent[1]
|
||||
|
||||
|
||||
def test_async_openai_compatible_wire_transmits_explicit_effort(monkeypatch):
|
||||
import asyncio
|
||||
import httpx
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
sent = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_slot(*args, **kwargs):
|
||||
yield
|
||||
|
||||
async def fake_post(client, url, headers, **kwargs):
|
||||
sent.append(dict(kwargs["json"]))
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": "ok"}}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", lambda *_: None)
|
||||
monkeypatch.setattr(llm_core, "_set_cached_response", lambda *a, **k: None)
|
||||
monkeypatch.setattr(llm_core, "_get_http_client", lambda: object())
|
||||
monkeypatch.setattr(llm_core, "_is_host_dead", lambda *_: False)
|
||||
monkeypatch.setattr(llm_core, "_local_model_slot", fake_slot)
|
||||
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a: None)
|
||||
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
|
||||
|
||||
async def exercise():
|
||||
url = "https://example.test/v1"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
await llm_core.llm_call_async(
|
||||
url, "gpt-5.5", messages, max_tokens=0,
|
||||
max_retries=1, reasoning_effort="medium",
|
||||
)
|
||||
await llm_core.llm_call_async(
|
||||
url, "gpt-5.5", messages, max_tokens=0,
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
assert sent[0]["reasoning_effort"] == "medium"
|
||||
assert "reasoning_effort" not in sent[1]
|
||||
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