Compare commits

...
Author SHA1 Message Date
dependabot[bot] 34f6f4f6e3 chore(deps-dev): bump the npm group across 1 directory with 2 updates
Bumps the npm group with 2 updates in the / directory: [@antithesishq/bombadil](https://github.com/antithesishq/bombadil) and [@playwright/test](https://github.com/microsoft/playwright).


Updates `@antithesishq/bombadil` from 0.7.0 to 0.7.8
- [Release notes](https://github.com/antithesishq/bombadil/releases)
- [Changelog](https://github.com/antithesishq/bombadil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/antithesishq/bombadil/compare/v0.7.0...v0.7.8)

Updates `@playwright/test` from 1.62.1 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.1...v1.64.0)

---
updated-dependencies:
- dependency-name: "@antithesishq/bombadil"
  dependency-version: 0.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@playwright/test"
  dependency-version: 1.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-13 20:06:04 +00:00
Alexandre Teixeira 1cc56cb9d7 refactor(frontend): establish source ownership and architecture gates (#6701) 2026-10-13 21:03:48 +01:00
Harshit Ojha b5b76fe46d fix(skills): inject published skills when Auto-approve is off (#6643)
With Auto-approve OFF the agent loop asked get_relevant_skills for
min_confidence=2.0, meant as 'published only'. The gate applies the
confidence check to published skills too, and confidence is 0-1, so no
user skill was ever injected, published or not. The explicit-activation
path already treats OFF as status == published.

Add a published_only flag to get_relevant_skills and pass it from both
callers that used the sentinel (relevance injection and the skill
preflight). Auto-approve ON is unchanged: a skill still needs a passing
audit and the confidence threshold.

Fixes #6634
2026-10-13 21:02:23 +01:00
Mark CockramandAlexandre Teixeira 0e2c08300e feat(settings): opt-in reasoning effort for utility model calls (#6572)
* feat(settings): opt-in reasoning effort for utility model calls

Add utility_reasoning_effort (empty = provider default) and let utility
calls inherit a chat session's effort only on that session's matching
endpoint+model route. Explicit caller effort wins; otherwise the utility
setting wins over session inheritance on utility-chain routes. Resolve
the chain and setting per user and validate levels against model evidence.
No-session work uses the setting only, with no chat-effort crossover.

- src/utility_effort.py owns effort_for_call and candidate_effort_factory
- remove global effort fill from llm_call/llm_call_async; utility call
  sites pass effort explicitly, so unrelated calls on the same route do
  not pick it up
- wire auto-title, memory/persona/skill extraction, memory suggest/import/
  audit, compaction, auto-sort, and per-owner background task candidates
- wire no-session email writing-style, summary, and translation calls,
  plus skills evaluation, necessity/retrieval audits, and improvement
- deliberately exclude email replies because thinking_mode='off'
- add the optional candidate_request_factory to llm_call_async_with_fallback
- add sync llm_call reasoning_effort and include it in the cache key
- show the Utility Model effort selector only for advertised levels

Fixes #6569

* fix(utility): exclude email replies and support inherited effort in settings

Pass reasoning_effort='' to task_llm_call_async for scheduled reply drafting to honor the email reply exclusion. In AI settings, resolve the effective Default Chat endpoint and model when Utility is set to 'Same as chat' so supported reasoning effort levels can be selected.

* feat(utility): support same-as-chat effort and session model fallback in compaction

In AI settings, show 'Same as chat' as the default effort option when Utility Model is inherited, and support an explicit 'Provider default' (off) override. Pass the active session fallback in context compaction callers so compaction shares the live chat session's model and reasoning effort for maximum prompt/KV cache affinity.

* fix(settings): clarify chat effort inheritance and keep option visible

Label inherited effort as 'Same as chat (when supported)' and mention
cache affinity and latency speedups in the help text. Ensure the effort
control remains visible when the utility model is set to 'Same as chat'
even if the default chat model lacks advertised reasoning levels, and
populate candidate levels across configured endpoints so the user can
configure effort or keep provider default.

* feat(settings): per-fallback reasoning effort for utility model

Each utility fallback row gets an Effort select, shown only when the
selected model advertises reasoning levels. The choice is stored as
reasoning_effort on the utility_model_fallbacks entry and sent only to
that candidate; an empty value means provider default.

* fix(utility): transmit reasoning effort in non-streaming calls

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-10-13 21:02:16 +01:00
GrzegorzandClaude Opus 5.5 afecd44ddf fix(approvals): let "approve once" run ungranted bash/python, close skill-test streams (#6635) (#6683)
ToolApprovalStore.create() sealed a process identity only from the request
authority's launch scopes, which exist only for granted bash/python. For an
ungranted call the sealing failed silently, so the approval was created
without a process identity and the executor always refused it ("Approved
action has no sealed process/job identity"). When the authority is not
inherited and has no launch scope for the backend, seal one in the
request's single workspace root at approval time, as filesystem approvals
already do. Inherited authorities still cannot widen.

The skill test run and audit arm left their `async for` over
stream_agent_loop early when an approval was needed, without closing the
generator. asyncio closed it later from another task, so its ContextVar
resets raised "Token ... was created in a different Context". Close the
stream with aclosing, like the scheduler and teacher escalation do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:16:02 +01:00
Beyrem WeslatiandAlexandre Teixeira 8feb50e894 feat(notes): attach selected Notes/Todos as Agent context (#6495)
* feat: add Notes and Todos as agent chat context

- Send selected Note/Todo IDs from the Notes UI
- Load selected notes with owner scoping in chat routes
- Format note content and checklist items for the model
- Inject selected notes as untrusted chat context
- Add a selected-context indicator beside Shell Access
- Keep selected context associated with the active chat session

* Improved selected notes context handling and discouragin redundant note lookup and permission requests

* Fix selected notes UI when switching chats

* fix(notes): harden selected context lifecycle and validation

---------

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

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

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

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

* fix(branding): restore publication asset integrations

* fix(publication): tighten restoration provenance
2026-10-07 02:08:34 +01:00
83 changed files with 4250 additions and 271 deletions
+4
View File
@@ -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
+31 -8
View File
@@ -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"
+21
View File
@@ -67,3 +67,24 @@ SAN-191 reconciles references, font preferences, catalogs, tests and packaging.
The service-worker cache version changes so activation deletes prior app caches.
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.
+8 -2
View File
@@ -1,4 +1,6 @@
<h1 align="center">Odysseus</h1>
<p align="center">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
<p align="center">
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
View File
@@ -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.
+127
View File
@@ -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.
+223
View File
@@ -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.
+173
View File
@@ -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.
+453
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
{
"version": 1,
"modules": [
{
"source": "shared/actionMenuOrder.js",
"deployment": "js/actionMenuOrder.js"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
+29
View File
@@ -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);
}
+25 -29
View File
@@ -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
View File
@@ -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"
}
}
+20
View File
@@ -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
View File
@@ -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,
+3
View File
@@ -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)
+1
View File
@@ -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:
+10 -1
View File
@@ -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()
+23 -18
View File
@@ -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"],
@@ -1031,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
@@ -1129,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
@@ -1145,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)
+7
View File
@@ -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
+14 -2
View File
@@ -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
View File
@@ -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
+148
View File
@@ -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())
+180
View File
@@ -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; }
}
+69
View File
@@ -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())
+10
View File
@@ -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,
+2
View File
@@ -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)",
+10 -3
View File
@@ -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
View File
@@ -20,7 +20,7 @@ This spec covers the current browser app in:
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
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
View File
@@ -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
+18 -9
View File
@@ -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
View File
@@ -2297,10 +2297,10 @@ async def action_audit_skills(owner: str, **kwargs) -> Tuple[str, bool]:
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
"""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
+53 -3
View File
@@ -96,6 +96,41 @@ _PUBLIC_PREVIEW_TOOL_ERRORS = {message: message for message in (
)}
def _schema_argument_hint(args, schema):
"""Describe a schema mismatch from the schema and the call's own arguments.
Rebuilt from a fresh validation rather than read off the caught exception,
so nothing the exception carries can reach the model.
"""
if not isinstance(schema, dict):
return ''
try:
validator = jsonschema.validators.validator_for(schema)(schema)
error = jsonschema.exceptions.best_match(validator.iter_errors(args))
except jsonschema.exceptions.SchemaError:
return ''
if error is None:
return ''
location = '.'.join(str(part) for part in error.absolute_path)[:80]
field = f"'{location}' " if location else ''
if error.validator == 'required' and isinstance(error.instance, dict):
missing = [str(name) for name in error.validator_value if name not in error.instance]
prefix = f'{location}.' if location else ''
hint = 'Missing required argument: ' + ', '.join(f"'{prefix}{name}'" for name in missing) + '.'
elif error.validator == 'type':
expected = error.validator_value
expected = ' or '.join(expected) if isinstance(expected, list) else str(expected)
hint = f'Argument {field}must be of type {expected}.'
elif error.validator == 'enum':
allowed = ', '.join(str(value) for value in error.validator_value[:12])
hint = f'Argument {field}must be one of: {allowed}.'
elif error.validator == 'additionalProperties':
hint = 'Remove arguments the offered tool schema does not define.'
else:
return ''
return f'{hint} Correct the call using the offered tool schema.'
def _public_preview_tool_error(exc, *, execution_attempted=False):
"""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,
}
+9 -1
View File
@@ -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
View File
@@ -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)")
+5
View File
@@ -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",
}
+7
View File
@@ -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
View File
@@ -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:
+9 -5
View File
@@ -964,24 +964,28 @@ def _is_hardlinked_regular_file(resolved: str) -> bool:
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
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()
+150
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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

+22
View File
@@ -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 &ldquo;Same as chat&rdquo; 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;">
+68
View File
@@ -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);
+14
View File
@@ -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));
+15
View File
@@ -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
View File
@@ -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();
});
}
+1
View File
@@ -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() {
+6 -1
View File
@@ -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
View File
@@ -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;
+142
View File
@@ -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, []);
});
+41 -7
View File
@@ -14,23 +14,57 @@ const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
page.on('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.
+81
View File
@@ -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\)/);
});
+38 -1
View File
@@ -176,6 +176,41 @@ def test_native_file_tools_hide_control_plane_hardlink_alias(tmp_path, monkeypat
assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"]
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
+170
View File
@@ -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"]
+125
View File
@@ -0,0 +1,125 @@
"""Background note reminders retry failed primary delivery without losing receipts."""
import datetime
import json
from types import SimpleNamespace
import httpx
import pytest
from tests.helpers.database import disposable_database
@pytest.fixture
def reminder_scan(tmp_path, monkeypatch):
from core import database
from routes import note_routes
from src import builtin_actions, integrations, settings
instant = datetime.datetime(2026, 10, 7, 12, tzinfo=datetime.timezone.utc)
class Clock(datetime.datetime):
@classmethod
def now(cls, tz=None):
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
calls, notices = [], []
statuses = [503, 200]
channel = {"reminder_channel": "webhook", "reminder_webhook_integration_id": "audit", "reminder_webhook_payload_template": '{"message":"{{message}}"}'}
def deliver(request):
calls.append(request)
return httpx.Response(statuses.pop(0))
real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(deliver), **kw))
monkeypatch.setattr(settings, "load_settings", lambda: channel)
monkeypatch.setattr(integrations, "load_integrations", lambda: [{"id": "audit", "preset": "ntfy", "base_url": "https://reminder.example.test", "enabled": True}])
monkeypatch.setattr(note_routes, "_scheduler_ref", SimpleNamespace(add_notification=lambda **kw: notices.append(kw)))
monkeypatch.setattr(note_routes, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(builtin_actions, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(datetime, "datetime", Clock)
monkeypatch.setattr("src.url_safety.check_outbound_url", lambda *a, **kw: (True, ""))
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
with factory() as db:
db.add(database.Note(id="note-1", owner="alice", title="Due note", due_date=instant.isoformat()))
db.commit()
yield SimpleNamespace(
scan=lambda: builtin_actions.action_ping_notes("alice"),
path=tmp_path / "note_pings_alice.json", calls=calls, notices=notices,
statuses=statuses, channel=channel, instant=instant, factory=factory,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("channel", ["webhook", "ntfy"])
async def test_failed_primary_is_retried_and_receipt_keeps_channel(reminder_scan, channel):
from src.builtin_actions import TaskNoop
scan = reminder_scan
scan.channel["reminder_channel"] = channel
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.calls) == 1
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
message, ok = await scan.scan()
assert ok is True and "Pinged 1" in message
assert len(scan.calls) == 2
assert len(scan.notices) == 1
assert json.loads(scan.path.read_text())["note-1"]["channel"] == channel
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.calls) == 2
@pytest.mark.asyncio
async def test_legacy_browser_receipt_allows_external_retry(reminder_scan):
scan = reminder_scan
scan.path.write_text(json.dumps({"note-1": scan.instant.isoformat()}))
scan.statuses[:] = [200]
message, ok = await scan.scan()
assert ok is True and "Pinged 1" in message
assert len(scan.calls) == 1 and scan.notices == []
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "webhook"
@pytest.mark.asyncio
async def test_browser_delivery_is_deduped_and_typed(reminder_scan):
from src.builtin_actions import TaskNoop
scan = reminder_scan
scan.channel["reminder_channel"] = "browser"
_, ok = await scan.scan()
assert ok is True
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.notices) == 1 and scan.calls == []
@pytest.mark.asyncio
async def test_total_delivery_failure_does_not_checkpoint(reminder_scan, monkeypatch):
from routes import note_routes
from src.builtin_actions import TaskNoop
scan = reminder_scan
monkeypatch.setattr(note_routes, "_scheduler_ref", None)
with pytest.raises(TaskNoop):
await scan.scan()
assert "note-1" not in json.loads(scan.path.read_text())
_, ok = await scan.scan()
assert ok is True and len(scan.calls) == 2
@pytest.mark.asyncio
async def test_pruning_retains_dispatcher_receipt_and_other_due_note(reminder_scan):
from core import database
scan = reminder_scan
with scan.factory() as db:
db.add(database.Note(id="later", owner="alice", title="Later", due_date=(scan.instant + datetime.timedelta(days=1)).isoformat()))
db.commit()
scan.path.write_text(json.dumps({"gone": "old", "later": {"at": scan.instant.isoformat(), "channel": "email"}}))
scan.statuses[:] = [200]
_, ok = await scan.scan()
assert ok is True
cache = json.loads(scan.path.read_text())
assert set(cache) == {"note-1", "later"}
assert cache["note-1"]["channel"] == "webhook"
assert cache["later"]["channel"] == "email"
+45 -11
View File
@@ -30,23 +30,49 @@ def test_retained_artifacts_match_distributed_provenance_and_notices():
def test_removed_resource_ledger_has_no_runtime_references():
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)
+3 -4
View File
@@ -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():
+28
View File
@@ -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
+17
View File
@@ -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)
+61
View File
@@ -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()]
+42
View File
@@ -222,3 +222,45 @@ def test_untrusted_validation_detail_cannot_masquerade_as_curated_guidance(detai
assert "TAKEOVER_SECRET" not in public
assert "/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
+489
View File
@@ -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]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+195 -20
View File
@@ -273,12 +273,87 @@
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
.shot .frame-dots 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 &amp; Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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 &amp; 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 &amp; 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 &amp; 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 &amp; Tasks ]</span></div>
<video muted loop playsinline preload="none"><source src="notes.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg>Notes &amp; Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg><span>[ Image Gallery ]</span></div>
<video muted loop playsinline preload="none"><source src="gallery.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg>Image Gallery</span><span class="desc">Generate, edit, remove backgrounds, and inpaint in your own gallery.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg><span>[ Themes ]</span></div>
<video muted loop playsinline preload="none"><source src="theme.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg>Themes</span><span class="desc">Restyle and make it yours - edit your own, or ask the agent to make one.</span></div>
</div>
</div>
</div>
</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
View File
@@ -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.